gdharness 0.13.35 → 0.13.37

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 (3) hide show
  1. package/build/cli.js +556 -234
  2. package/build/index.js +396 -132
  3. package/package.json +1 -1
package/build/cli.js CHANGED
@@ -133,6 +133,28 @@ function godotCandidates(platform, home) {
133
133
  }
134
134
  var init_detection = () => {};
135
135
 
136
+ // src/errors.ts
137
+ function errorMessage(error, fallback = "Unknown error") {
138
+ if (error instanceof Error) {
139
+ return error.message || fallback;
140
+ }
141
+ if (typeof error === "string") {
142
+ return error || fallback;
143
+ }
144
+ if (typeof error === "number" || typeof error === "boolean") {
145
+ return String(error);
146
+ }
147
+ return fallback;
148
+ }
149
+ function toError(error) {
150
+ return error instanceof Error ? error : new Error(errorMessage(error), { cause: error });
151
+ }
152
+ var Refusal2;
153
+ var init_errors = __esm(() => {
154
+ Refusal2 = class Refusal2 extends Error {
155
+ };
156
+ });
157
+
136
158
  // src/launch.ts
137
159
  function envValue(name, variables = process.env) {
138
160
  const value = variables[name];
@@ -176,7 +198,10 @@ function userDataIn(home, variables = process.env) {
176
198
  }
177
199
  return carried;
178
200
  }
179
- var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER";
201
+ function savesStayPut(platform = process.platform) {
202
+ return platform === "darwin";
203
+ }
204
+ var OPENED_BY_A_SERVER = "GDHARNESS_OPENED_BY_A_SERVER", SAVES_NOT_MOVED_NOTE = "On macOS the engine reads user:// off HOME and ignores the variables that move it elsewhere, measured on Godot 4.7.2, so this run wrote its saves where the player keeps theirs. A suite that saves a game has written into the same folder as the copy being played, and one that tidies up after itself may have removed real saves.";
180
205
 
181
206
  // node_modules/yaml/dist/nodes/identity.js
182
207
  var require_identity = __commonJS(function(exports) {
@@ -7120,28 +7145,6 @@ var require_public_api = __commonJS(function(exports) {
7120
7145
  exports.stringify = stringify;
7121
7146
  });
7122
7147
 
7123
- // src/errors.ts
7124
- function errorMessage(error, fallback = "Unknown error") {
7125
- if (error instanceof Error) {
7126
- return error.message || fallback;
7127
- }
7128
- if (typeof error === "string") {
7129
- return error || fallback;
7130
- }
7131
- if (typeof error === "number" || typeof error === "boolean") {
7132
- return String(error);
7133
- }
7134
- return fallback;
7135
- }
7136
- function toError(error) {
7137
- return error instanceof Error ? error : new Error(errorMessage(error), { cause: error });
7138
- }
7139
- var Refusal2;
7140
- var init_errors = __esm(() => {
7141
- Refusal2 = class Refusal2 extends Error {
7142
- };
7143
- });
7144
-
7145
7148
  // src/runner.ts
7146
7149
  import { existsSync as existsSync3 } from "node:fs";
7147
7150
  import { dirname, join as join2 } from "node:path";
@@ -7200,7 +7203,7 @@ class GameLog {
7200
7203
  };
7201
7204
  partial = { stdout: "", stderr: "", transcript: "" };
7202
7205
  lastHeadline = null;
7203
- cursor = 0;
7206
+ seen = { error: 0, warning: 0, info: 0 };
7204
7207
  append(source, chunk) {
7205
7208
  const decoded = typeof chunk === "string" ? chunk : this.decoders[source].write(chunk);
7206
7209
  const lines = (this.partial[source] + decoded).split(`
@@ -7257,10 +7260,14 @@ class GameLog {
7257
7260
  return this.entries.filter((entry) => entry.severity === severity).length;
7258
7261
  }
7259
7262
  select(options) {
7260
- const floor = options.sinceLastCall ? this.cursor : 0;
7261
- this.cursor = this.entries.length;
7262
- const wanted = new Set(options.severity === "error" ? ["error"] : options.severity === "warning" ? ["error", "warning"] : ["error", "warning", "info"]);
7263
+ const floor = options.sinceLastCall ? this.seen[options.severity] : 0;
7264
+ const wanted = ADMITTED[options.severity];
7263
7265
  const needle = options.contains?.toLowerCase();
7266
+ if (needle === undefined) {
7267
+ for (const severity of wanted) {
7268
+ this.seen[severity] = this.entries.length;
7269
+ }
7270
+ }
7264
7271
  const matching = this.entries.slice(floor).filter((entry) => {
7265
7272
  if (!wanted.has(entry.severity)) {
7266
7273
  return false;
@@ -7274,11 +7281,16 @@ class GameLog {
7274
7281
  return { entries: matching.slice(omitted), omitted };
7275
7282
  }
7276
7283
  }
7277
- var HEADLINE, COLOUR_CODE, STREAMS;
7284
+ var HEADLINE, COLOUR_CODE, STREAMS, ADMITTED;
7278
7285
  var init_game_log = __esm(() => {
7279
7286
  HEADLINE = /^(USER )?(SCRIPT ERROR|ERROR|WARNING):\s?(.*)$/;
7280
7287
  COLOUR_CODE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
7281
7288
  STREAMS = ["stdout", "stderr", "transcript"];
7289
+ ADMITTED = {
7290
+ error: new Set(["error"]),
7291
+ warning: new Set(["error", "warning"]),
7292
+ info: new Set(["error", "warning", "info"])
7293
+ };
7282
7294
  });
7283
7295
 
7284
7296
  // src/scratch.ts
@@ -7311,6 +7323,20 @@ var init_scratch = __esm(() => {
7311
7323
  import { existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync3 } from "node:fs";
7312
7324
  import { homedir as homedir3, tmpdir as tmpdir2 } from "node:os";
7313
7325
  import { join as join5 } from "node:path";
7326
+ function packageUrl(root) {
7327
+ return `${root.replace(/\/+$/, "")}/gdharness/latest`;
7328
+ }
7329
+ function registryFor(environment) {
7330
+ const named = environment["GDHARNESS_REGISTRY"] ?? "";
7331
+ if (named === "") {
7332
+ return packageUrl(REGISTRY_ROOT);
7333
+ }
7334
+ try {
7335
+ return new URL(named).protocol === "https:" ? packageUrl(named) : packageUrl(REGISTRY_ROOT);
7336
+ } catch {
7337
+ return packageUrl(REGISTRY_ROOT);
7338
+ }
7339
+ }
7314
7340
  function cacheDirectory(environment) {
7315
7341
  const set = (name) => {
7316
7342
  const value = environment[name];
@@ -7380,8 +7406,8 @@ function isNewer(candidate, current) {
7380
7406
  }
7381
7407
  return !left.prerelease && right.prerelease;
7382
7408
  }
7383
- async function fetchLatest() {
7384
- const response = await fetch(REGISTRY, {
7409
+ async function fetchLatest(registry) {
7410
+ const response = await fetch(registry, {
7385
7411
  headers: { accept: "application/vnd.npm.install-v1+json, application/json" },
7386
7412
  redirect: "error",
7387
7413
  signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
@@ -7413,16 +7439,19 @@ async function fetchLatest() {
7413
7439
  class UpdateCheck {
7414
7440
  latest = null;
7415
7441
  checking = false;
7442
+ asked = false;
7416
7443
  checkedAt = 0;
7417
7444
  retryAt = 0;
7418
7445
  backoffMs = FIRST_RETRY_MS;
7419
7446
  enabled;
7420
7447
  current;
7421
7448
  cachePath;
7422
- constructor(current, environment = process.env) {
7449
+ ask;
7450
+ constructor(current, environment = process.env, ask = () => fetchLatest(registryFor(environment))) {
7423
7451
  this.current = current;
7424
7452
  this.enabled = (environment["GDHARNESS_NO_UPDATE_CHECK"] ?? "") === "";
7425
7453
  this.cachePath = cacheFile(environment);
7454
+ this.ask = ask;
7426
7455
  const cached = this.enabled ? readCache(this.cachePath) : null;
7427
7456
  if (cached !== null) {
7428
7457
  this.latest = cached.latest;
@@ -7430,11 +7459,12 @@ class UpdateCheck {
7430
7459
  }
7431
7460
  }
7432
7461
  refresh(now = Date.now()) {
7433
- if (!this.enabled || this.checking || now < this.retryAt || !this.stale(now)) {
7462
+ if (!this.enabled || this.checking || now < this.retryAt || this.asked && !this.stale(now)) {
7434
7463
  return;
7435
7464
  }
7465
+ this.asked = true;
7436
7466
  this.checking = true;
7437
- fetchLatest().then((version) => {
7467
+ this.ask().then((version) => {
7438
7468
  if (version === null) {
7439
7469
  this.scheduleRetry(now);
7440
7470
  return;
@@ -7473,10 +7503,10 @@ class UpdateCheck {
7473
7503
  };
7474
7504
  }
7475
7505
  }
7476
- var REGISTRY = "https://registry.npmjs.org/gdharness/latest", RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag", CACHE_MS, REQUEST_TIMEOUT_MS = 1e4, MAX_BODY_BYTES, FIRST_RETRY_MS = 30000, MAX_RETRY_MS, VERSION;
7506
+ var REGISTRY_ROOT = "https://registry.npmjs.org", RELEASES = "https://github.com/Aureliolo/gdharness/releases/tag", CACHE_MS, REQUEST_TIMEOUT_MS = 1e4, MAX_BODY_BYTES, FIRST_RETRY_MS = 30000, MAX_RETRY_MS, VERSION;
7477
7507
  var init_update_check = __esm(() => {
7478
7508
  init_runner();
7479
- CACHE_MS = 4 * 60 * 60 * 1000;
7509
+ CACHE_MS = 10 * 60 * 1000;
7480
7510
  MAX_BODY_BYTES = 1 << 20;
7481
7511
  MAX_RETRY_MS = 60 * 60 * 1000;
7482
7512
  VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
@@ -7625,7 +7655,7 @@ function staleAnalysisNote(contradicted, dependsOn) {
7625
7655
  }
7626
7656
  const types = new Set(contradicted.map((one) => one.type));
7627
7657
  const declaring = [...new Set(contradicted.map((one) => one.declaredIn))].sort();
7628
- const lever = dependsOn.length === 0 ? ", and the named types depend on no other global class, so that lever is not available here. " + "That is the common case rather than the awkward one: an edit near a type that depends on " + "something refreshes it before the fault is noticed, which is why a type with no " + "dependencies is where this turns up" : `, so changing ${[...dependsOn].sort().join(", ")} and rescanning is worth a try`;
7658
+ const lever = dependsOn.length === 0 ? ", and the named types depend on no other global class, so that lever is not available here. " + "That is the common case rather than the awkward one: an edit near a type that depends on " + "something refreshes it before the fault is noticed, which is why a type with no " + "dependencies is where this turns up" : `, so changing ${[...dependsOn].sort().join(" or ")} and then rescanning is worth a try`;
7629
7659
  return `The editor is reporting against an older copy of ${types.size === 1 ? "a type" : "some types"} ` + "named under contradictedByTheFile. Each member listed is declared in the file the class cache " + "points at, so those diagnostics are wrong however the code is written. Run editor_rescan first: " + "it costs about half a second, and it has cleared this in every reproduction measured since the " + "scan timing was fixed: both in one project, the scan returning in 243ms and 275ms, and one in " + "another where the same call also rebuilt the copy. " + "The fault behind it is that a script the editor has loaded keeps the copy it built, and nothing " + `rebuilds that copy when the file changes; ${declaring.length === 1 ? `editor_rescan with reloadScript set to ${declaring[0]} recompiles that copy` : `reloadScript takes one script, so a call each for ${declaring.join(" and ")} recompiles those copies`} from the file into the same ` + "object and answers with the members it has afterwards under reloadedMethods. Read that and the " + "next diagnostics as two readings, because they are of two things: measured in one window on " + "one editor, the analyser resolved a call to a newly added method while the built copy of that " + "same script did not have it. So the reload is worth doing and is not what clears these; the " + "scan is. The other lever measured is that a held type is refreshed when something it " + `depends on changes rather than when it changes itself${lever}. editor_launch restart has always ` + "worked and costs a window. project_import refresh_classes does not, and answers added: [] while " + "this is happening.";
7630
7660
  }
7631
7661
  function uncachedClassNote(types) {
@@ -15048,6 +15078,11 @@ function settingKeys(content) {
15048
15078
  return named;
15049
15079
  }
15050
15080
  function settingsDroppedReport(before, after) {
15081
+ if (before === null || after === null) {
15082
+ return {
15083
+ settingsNote: `project.godot could not be read ${before === null ? "before" : "after"} this, so nothing can` + " be said about what the editor saved away. Opening a project rewrites the file and drops any" + " key sitting at its own default, and this is the only thing that would have named them." + " Read the file yourself if a setting was pinned at a default on purpose."
15084
+ };
15085
+ }
15051
15086
  const dropped = [...before].filter(([key]) => !after.has(key));
15052
15087
  if (dropped.length === 0) {
15053
15088
  return {};
@@ -15665,7 +15700,7 @@ var init_tool_definitions = __esm(() => {
15665
15700
  },
15666
15701
  {
15667
15702
  name: "project_test",
15668
- description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
15703
+ description: "Runs the project's gdUnit4 tests headless and answers with every case that did not pass: where it is, and what the assertion said. Suites where everything passed are counted rather than listed, and an engine message keeps the frames above gdUnit4 rather than the twenty inside it, so a clean tier answers in a few lines. The class list is rebuilt first, so a suite written a moment ago is found. On Windows and Linux the run gets a user:// of its own, so a suite that saves a game writes nowhere near the saves of the copy somebody plays. On macOS the engine reads user:// off HOME and the run writes where the player keeps theirs, which the answer says under savesNote rather than leaving to be found in a save list. A run that found nothing to run is never called a pass: gdUnit4 exits cleanly for one, so the answer says so and names the path it looked in. A run where nothing failed but nodes were left in the tree comes back under warnings, with the count per suite: gdUnit4 decides its verdict on those and keeps them out of its report, so they are read off what it printed. Needs gdUnit4 under addons/gdUnit4.",
15669
15704
  parameters: {
15670
15705
  projectPath: PROJECT_PATH,
15671
15706
  path: {
@@ -15932,7 +15967,7 @@ var init_tool_definitions = __esm(() => {
15932
15967
  },
15933
15968
  {
15934
15969
  name: "editor_launch",
15935
- description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one.",
15970
+ description: "Opens the Godot editor on a project, in a window on this machine, or restarts the one already connected. An editor goes on serving the addon it read at startup, so restart is what puts a gdharness upgrade into effect; it saves open scenes on the way out and answers with the version that came back. Only an editor with a window can be restarted, because the engine hands back none of the arguments it was started with. editor_status says which editor is connected and whether it is holding an old addon. Opening a project is a save and so is closing one, which is the part that is not obvious from outside: the editor imports the project on open and writes project.godot back, and Godot writes only what differs from its own defaults, so a key named deliberately at its default value is dropped either way. A restart says which under settingsDropped in its own answer because it waits for the editor to return. An open cannot, since it answers as soon as the process exists and the save happens during the import minutes later, so editor_status carries that reading once the editor has connected. Nothing else will say a key went until something depends on one. A restart of an editor this server opened is a quit and then a launch, and this server can be ended between them, usually by the reconnect the restart was being taken for: the editor is gone and nothing replaces it. That is written down before the quit, so the next server says so under restartInterrupted in editor_status rather than waiting for an editor that is not coming, and open finishes it, saying so under finishesRestart.",
15936
15971
  parameters: { projectPath: { ...PROJECT_PATH, ops: ["open"] } },
15937
15972
  requires: [],
15938
15973
  operations: {
@@ -15994,7 +16029,7 @@ var init_tool_definitions = __esm(() => {
15994
16029
  },
15995
16030
  {
15996
16031
  name: "editor_output",
15997
- description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: running is asked of the operating system, endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over.",
16032
+ description: "What the project started by editor_run has printed, as entries with a severity: the errors and warnings the engine reported, each with where it happened, and everything else as info. Answers with the counts and the verdict as well as the entries. A run that has quit still answers here, with running false and its exit code, until the next one starts. A run this server started also answers with transcript, the file both its streams are written to: uncapped, written while the run is going, and the thing to read or tail for a long run rather than the engine's own log, which every engine start rotates away. Read the file for output and this answer for state: endedBy says whether gdharness ended the run, and a transcript that has stopped growing is a run between prints rather than a run that is over. running is asked of the operating system whenever there is a process to ask about, which is every run this server started and every editor-played run whose game announced its runtime. For an editor-played run that never announced, there is no process id here and running is the editor's answer, which can lag: one measured elsewhere reported a game as playing for fifteen seconds after its process had been ended from outside. editor_status names that game under runtimes, which does not depend on the editor answering.",
15998
16033
  parameters: {
15999
16034
  severity: {
16000
16035
  type: "string",
@@ -16003,7 +16038,7 @@ var init_tool_definitions = __esm(() => {
16003
16038
  },
16004
16039
  sinceLastCall: {
16005
16040
  type: "boolean",
16006
- description: "Only entries printed since the previous editor_output. Default false."
16041
+ description: "Only entries this has not already answered with at this severity. Default false. Measured per severity floor and not across them, so polling for errors leaves the rest of the output for a later read rather than consuming it, and a read with contains is a search over the whole run and consumes nothing."
16007
16042
  },
16008
16043
  cpu: {
16009
16044
  type: "boolean",
@@ -16022,7 +16057,7 @@ var init_tool_definitions = __esm(() => {
16022
16057
  },
16023
16058
  {
16024
16059
  name: "editor_status",
16025
- description: 'Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means "not reached yet" as often as it means "no editor". True is worth waiting out; false is an editor that is not there. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade: installing moves the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with. Those games are real and running, but no tool here can reach them until the two halves match, so runtimes stays empty and runtimeConnected stays false.',
16060
+ description: 'Whether the editor addon is connected, which Godot answers, whether the editor is playing something, and whether a game with the runtime addon is reachable. playingInEditor is null when the editor was not asked or did not answer, which is not the same as a no: with no editor connected there is nobody to ask, and an editor that does not serve the question is given a moment and then left. A game can be playing while this is null, so read it as unanswered rather than as nothing playing, and use processActive and runtimes, which come from elsewhere and do not depend on the editor answering. When connected is false, mayYetConnect says whether that is final: the editor dials this server rather than the other way round and backs off between tries, so for the first half-minute after a server starts, false means "not reached yet" as often as it means "no editor". True is worth waiting out; false is an editor that is not there. False beside restartInterrupted is one that an editor_launch restart on a previous server asked to quit, where the launch half never ran because that server was ended in between, usually by the reconnect the restart was taken for; editor_launch open finishes it. listeningSince is when this server took the bridge port, which is what that window is measured from and what a caller wanting its own window should read. An editor this server opened counts for as long as its process lives, however far past that window the import runs, and awaitingLaunchedEditor names its pid so the two reasons for true are told apart. settingsDropped appears here once when the editor this server opened saved project.godot on its way in, which is the same reading editor_launch restart makes in its own answer. A game that has announced itself in a protocol this server does not speak is listed under unreadable rather than left out, with the protocol it speaks and whether the server or the addon is the older half. This happens during an upgrade that moves the runtime protocol, which most do not: installing replaces the addon on disk as soon as the pin moves, while a server that is already running stays on the version it started with, so the two speak different protocols until the server is reconnected. Those games are real and running, but no tool here can reach them until the halves match, so runtimes stays empty and runtimeConnected stays false. An upgrade that leaves the protocol alone opens no such window and this list stays empty through it.',
16026
16061
  parameters: {},
16027
16062
  requires: []
16028
16063
  },
@@ -35225,57 +35260,77 @@ import { execFile as execFile4 } from "node:child_process";
35225
35260
  import { existsSync as existsSync11 } from "node:fs";
35226
35261
  import { normalize as normalize2 } from "node:path";
35227
35262
  import { promisify as promisify4 } from "node:util";
35228
- var run4, GodotLocator2;
35229
- var init_godot_path = __esm(() => {
35230
- init_detection();
35231
- run4 = promisify4(execFile4);
35232
- GodotLocator2 = class GodotLocator2 {
35233
- answered = new Map;
35234
- found = null;
35235
- async find() {
35236
- if (this.found !== null && await this.answers(this.found)) {
35237
- return this.found;
35238
- }
35239
- this.found = null;
35240
- const named = envValue("GODOT_PATH");
35241
- if (named !== undefined) {
35242
- const path = normalize2(named);
35243
- if (await this.answers(path)) {
35244
- this.found = path;
35245
- }
35246
- return this.found;
35247
- }
35248
- for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
35249
- const path = normalize2(candidate);
35250
- if (await this.answers(path)) {
35251
- this.found = path;
35252
- return path;
35253
- }
35263
+
35264
+ class GodotLocator2 {
35265
+ answered = new Map;
35266
+ found = null;
35267
+ async find() {
35268
+ if (this.found !== null && await this.whyNot(this.found) === null) {
35269
+ return { ok: true, path: this.found };
35270
+ }
35271
+ this.found = null;
35272
+ const named = envValue("GODOT_PATH");
35273
+ if (named !== undefined) {
35274
+ const path = normalize2(named);
35275
+ const reason = await this.whyNot(path);
35276
+ if (reason === null) {
35277
+ this.found = path;
35278
+ return { ok: true, path };
35254
35279
  }
35255
- return null;
35280
+ return { ok: false, named: path, reason };
35256
35281
  }
35257
- static ADVICE = [
35258
- "Set GODOT_PATH to the Godot 4 executable",
35259
- "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
35260
- ];
35261
- async answers(path) {
35262
- const known = this.answered.get(path);
35263
- if (known !== undefined) {
35264
- return known;
35282
+ for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
35283
+ const path = normalize2(candidate);
35284
+ if (await this.whyNot(path) === null) {
35285
+ this.found = path;
35286
+ return { ok: true, path };
35265
35287
  }
35266
- let ok = false;
35267
- if (path === "godot" || existsSync11(path)) {
35268
- try {
35269
- await run4(path, ["--version"]);
35270
- ok = true;
35271
- } catch {
35272
- ok = false;
35273
- }
35288
+ }
35289
+ return { ok: false, named: null };
35290
+ }
35291
+ static refusal(located) {
35292
+ if (located.named === null) {
35293
+ return {
35294
+ message: "No Godot executable found.",
35295
+ advice: [
35296
+ "Set GODOT_PATH to the Godot 4 executable",
35297
+ "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
35298
+ ]
35299
+ };
35300
+ }
35301
+ return {
35302
+ message: `GODOT_PATH is set to ${located.named}, which ${located.reason ?? "does not answer"}.`,
35303
+ advice: [
35304
+ "Point GODOT_PATH at a Godot 4 executable that answers --version",
35305
+ "Or unset it, and the usual install locations are searched instead"
35306
+ ]
35307
+ };
35308
+ }
35309
+ async whyNot(path) {
35310
+ const known = this.answered.get(path);
35311
+ if (known !== undefined) {
35312
+ return known;
35313
+ }
35314
+ let reason;
35315
+ if (path !== "godot" && !existsSync11(path)) {
35316
+ reason = "does not exist";
35317
+ } else {
35318
+ try {
35319
+ await run4(path, ["--version"]);
35320
+ reason = null;
35321
+ } catch (error) {
35322
+ reason = `does not answer --version: ${errorMessage(error)}`;
35274
35323
  }
35275
- this.answered.set(path, ok);
35276
- return ok;
35277
35324
  }
35278
- };
35325
+ this.answered.set(path, reason);
35326
+ return reason;
35327
+ }
35328
+ }
35329
+ var run4;
35330
+ var init_godot_path = __esm(() => {
35331
+ init_detection();
35332
+ init_errors();
35333
+ run4 = promisify4(execFile4);
35279
35334
  });
35280
35335
 
35281
35336
  // src/issues.ts
@@ -35344,15 +35399,36 @@ function filledTemplate2(where, message, godotVersion) {
35344
35399
  ].join(`
35345
35400
  `);
35346
35401
  }
35347
- function defectReport2(where, error, godotVersion) {
35402
+
35403
+ class DefectsSeen {
35404
+ counts = new Map;
35405
+ before(where, message) {
35406
+ const signature = defectSignature2(where, message);
35407
+ const seen = this.counts.get(signature) ?? 0;
35408
+ this.counts.set(signature, seen + 1);
35409
+ return seen;
35410
+ }
35411
+ }
35412
+ function defectReport2(where, error, context = {}) {
35413
+ const { godotVersion, seenBefore = 0 } = context;
35348
35414
  const message = errorMessage(error);
35349
35415
  const title = `Defect ${defectSignature2(where, message)}: ${where}`;
35350
35416
  const url = `${NEW_ISSUE2}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate2(where, message, godotVersion))}`;
35351
35417
  const width = Math.max(...facts2(where, message, godotVersion).map(([label]) => (label ?? "").length));
35352
35418
  return [
35353
- "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
35354
- "anything about the call: the arguments did not cause it, and sending it again will not",
35355
- "change it.",
35419
+ "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
35420
+ "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
35421
+ "in gdharness however the rest of it turns out.",
35422
+ "",
35423
+ ...seenBefore === 0 ? [
35424
+ "Send the call once more before anything else. Some of what reaches this line is a file",
35425
+ "another process had open, or a connection that dropped, and those are gone by the next",
35426
+ "attempt. If the same Signature comes back, it is not one of those, and repeating it",
35427
+ "further only spends turns."
35428
+ ] : [
35429
+ `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
35430
+ "state rather than a passing one, and sending the call again will not change it."
35431
+ ],
35356
35432
  "",
35357
35433
  ...facts2(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
35358
35434
  "",
@@ -36315,11 +36391,53 @@ var init_project_scan = __esm(() => {
36315
36391
  CARRIES_A_UID = new Set(["gd", "shader", "gdshader"]);
36316
36392
  });
36317
36393
 
36394
+ // src/restart-note.ts
36395
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync13, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
36396
+ import { dirname as dirname9, join as join15 } from "node:path";
36397
+ function restartNotePath(projectPath) {
36398
+ return join15(projectPath, ".godot", "gdharness-restart.json");
36399
+ }
36400
+ function noteRestartBegun(note) {
36401
+ const path = restartNotePath(note.projectPath);
36402
+ try {
36403
+ mkdirSync7(dirname9(path), { recursive: true });
36404
+ writeFileSync9(path, `${JSON.stringify(note, null, 2)}
36405
+ `, "utf8");
36406
+ } catch (error) {
36407
+ console.error(`[SERVER] Could not record the restart at ${path}: ${errorMessage(error)}`);
36408
+ }
36409
+ }
36410
+ function restartOwed(projectPath) {
36411
+ try {
36412
+ const parsed = JSON.parse(readFileSync13(restartNotePath(projectPath), "utf8"));
36413
+ if (typeof parsed !== "object" || parsed === null) {
36414
+ return null;
36415
+ }
36416
+ const said = parsed;
36417
+ if (typeof said.quitAt !== "string" || typeof said.byPid !== "number" || typeof said.ports !== "object") {
36418
+ return null;
36419
+ }
36420
+ return said;
36421
+ } catch {
36422
+ return null;
36423
+ }
36424
+ }
36425
+ function restartSettled(projectPath) {
36426
+ try {
36427
+ rmSync7(restartNotePath(projectPath), { force: true });
36428
+ } catch (error) {
36429
+ console.error(`[SERVER] Could not clear the restart note: ${errorMessage(error)}`);
36430
+ }
36431
+ }
36432
+ var init_restart_note = __esm(() => {
36433
+ init_errors();
36434
+ });
36435
+
36318
36436
  // src/runtime-client.ts
36319
- import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync13, unlinkSync } from "node:fs";
36437
+ import { existsSync as existsSync13, readdirSync as readdirSync5, readFileSync as readFileSync14, unlinkSync } from "node:fs";
36320
36438
  import { createConnection as createConnection3 } from "node:net";
36321
36439
  import { tmpdir as tmpdir4 } from "node:os";
36322
- import { join as join15, resolve as resolve4 } from "node:path";
36440
+ import { join as join16, resolve as resolve4 } from "node:path";
36323
36441
  import { setTimeout as delay2 } from "node:timers/promises";
36324
36442
  function runtimeDirectory(variables = process.env) {
36325
36443
  const explicit = envValue("GDHARNESS_RUNTIME_DIR", variables);
@@ -36327,13 +36445,13 @@ function runtimeDirectory(variables = process.env) {
36327
36445
  return explicit;
36328
36446
  }
36329
36447
  const perUser = envValue("XDG_RUNTIME_DIR", variables);
36330
- return join15(perUser ?? tmpdir4(), "gdharness");
36448
+ return join16(perUser ?? tmpdir4(), "gdharness");
36331
36449
  }
36332
36450
  function runtimeDirectories(variables = process.env) {
36333
36451
  const candidates = [runtimeDirectory(variables)];
36334
36452
  const fallbacks = process.platform === "win32" ? [envValue("SystemRoot", variables) ?? envValue("windir", variables) ?? "C:\\Windows"] : ["/tmp", "/var/tmp"];
36335
36453
  for (const base of fallbacks) {
36336
- candidates.push(join15(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36454
+ candidates.push(join16(base, process.platform === "win32" ? "Temp" : "", "gdharness"));
36337
36455
  }
36338
36456
  return [...new Set(candidates.map((path) => resolve4(path)))];
36339
36457
  }
@@ -36348,7 +36466,7 @@ function processAlive(pid) {
36348
36466
  function parseAnnouncement(file, pid) {
36349
36467
  let fields;
36350
36468
  try {
36351
- fields = asParams(JSON.parse(readFileSync13(file, "utf8")));
36469
+ fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36352
36470
  } catch {
36353
36471
  return { kind: "rubbish" };
36354
36472
  }
@@ -36386,6 +36504,28 @@ function runtimesAnnounced(directories = runtimeDirectories()) {
36386
36504
  function discoverRuntimes(directories = runtimeDirectories()) {
36387
36505
  return runtimesAnnounced(directories).running;
36388
36506
  }
36507
+ function wentAway(file, pid) {
36508
+ let project = "";
36509
+ try {
36510
+ const fields = asParams(JSON.parse(readFileSync14(file, "utf8")));
36511
+ project = readString(readParams(fields, "project") ?? {}, "path") ?? "";
36512
+ } catch {
36513
+ return;
36514
+ }
36515
+ if (project === "") {
36516
+ return;
36517
+ }
36518
+ gone.unshift({ pid, project, noticedAt: Date.now() });
36519
+ gone.length = Math.min(gone.length, GONE_KEPT);
36520
+ }
36521
+ function announcedAndGone(projectPath) {
36522
+ if (projectPath === undefined) {
36523
+ return [];
36524
+ }
36525
+ const since = Date.now() - GONE_WINDOW_MS;
36526
+ const wanted = resolve4(projectPath);
36527
+ return gone.filter((one) => one.noticedAt >= since && resolve4(one.project) === wanted);
36528
+ }
36389
36529
  function announcedIn(directory) {
36390
36530
  if (!existsSync13(directory)) {
36391
36531
  return [];
@@ -36396,10 +36536,14 @@ function announcedIn(directory) {
36396
36536
  if (!match) {
36397
36537
  continue;
36398
36538
  }
36399
- const file = join15(directory, entry);
36539
+ const file = join16(directory, entry);
36400
36540
  const pid = Number.parseInt(match[1] ?? "", 10);
36401
- const announced = processAlive(pid) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
36541
+ const alive = processAlive(pid);
36542
+ const announced = alive ? parseAnnouncement(file, pid) : { kind: "rubbish" };
36402
36543
  if (announced.kind === "rubbish") {
36544
+ if (!alive) {
36545
+ wentAway(file, pid);
36546
+ }
36403
36547
  try {
36404
36548
  unlinkSync(file);
36405
36549
  } catch {}
@@ -36438,6 +36582,16 @@ function chooseRuntime(endpoints, projectPath, unspoken = []) {
36438
36582
  if (unspoken.length > 0) {
36439
36583
  return { problem: tooNew(unspoken) };
36440
36584
  }
36585
+ const went = announcedAndGone(projectPath);
36586
+ const first = went[0];
36587
+ if (first !== undefined) {
36588
+ const ago = Math.max(1, Math.round((Date.now() - first.noticedAt) / 1000));
36589
+ const others = went.length - 1;
36590
+ const earlier = others === 0 ? "" : ` ${others} earlier ${others === 1 ? "one" : "ones"} went the same way.`;
36591
+ return {
36592
+ problem: `A game with the runtime addon announced itself and its process is gone: pid ${first.pid}` + `${first.project === "" ? "" : ` for ${first.project}`}, noticed ${ago}s ago.${earlier}` + " It quit or was ended rather than never starting, so editor_output has what it printed" + " on the way, including whatever it broke on. Start another with editor_run once you have read it."
36593
+ };
36594
+ }
36441
36595
  return {
36442
36596
  problem: "No game with the runtime addon is running. Start one with editor_run, or play the project from the editor with the addon enabled."
36443
36597
  };
@@ -36551,43 +36705,44 @@ function runtimeRequest(endpoint, command, params, timeoutMs) {
36551
36705
  });
36552
36706
  });
36553
36707
  }
36554
- var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
36708
+ var RUNTIME_PROTOCOL = 2, ANNOUNCEMENT_PATTERN, GONE_WINDOW_MS = 120000, GONE_KEPT = 32, gone, LOOK_EVERY_MS = 50, ANNOUNCE_BUDGET_MS = 5000, nextRequestId = 1;
36555
36709
  var init_runtime_client = __esm(() => {
36556
36710
  ANNOUNCEMENT_PATTERN = /^runtime-(\d+)\.json$/;
36711
+ gone = [];
36557
36712
  });
36558
36713
 
36559
36714
  // src/run-record.ts
36560
36715
  import { execFileSync } from "node:child_process";
36561
- import { mkdirSync as mkdirSync7, openSync, readdirSync as readdirSync6, readFileSync as readFileSync14, rmSync as rmSync7, statSync as statSync3, writeFileSync as writeFileSync9 } from "node:fs";
36562
- import { basename as basename2, join as join16 } from "node:path";
36716
+ import { mkdirSync as mkdirSync8, openSync, readdirSync as readdirSync6, readFileSync as readFileSync15, rmSync as rmSync8, statSync as statSync3, writeFileSync as writeFileSync10 } from "node:fs";
36717
+ import { basename as basename2, join as join17 } from "node:path";
36563
36718
  function runsDirectory() {
36564
- return join16(runtimeDirectory(), "runs");
36719
+ return join17(runtimeDirectory(), "runs");
36565
36720
  }
36566
36721
  function recordPath() {
36567
- return join16(runsDirectory(), "run.json");
36722
+ return join17(runsDirectory(), "run.json");
36568
36723
  }
36569
36724
  function recordPaths() {
36570
- return runtimeDirectories().map((directory) => join16(directory, "runs", "run.json"));
36725
+ return runtimeDirectories().map((directory) => join17(directory, "runs", "run.json"));
36571
36726
  }
36572
36727
  function openTranscript(startedAt) {
36573
- mkdirSync7(runsDirectory(), { recursive: true });
36574
- const path = join16(runsDirectory(), `run-${startedAt}.log`);
36728
+ mkdirSync8(runsDirectory(), { recursive: true });
36729
+ const path = join17(runsDirectory(), `run-${startedAt}.log`);
36575
36730
  return { path, fd: openSync(path, "a") };
36576
36731
  }
36577
36732
  function writeRunRecord(record) {
36578
- mkdirSync7(runsDirectory(), { recursive: true });
36579
- writeFileSync9(recordPath(), JSON.stringify(record, null, 2), "utf8");
36733
+ mkdirSync8(runsDirectory(), { recursive: true });
36734
+ writeFileSync10(recordPath(), JSON.stringify(record, null, 2), "utf8");
36580
36735
  }
36581
36736
  function editorNotePath() {
36582
- return join16(runsDirectory(), "editor-run.json");
36737
+ return join17(runsDirectory(), "editor-run.json");
36583
36738
  }
36584
36739
  function writeEditorRunNote(note) {
36585
- mkdirSync7(runsDirectory(), { recursive: true });
36586
- writeFileSync9(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36740
+ mkdirSync8(runsDirectory(), { recursive: true });
36741
+ writeFileSync10(editorNotePath(), JSON.stringify(note, null, 2), "utf8");
36587
36742
  }
36588
36743
  function readEditorRunNote() {
36589
36744
  for (const directory of runtimeDirectories()) {
36590
- const note = editorNoteAt(join16(directory, "runs", "editor-run.json"));
36745
+ const note = editorNoteAt(join17(directory, "runs", "editor-run.json"));
36591
36746
  if (note !== null) {
36592
36747
  return note;
36593
36748
  }
@@ -36597,7 +36752,7 @@ function readEditorRunNote() {
36597
36752
  function editorNoteAt(path) {
36598
36753
  let parsed;
36599
36754
  try {
36600
- parsed = JSON.parse(readFileSync14(path, "utf8"));
36755
+ parsed = JSON.parse(readFileSync15(path, "utf8"));
36601
36756
  } catch {
36602
36757
  return null;
36603
36758
  }
@@ -36620,7 +36775,7 @@ function recordRunEnded(pid, exitCode) {
36620
36775
  return;
36621
36776
  }
36622
36777
  try {
36623
- writeFileSync9(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36778
+ writeFileSync10(path, JSON.stringify({ ...record, exitCode }, null, 2), "utf8");
36624
36779
  } catch {}
36625
36780
  }
36626
36781
  function readRunRecord() {
@@ -36635,7 +36790,7 @@ function readRunRecord() {
36635
36790
  function recordAt(path) {
36636
36791
  let parsed;
36637
36792
  try {
36638
- parsed = JSON.parse(readFileSync14(path, "utf8"));
36793
+ parsed = JSON.parse(readFileSync15(path, "utf8"));
36639
36794
  } catch {
36640
36795
  return null;
36641
36796
  }
@@ -36662,23 +36817,32 @@ function recordAt(path) {
36662
36817
  }
36663
36818
  function runningAs(pid) {
36664
36819
  if (process.platform === "win32") {
36665
- const line = windowsCommandLine(pid);
36666
- if (line !== null) {
36667
- return { kind: "commandLine", text: line };
36820
+ const answer = windowsCommandLine(pid);
36821
+ if (answer !== null) {
36822
+ const [, ...rest] = answer.split(`
36823
+ `);
36824
+ const line = rest.join(`
36825
+ `).trim();
36826
+ const began = startedAt(pid, answer);
36827
+ if (line !== "") {
36828
+ return { kind: "commandLine", text: line, ...began === null ? {} : { startedAt: began } };
36829
+ }
36668
36830
  }
36669
36831
  const image = windowsImage(pid);
36670
36832
  return image === null ? null : { kind: "image", text: image };
36671
36833
  }
36672
36834
  try {
36835
+ const began = startedAt(pid, null);
36836
+ const when = began === null ? {} : { startedAt: began };
36673
36837
  if (process.platform === "linux") {
36674
- const raw = readFileSync14(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36675
- return raw === "" ? null : { kind: "commandLine", text: raw };
36838
+ const raw = readFileSync15(`/proc/${pid}/cmdline`, "utf8").replaceAll("\x00", " ").trim();
36839
+ return raw === "" ? null : { kind: "commandLine", text: raw, ...when };
36676
36840
  }
36677
36841
  const args = execFileSync("ps", ["-p", String(pid), "-o", "args="], {
36678
36842
  encoding: "utf8",
36679
36843
  timeout: 15000
36680
36844
  }).trim();
36681
- return args === "" ? null : { kind: "commandLine", text: args };
36845
+ return args === "" ? null : { kind: "commandLine", text: args, ...when };
36682
36846
  } catch {
36683
36847
  return null;
36684
36848
  }
@@ -36717,13 +36881,30 @@ function windowsCommandLine(pid) {
36717
36881
  "-NoProfile",
36718
36882
  "-NonInteractive",
36719
36883
  "-Command",
36720
- `(Get-CimInstance Win32_Process -Filter "ProcessId=${pid}").CommandLine`
36884
+ `$p = Get-CimInstance Win32_Process -Filter "ProcessId=${pid}"; if ($p) { $p.CreationDate.ToUniversalTime().ToString("o"); $p.CommandLine }`
36721
36885
  ], { encoding: "utf8", timeout: 15000, windowsHide: true }).trim();
36722
36886
  return answer === "" ? null : answer;
36723
36887
  } catch {
36724
36888
  return null;
36725
36889
  }
36726
36890
  }
36891
+ function startedAt(pid, windowsAnswer) {
36892
+ if (process.platform === "win32") {
36893
+ const when = Date.parse(windowsAnswer?.split(`
36894
+ `)[0]?.trim() ?? "");
36895
+ return Number.isNaN(when) ? null : when;
36896
+ }
36897
+ try {
36898
+ const said = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
36899
+ encoding: "utf8",
36900
+ timeout: 15000
36901
+ }).trim();
36902
+ const when = Date.parse(said);
36903
+ return said === "" || Number.isNaN(when) ? null : when;
36904
+ } catch {
36905
+ return null;
36906
+ }
36907
+ }
36727
36908
  function windowsImage(pid) {
36728
36909
  try {
36729
36910
  const csv = execFileSync("tasklist", ["/FI", `PID eq ${pid}`, "/FO", "CSV", "/NH"], {
@@ -36746,6 +36927,9 @@ function judgeRun(record, running, needed) {
36746
36927
  if (running === null) {
36747
36928
  return false;
36748
36929
  }
36930
+ if (running.startedAt !== undefined && running.startedAt - record.startedAt > SAME_RUN_WINDOW_MS) {
36931
+ return false;
36932
+ }
36749
36933
  const engine = record.command === undefined ? null : basename2(record.command);
36750
36934
  const said = process.platform === "win32" ? running.text.toLowerCase() : running.text;
36751
36935
  const wanted = engine === null || process.platform !== "win32" ? engine : engine.toLowerCase();
@@ -36771,7 +36955,7 @@ function clearRunRecord(ours = () => true) {
36771
36955
  continue;
36772
36956
  }
36773
36957
  try {
36774
- rmSync7(path);
36958
+ rmSync8(path);
36775
36959
  } catch {}
36776
36960
  }
36777
36961
  }
@@ -36795,16 +36979,16 @@ function sweepTranscripts(keepMs = 24 * 60 * 60 * 1000, now = Date.now()) {
36795
36979
  if (!entry.startsWith("run-") || !entry.endsWith(".log")) {
36796
36980
  continue;
36797
36981
  }
36798
- const path = join16(directory, entry);
36982
+ const path = join17(directory, entry);
36799
36983
  if (path === keep || ageMs(path, now) < keepMs) {
36800
36984
  continue;
36801
36985
  }
36802
36986
  try {
36803
- rmSync7(path);
36987
+ rmSync8(path);
36804
36988
  } catch {}
36805
36989
  }
36806
36990
  }
36807
- var AN_EDITOR;
36991
+ var AN_EDITOR, SAME_RUN_WINDOW_MS = 1e4;
36808
36992
  var init_run_record = __esm(() => {
36809
36993
  init_runtime_client();
36810
36994
  AN_EDITOR = /(?:^|\s)(?:-e|--editor)(?:\s|$)/;
@@ -36818,15 +37002,15 @@ import {
36818
37002
  closeSync,
36819
37003
  existsSync as existsSync14,
36820
37004
  fstatSync,
36821
- mkdirSync as mkdirSync8,
37005
+ mkdirSync as mkdirSync9,
36822
37006
  mkdtempSync as mkdtempSync3,
36823
37007
  openSync as openSync2,
36824
37008
  readdirSync as readdirSync7,
36825
- readFileSync as readFileSync15,
37009
+ readFileSync as readFileSync16,
36826
37010
  readSync
36827
37011
  } from "node:fs";
36828
37012
  import { tmpdir as tmpdir5 } from "node:os";
36829
- import { basename as basename3, dirname as dirname9, join as join17, normalize as normalize3 } from "node:path";
37013
+ import { basename as basename3, dirname as dirname10, join as join18, normalize as normalize3 } from "node:path";
36830
37014
  import { setTimeout as delay3 } from "node:timers/promises";
36831
37015
  import { fileURLToPath as fileURLToPath4 } from "node:url";
36832
37016
  import { promisify as promisify6 } from "node:util";
@@ -36840,12 +37024,18 @@ function stillRunning(run) {
36840
37024
  if (!run.throughEditor && run.process === null && run.pid !== null) {
36841
37025
  return alive(run.pid);
36842
37026
  }
37027
+ if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
37028
+ return false;
37029
+ }
36843
37030
  return true;
36844
37031
  }
36845
37032
  function runIsUp(run, editorSays) {
36846
37033
  if (run?.throughEditor !== true) {
36847
37034
  return stillRunning(run);
36848
37035
  }
37036
+ if (run.announcedPid !== undefined && !alive(run.announcedPid)) {
37037
+ return false;
37038
+ }
36849
37039
  return editorSays ?? stillRunning(run);
36850
37040
  }
36851
37041
  function runtimeVerdict(endpoint, after) {
@@ -36975,7 +37165,7 @@ function aboveTheRunner(entry) {
36975
37165
  }
36976
37166
  function elsewhereIn(projectPath, asked) {
36977
37167
  const wanted = asked.replace(/^res:\/\//, "").replace(/\/+$/, "");
36978
- if (wanted === "" || existsSync14(join17(projectPath, wanted))) {
37168
+ if (wanted === "" || existsSync14(join18(projectPath, wanted))) {
36979
37169
  return "";
36980
37170
  }
36981
37171
  const root = (wanted.split("/")[0] ?? "").toLowerCase();
@@ -36989,11 +37179,11 @@ function elsewhereIn(projectPath, asked) {
36989
37179
  return near.length === 0 ? "" : ` The project does have ${there}.`;
36990
37180
  }
36991
37181
  function hasMainScene(projectFile) {
36992
- const scene = parseProjectGodot(readFileSync15(projectFile, "utf8"))["application"]?.["run/main_scene"];
37182
+ const scene = parseProjectGodot(readFileSync16(projectFile, "utf8"))["application"]?.["run/main_scene"];
36993
37183
  return typeof scene === "string" && scene !== "";
36994
37184
  }
36995
37185
  function editorPlaysHeadless(projectFile) {
36996
- const runArgs = parseProjectGodot(readFileSync15(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
37186
+ const runArgs = parseProjectGodot(readFileSync16(projectFile, "utf8"))["editor"]?.["run/main_run_args"];
36997
37187
  return typeof runArgs === "string" && /(?:^|\s)--headless(?:\s|$)/.test(runArgs);
36998
37188
  }
36999
37189
  function camelCased(params) {
@@ -37009,7 +37199,7 @@ function camelCased(params) {
37009
37199
  class GodotServer {
37010
37200
  mcp;
37011
37201
  locator = new GodotLocator2;
37012
- operationsScript = join17(__dirname2, "godot", "operations", "godot_operations.gd");
37202
+ operationsScript = join18(__dirname2, "godot", "operations", "godot_operations.gd");
37013
37203
  godotBridge;
37014
37204
  tools = buildToolDefinitions();
37015
37205
  activeProcess = null;
@@ -37017,6 +37207,8 @@ class GodotServer {
37017
37207
  noticedUpdate = false;
37018
37208
  callsSinceNotice = 0;
37019
37209
  callsSinceFeedback = 0;
37210
+ defectsSeen = new DefectsSeen;
37211
+ lastGodotVersion = null;
37020
37212
  lspClient = null;
37021
37213
  dapClient = null;
37022
37214
  bridgeStartupError = null;
@@ -37061,11 +37253,11 @@ class GodotServer {
37061
37253
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
37062
37254
  }
37063
37255
  async run() {
37064
- const godotPath = await this.locator.find();
37065
- if (godotPath === null) {
37066
- console.error("[SERVER] No Godot found. Set GODOT_PATH; until then every tool that runs the engine will say so.");
37256
+ const located = await this.locator.find();
37257
+ if (!located.ok) {
37258
+ console.error(`[SERVER] ${GodotLocator2.refusal(located).message} Until that is fixed every tool that runs the engine will say so.`);
37067
37259
  } else {
37068
- console.error(`[SERVER] Using Godot at: ${godotPath}`);
37260
+ console.error(`[SERVER] Using Godot at: ${located.path}`);
37069
37261
  }
37070
37262
  const transport = new StdioServerTransport;
37071
37263
  await this.mcp.server.connect(transport);
@@ -37265,7 +37457,19 @@ class GodotServer {
37265
37457
  }
37266
37458
  const where = op === "" ? tool : `${tool} op=${op}`;
37267
37459
  console.error(`[SERVER] Unmodelled failure in ${where}:`, error);
37268
- return { content: [{ type: "text", text: defectReport2(where, error) }], isError: true };
37460
+ const seenBefore = this.defectsSeen.before(where, errorMessage(error));
37461
+ return {
37462
+ content: [
37463
+ {
37464
+ type: "text",
37465
+ text: defectReport2(where, error, {
37466
+ seenBefore,
37467
+ godotVersion: this.lastGodotVersion ?? undefined
37468
+ })
37469
+ }
37470
+ ],
37471
+ isError: true
37472
+ };
37269
37473
  }
37270
37474
  }
37271
37475
  withUpdateNotice(answer) {
@@ -37626,21 +37830,19 @@ class GodotServer {
37626
37830
  }
37627
37831
  }
37628
37832
  async engine() {
37629
- const godotPath = await this.locator.find();
37630
- if (godotPath === null) {
37631
- return {
37632
- ok: false,
37633
- response: this.createErrorResponse("No Godot executable found.", GodotLocator2.ADVICE)
37634
- };
37833
+ const located = await this.locator.find();
37834
+ if (!located.ok) {
37835
+ const refusal = GodotLocator2.refusal(located);
37836
+ return { ok: false, response: this.createErrorResponse(refusal.message, [...refusal.advice]) };
37635
37837
  }
37636
- return { ok: true, value: godotPath };
37838
+ return { ok: true, value: located.path };
37637
37839
  }
37638
37840
  project(args) {
37639
37841
  const path = readNonEmptyString(args, "projectPath");
37640
37842
  if (path === undefined) {
37641
37843
  return { ok: false, response: this.createErrorResponse("projectPath is required.") };
37642
37844
  }
37643
- const file = join17(path, "project.godot");
37845
+ const file = join18(path, "project.godot");
37644
37846
  if (!existsSync14(file)) {
37645
37847
  return {
37646
37848
  ok: false,
@@ -37782,7 +37984,7 @@ class GodotServer {
37782
37984
  }
37783
37985
  const engine = await this.engine();
37784
37986
  if (!engine.ok) {
37785
- return this.createErrorResponse("No Godot executable found.");
37987
+ return engine.response;
37786
37988
  }
37787
37989
  const projectPath = project.value.path;
37788
37990
  const before = scriptsWithoutUid(projectPath);
@@ -37805,7 +38007,11 @@ class GodotServer {
37805
38007
  async operation(operation, params, projectPath) {
37806
38008
  const engine = await this.engine();
37807
38009
  if (!engine.ok) {
37808
- return { ok: false, message: "No Godot executable found.", messages: [] };
38010
+ return {
38011
+ ok: false,
38012
+ message: engine.response.content.map((block) => block.text).join(" "),
38013
+ messages: []
38014
+ };
37809
38015
  }
37810
38016
  this.logDebug(`Running ${operation} in ${projectPath}: ${JSON.stringify(params)}`);
37811
38017
  return await runOperation2({ godotPath: engine.value, script: this.operationsScript, debug: GODOT_DEBUG_MODE_DEFAULT }, operation, params, projectPath);
@@ -37837,7 +38043,7 @@ class GodotServer {
37837
38043
  if (!engine.ok) {
37838
38044
  return engine.response;
37839
38045
  }
37840
- const application = parseProjectGodot(readFileSync15(project.value.file, "utf8"))["application"] ?? {};
38046
+ const application = parseProjectGodot(readFileSync16(project.value.file, "utf8"))["application"] ?? {};
37841
38047
  const name = application["config/name"];
37842
38048
  const mainScene = application["run/main_scene"];
37843
38049
  const info = {
@@ -37898,18 +38104,18 @@ class GodotServer {
37898
38104
  if (!engine.ok) {
37899
38105
  return engine.response;
37900
38106
  }
37901
- const folder = dirname9(output.absolutePath);
38107
+ const folder = dirname10(output.absolutePath);
37902
38108
  try {
37903
- mkdirSync8(folder, { recursive: true });
38109
+ mkdirSync9(folder, { recursive: true });
37904
38110
  } catch (error) {
37905
- return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
38111
+ return this.createErrorResponse(`Cannot create ${dirname10(output.relativePath)} to export into: ${errorMessage(error)}`);
37906
38112
  }
37907
38113
  const debug = readBoolean(args, "debug") ?? false;
37908
- const exportLogs = mkdtempSync3(join17(tmpdir5(), "gdharness-export-"));
38114
+ const exportLogs = mkdtempSync3(join18(tmpdir5(), "gdharness-export-"));
37909
38115
  const exportArgs = [
37910
38116
  "--headless",
37911
38117
  "--log-file",
37912
- join17(exportLogs, "engine.log"),
38118
+ join18(exportLogs, "engine.log"),
37913
38119
  "--path",
37914
38120
  project.value.path,
37915
38121
  debug ? "--export-debug" : "--export-release",
@@ -37967,7 +38173,7 @@ class GodotServer {
37967
38173
  return contained.response;
37968
38174
  }
37969
38175
  const runner = "addons/gdUnit4/bin/GdUnitCmdTool.gd";
37970
- if (!existsSync14(join17(project.value.path, runner))) {
38176
+ if (!existsSync14(join18(project.value.path, runner))) {
37971
38177
  return this.createErrorResponse(`gdUnit4 is not installed in this project: no ${runner}.`, [
37972
38178
  "Install gdUnit4 under addons/gdUnit4, from https://github.com/godot-gdunit-labs/gdUnit4"
37973
38179
  ]);
@@ -38002,7 +38208,7 @@ class GodotServer {
38002
38208
  ];
38003
38209
  const timeoutMs = readPositiveNumber(args, "timeoutMs") ?? 600000;
38004
38210
  this.logDebug(`Running tests: ${engine.value} ${cmdArgs.join(" ")}`);
38005
- const userData = mkdtempSync3(join17(tmpdir5(), "gdharness-tests-"));
38211
+ const userData = mkdtempSync3(join18(tmpdir5(), "gdharness-tests-"));
38006
38212
  const run = this.spawnGame(engine.value, cmdArgs, userDataIn(userData));
38007
38213
  const hung = await new Promise((resolve) => {
38008
38214
  const timer = setTimeout(() => {
@@ -38018,14 +38224,14 @@ class GodotServer {
38018
38224
  resolve(false);
38019
38225
  });
38020
38226
  });
38021
- const reportsDir = join17(project.value.path, ".godot", ...ours.split("/"));
38227
+ const reportsDir = join18(project.value.path, ".godot", ...ours.split("/"));
38022
38228
  let report = null;
38023
38229
  let reportProblem = null;
38024
38230
  try {
38025
38231
  const written = existsSync14(reportsDir) ? readdirSync7(reportsDir).filter((name) => name.startsWith("report_")).sort((a, b) => Number(a.slice("report_".length)) - Number(b.slice("report_".length))) : [];
38026
38232
  const newest = written.at(-1);
38027
38233
  if (newest !== undefined) {
38028
- report = parseJUnit(readFileSync15(join17(reportsDir, newest, "results.xml"), "utf8"));
38234
+ report = parseJUnit(readFileSync16(join18(reportsDir, newest, "results.xml"), "utf8"));
38029
38235
  }
38030
38236
  } catch (error) {
38031
38237
  reportProblem = errorMessage(error);
@@ -38046,6 +38252,7 @@ class GodotServer {
38046
38252
  const said = printed.map((entry) => entry.text);
38047
38253
  const nothingRan = hung ? null : whyNoReport(said, asked);
38048
38254
  const verdict = hung ? `hung: killed after ${timeoutMs} ms` : nothingRan ?? verdicts[exitCode ?? -1] ?? `exit ${exitCode ?? "unknown"}`;
38255
+ const savesNote = savesStayPut() && nothingRan === null ? SAVES_NOT_MOVED_NOTE : undefined;
38049
38256
  if (report === null) {
38050
38257
  const note = nothingRan === null ? `The test run wrote no report (${verdict}${reportProblem ? `; ${reportProblem}` : ""}).` : `No tests ran: ${verdict}.${elsewhereIn(project.value.path, asked)}`;
38051
38258
  return {
@@ -38060,7 +38267,8 @@ class GodotServer {
38060
38267
  exitCode,
38061
38268
  hung,
38062
38269
  arguments: cmdArgs,
38063
- entries: forAnswer(printed.slice(0, 60))
38270
+ entries: forAnswer(printed.slice(0, 60)),
38271
+ savesNote
38064
38272
  }, null, 2)
38065
38273
  }
38066
38274
  ],
@@ -38093,6 +38301,7 @@ class GodotServer {
38093
38301
  warnings: warnings.length > 0 ? warnings : undefined,
38094
38302
  orphans: orphans.total > 0 ? orphans.total : undefined,
38095
38303
  notRun: notRun > 0 ? notRun : undefined,
38304
+ savesNote,
38096
38305
  note: verdict.startsWith("warnings") && warnings.length === 0 ? "gdUnit4 exits 101 for orphan nodes when nothing failed, and this run printed no count of them: orphan reporting may be off in the project settings." : notRun > 0 ? `This run stopped at the first failure in each suite it failed in, so ${notRun} case${notRun === 1 ? "" : "s"} never ran and count as neither passed nor failed. Leave failFast out to run every case.` : undefined,
38097
38306
  suites: unclean.map((suite) => ({
38098
38307
  name: suite.name,
@@ -38115,7 +38324,8 @@ class GodotServer {
38115
38324
  async godotVersion(godotPath) {
38116
38325
  try {
38117
38326
  const { stdout } = await run6(godotPath, ["--version"], { timeout: 1e4 });
38118
- return stdout.trim();
38327
+ this.lastGodotVersion = stdout.trim();
38328
+ return this.lastGodotVersion;
38119
38329
  } catch (error) {
38120
38330
  this.logDebug(`Godot did not answer --version: ${errorMessage(error)}`);
38121
38331
  return null;
@@ -38161,7 +38371,7 @@ class GodotServer {
38161
38371
  return null;
38162
38372
  }
38163
38373
  try {
38164
- return readFileSync15(contained.absolutePath, "utf8");
38374
+ return readFileSync16(contained.absolutePath, "utf8");
38165
38375
  } catch {
38166
38376
  return null;
38167
38377
  }
@@ -38203,7 +38413,7 @@ class GodotServer {
38203
38413
  return null;
38204
38414
  }
38205
38415
  try {
38206
- return readFileSync15(contained.absolutePath, "utf8");
38416
+ return readFileSync16(contained.absolutePath, "utf8");
38207
38417
  } catch {
38208
38418
  return null;
38209
38419
  }
@@ -38273,16 +38483,37 @@ class GodotServer {
38273
38483
  const status = this.godotBridge.getStatus();
38274
38484
  return (status.connected ? status[field] : undefined) ?? fallback;
38275
38485
  }
38486
+ restartLeftUnfinished(connected) {
38487
+ if (this.ownProject === null) {
38488
+ return null;
38489
+ }
38490
+ const owed = restartOwed(this.ownProject);
38491
+ if (owed === null) {
38492
+ return null;
38493
+ }
38494
+ if (connected) {
38495
+ restartSettled(this.ownProject);
38496
+ return null;
38497
+ }
38498
+ return owed;
38499
+ }
38276
38500
  getEditorStatusPayload() {
38277
38501
  const status = this.godotBridge.getStatus();
38278
38502
  const isPortConflict = this.bridgeStartupError?.includes("EADDRINUSE") ?? false;
38279
38503
  const stale = status.connected && status.addonVersion !== SERVER_VERSION;
38504
+ const unfinished = this.restartLeftUnfinished(status.connected);
38280
38505
  return {
38281
38506
  ...status,
38282
38507
  serverVersion: SERVER_VERSION,
38283
38508
  addonIsStale: status.connected ? stale : undefined,
38284
38509
  bridgeAvailable: this.bridgeStartupError === null,
38285
- mayYetConnect: status.connected ? undefined : this.anEditorIsStillComing(status.listeningSince),
38510
+ mayYetConnect: status.connected ? undefined : unfinished !== null ? false : this.anEditorIsStillComing(status.listeningSince),
38511
+ restartInterrupted: unfinished === null ? undefined : {
38512
+ quitEditorPid: unfinished.editorPid,
38513
+ quitAt: unfinished.quitAt,
38514
+ byServerPid: unfinished.byPid,
38515
+ note: "A restart begun by a previous gdharness server asked this project's editor to quit, and that server was ended before it could start the editor again, so nothing is coming. editor_launch open starts one, and clears this."
38516
+ },
38286
38517
  awaitingLaunchedEditor: status.connected || !this.launchedEditorIsUp() ? undefined : this.launchedEditor?.pid ?? undefined,
38287
38518
  ...status.connected ? this.whatTheLaunchedEditorDropped() : {},
38288
38519
  startupError: this.bridgeStartupError,
@@ -38318,7 +38549,7 @@ class GodotServer {
38318
38549
  }
38319
38550
  }
38320
38551
  async handleEditorStatus() {
38321
- const godotPath = await this.locator.find();
38552
+ const located = await this.locator.find();
38322
38553
  const announced = runtimesAnnounced();
38323
38554
  const games = await Promise.all(announced.running.map(async (endpoint) => {
38324
38555
  const reply = await runtimeRequest(endpoint, "ping", {}, this.runtimeTimeoutMs());
@@ -38338,8 +38569,9 @@ class GodotServer {
38338
38569
  debugPort: playing?.debugPort ?? this.godotBridge.getStatus().debugPort
38339
38570
  },
38340
38571
  godot: {
38341
- path: godotPath,
38342
- version: godotPath === null ? null : await this.godotVersion(godotPath)
38572
+ path: located.ok ? located.path : null,
38573
+ version: located.ok ? await this.godotVersion(located.path) : null,
38574
+ problem: located.ok ? undefined : GodotLocator2.refusal(located).message
38343
38575
  },
38344
38576
  game: {
38345
38577
  processActive: runIsUp(this.currentRun(), playing === null ? null : playing.playing),
@@ -38358,12 +38590,12 @@ class GodotServer {
38358
38590
  }
38359
38591
  settingKeysOf(projectPath) {
38360
38592
  if (projectPath === undefined) {
38361
- return new Map;
38593
+ return null;
38362
38594
  }
38363
38595
  try {
38364
- return settingKeys(readFileSync15(join17(projectPath, "project.godot"), "utf8"));
38596
+ return settingKeys(readFileSync16(join18(projectPath, "project.godot"), "utf8"));
38365
38597
  } catch {
38366
- return new Map;
38598
+ return null;
38367
38599
  }
38368
38600
  }
38369
38601
  async handleRestartEditor() {
@@ -38423,12 +38655,21 @@ class GodotServer {
38423
38655
  lsp: this.editorServes("lspPort", "GDHARNESS_LSP_PORT", DEFAULT_LSP_PORT),
38424
38656
  dap: this.editorServes("dapPort", "GDHARNESS_DAP_PORT", DEFAULT_DAP_PORT)
38425
38657
  };
38658
+ noteRestartBegun({
38659
+ projectPath,
38660
+ editorPid: editorPid ?? null,
38661
+ ports,
38662
+ quitAt: new Date().toISOString(),
38663
+ byPid: process.pid
38664
+ });
38426
38665
  const asked = await this.handleViaBridge("quit_editor", {});
38427
38666
  if (asked.isError === true) {
38667
+ restartSettled(projectPath);
38428
38668
  return asked;
38429
38669
  }
38430
38670
  await this.waitForBridge(() => !alive(editorPid), Date.now() + EDITOR_RESTART_TIMEOUT_MS);
38431
38671
  const opened = await this.openAnEditor(engine.value, projectPath, ports);
38672
+ restartSettled(projectPath);
38432
38673
  if (opened.error !== null) {
38433
38674
  return this.createErrorResponse(`The editor was asked to go and could not be started again: ${opened.error}`, [`editor_launch opens one on ${projectPath}`]);
38434
38675
  }
@@ -38464,10 +38705,12 @@ class GodotServer {
38464
38705
  this.logDebug(`Launching Godot editor for project: ${project.value.path}`);
38465
38706
  const ports = await this.portsForAnEditor();
38466
38707
  const before = this.settingKeysOf(project.value.path);
38708
+ const unfinished = restartOwed(project.value.path);
38467
38709
  const opened = await this.openAnEditor(engine.value, project.value.path, ports);
38468
38710
  if (opened.error !== null) {
38469
38711
  return this.createErrorResponse(`Could not start the editor: ${opened.error}`);
38470
38712
  }
38713
+ restartSettled(project.value.path);
38471
38714
  this.launchedFrom = { projectPath: project.value.path, before };
38472
38715
  return this.jsonTextResponse({
38473
38716
  launched: true,
@@ -38475,7 +38718,8 @@ class GodotServer {
38475
38718
  projectPath: project.value.path,
38476
38719
  lspPort: ports.lsp,
38477
38720
  dapPort: ports.dap,
38478
- settingsNote: before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
38721
+ finishesRestart: unfinished === null ? undefined : `This is the launch half of a restart begun at ${unfinished.quitAt} by server pid ${unfinished.byPid}, which quit editor pid ${unfinished.editorPid ?? "unknown"} and was ended before it could start one.`,
38722
+ settingsNote: before === null ? "project.godot could not be read before this editor was opened, so nothing here will be able to say what the import saved away. Opening a project rewrites the file and drops any key sitting at its own default." : before.size === 0 ? undefined : "Opening a project imports it and saves project.godot, and Godot drops any key sitting at its own default. editor_status names anything lost under settingsDropped once this editor has connected."
38479
38723
  });
38480
38724
  }
38481
38725
  async openAnEditor(engine, projectPath, ports) {
@@ -38592,13 +38836,17 @@ class GodotServer {
38592
38836
  return runIsUp(run, editorSays);
38593
38837
  }
38594
38838
  async runtimeUp(projectPath, before, budgetMs, withArgs) {
38595
- if (!existsSync14(join17(projectPath, RUNTIME_AUTOLOAD2.path))) {
38839
+ if (!existsSync14(join18(projectPath, RUNTIME_AUTOLOAD2.path))) {
38596
38840
  return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
38597
38841
  }
38598
38842
  const endpoint = await announcedSince(projectPath, before, {
38599
38843
  budgetMs,
38600
38844
  giveUp: () => this.dapClient?.isStopped() === true || !stillRunning(this.currentRun())
38601
38845
  });
38846
+ const going = this.currentRun();
38847
+ if (endpoint !== null && going?.throughEditor === true && going.announcedPid === undefined) {
38848
+ going.announcedPid = endpoint.pid;
38849
+ }
38602
38850
  return runtimeVerdict(endpoint, {
38603
38851
  addon: true,
38604
38852
  budgetMs,
@@ -38708,9 +38956,15 @@ class GodotServer {
38708
38956
  running.log.record("warning", `This run was not ended here: pid ${running.pid} no longer answers as the run that was recorded, so nothing was signalled. If that process is still the game, end it yourself; if it is not, it belongs to something else.`);
38709
38957
  return;
38710
38958
  }
38959
+ const signalled = runningAs(running.pid);
38960
+ const described = signalled === null ? "nothing it would name" : `${signalled.kind}: ${signalled.text}`;
38961
+ let landed = true;
38711
38962
  try {
38712
38963
  process.kill(running.pid);
38713
- } catch {}
38964
+ } catch {
38965
+ landed = false;
38966
+ }
38967
+ running.log.record("info", landed ? `gdharness ended pid ${running.pid}, which the operating system described as ${described}.` : `gdharness signalled pid ${running.pid} and it was already gone; the operating system had described it as ${described}.`);
38714
38968
  }
38715
38969
  spawnGame(godotPath, cmdArgs, env) {
38716
38970
  const child = spawn(godotPath, cmdArgs, { stdio: ["ignore", "pipe", "pipe"], ...env ? { env } : {} });
@@ -39048,7 +39302,7 @@ class GodotServer {
39048
39302
  notes.push(run.exitCode === 0 ? "This run quit on its own, cleanly: exit code 0." : "Nothing here ended this run: it stopped on its own or something outside this server stopped it.");
39049
39303
  }
39050
39304
  if (run.transcript !== null && selected.omitted > 0) {
39051
- notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: running above is asked of the operating system, and a transcript that stops growing is a run between prints.`);
39305
+ notes.push(`Everything this run has printed is in ${run.transcript}, uncapped and still being written. Read it for output, not for whether the run is alive: a transcript that stops growing is a run between prints. running above is asked of the operating system while there is a process to ask about, and for an editor-played run that announced no runtime it is the editor's answer instead, which can lag after the game has gone.`);
39052
39306
  }
39053
39307
  return this.jsonTextResponse({
39054
39308
  running: stillRunning(run),
@@ -39250,8 +39504,8 @@ class GodotServer {
39250
39504
  return this.createErrorResponse(choice.problem);
39251
39505
  }
39252
39506
  const expectsScreenshot = command === "capture_screenshot" || command === "capture_viewport";
39253
- const screenshotDir = expectsScreenshot ? mkdtempSync3(join17(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39254
- const screenshotPath = screenshotDir ? join17(screenshotDir, "capture.png") : null;
39507
+ const screenshotDir = expectsScreenshot ? mkdtempSync3(join18(tmpdir5(), "gdharness-runtime-screenshot-")) : null;
39508
+ const screenshotPath = screenshotDir ? join18(screenshotDir, "capture.png") : null;
39255
39509
  try {
39256
39510
  const reply = await runtimeRequest(choice.endpoint, command, screenshotPath ? { ...params, output_path: screenshotPath } : params, timeoutMs);
39257
39511
  if (!reply.ok) {
@@ -39272,7 +39526,7 @@ class GodotServer {
39272
39526
  return {
39273
39527
  content: [
39274
39528
  { type: "text", text: `Screenshot captured: ${dimensions}` },
39275
- { type: "image", data: readFileSync15(screenshotPath).toString("base64"), mimeType: "image/png" }
39529
+ { type: "image", data: readFileSync16(screenshotPath).toString("base64"), mimeType: "image/png" }
39276
39530
  ]
39277
39531
  };
39278
39532
  } finally {
@@ -39361,6 +39615,7 @@ var init_server3 = __esm(() => {
39361
39615
  init_process_time();
39362
39616
  init_project_scan();
39363
39617
  init_resources();
39618
+ init_restart_note();
39364
39619
  init_run_record();
39365
39620
  init_runtime_client();
39366
39621
  init_scratch();
@@ -39369,7 +39624,7 @@ var init_server3 = __esm(() => {
39369
39624
  init_tool_definitions();
39370
39625
  init_update_check();
39371
39626
  run6 = promisify6(execFile6);
39372
- __dirname2 = dirname9(fileURLToPath4(import.meta.url));
39627
+ __dirname2 = dirname10(fileURLToPath4(import.meta.url));
39373
39628
  VALUE_NAMES = {
39374
39629
  string: "a string",
39375
39630
  number: "a number",
@@ -39408,7 +39663,7 @@ var init_server3 = __esm(() => {
39408
39663
 
39409
39664
  // src/cli.ts
39410
39665
  import { existsSync as existsSync15 } from "fs";
39411
- import { join as join18, resolve as resolve5 } from "path";
39666
+ import { join as join19, resolve as resolve5 } from "path";
39412
39667
 
39413
39668
  // src/errors.ts
39414
39669
  class Refusal extends Error {
@@ -39416,6 +39671,7 @@ class Refusal extends Error {
39416
39671
 
39417
39672
  // src/godot-path.ts
39418
39673
  init_detection();
39674
+ init_errors();
39419
39675
  import { execFile } from "node:child_process";
39420
39676
  import { existsSync as existsSync2 } from "node:fs";
39421
39677
  import { normalize } from "node:path";
@@ -39426,47 +39682,65 @@ class GodotLocator {
39426
39682
  answered = new Map;
39427
39683
  found = null;
39428
39684
  async find() {
39429
- if (this.found !== null && await this.answers(this.found)) {
39430
- return this.found;
39685
+ if (this.found !== null && await this.whyNot(this.found) === null) {
39686
+ return { ok: true, path: this.found };
39431
39687
  }
39432
39688
  this.found = null;
39433
39689
  const named = envValue("GODOT_PATH");
39434
39690
  if (named !== undefined) {
39435
39691
  const path = normalize(named);
39436
- if (await this.answers(path)) {
39692
+ const reason = await this.whyNot(path);
39693
+ if (reason === null) {
39437
39694
  this.found = path;
39695
+ return { ok: true, path };
39438
39696
  }
39439
- return this.found;
39697
+ return { ok: false, named: path, reason };
39440
39698
  }
39441
39699
  for (const candidate of godotCandidates(process.platform, resolveHomeDirectory())) {
39442
39700
  const path = normalize(candidate);
39443
- if (await this.answers(path)) {
39701
+ if (await this.whyNot(path) === null) {
39444
39702
  this.found = path;
39445
- return path;
39703
+ return { ok: true, path };
39446
39704
  }
39447
39705
  }
39448
- return null;
39706
+ return { ok: false, named: null };
39449
39707
  }
39450
- static ADVICE = [
39451
- "Set GODOT_PATH to the Godot 4 executable",
39452
- "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
39453
- ];
39454
- async answers(path) {
39708
+ static refusal(located) {
39709
+ if (located.named === null) {
39710
+ return {
39711
+ message: "No Godot executable found.",
39712
+ advice: [
39713
+ "Set GODOT_PATH to the Godot 4 executable",
39714
+ "Or install Godot where it is usually found, such as /usr/bin/godot or C:\\Program Files\\Godot"
39715
+ ]
39716
+ };
39717
+ }
39718
+ return {
39719
+ message: `GODOT_PATH is set to ${located.named}, which ${located.reason ?? "does not answer"}.`,
39720
+ advice: [
39721
+ "Point GODOT_PATH at a Godot 4 executable that answers --version",
39722
+ "Or unset it, and the usual install locations are searched instead"
39723
+ ]
39724
+ };
39725
+ }
39726
+ async whyNot(path) {
39455
39727
  const known = this.answered.get(path);
39456
39728
  if (known !== undefined) {
39457
39729
  return known;
39458
39730
  }
39459
- let ok = false;
39460
- if (path === "godot" || existsSync2(path)) {
39731
+ let reason;
39732
+ if (path !== "godot" && !existsSync2(path)) {
39733
+ reason = "does not exist";
39734
+ } else {
39461
39735
  try {
39462
39736
  await run(path, ["--version"]);
39463
- ok = true;
39464
- } catch {
39465
- ok = false;
39737
+ reason = null;
39738
+ } catch (error) {
39739
+ reason = `does not answer --version: ${errorMessage(error)}`;
39466
39740
  }
39467
39741
  }
39468
- this.answered.set(path, ok);
39469
- return ok;
39742
+ this.answered.set(path, reason);
39743
+ return reason;
39470
39744
  }
39471
39745
  }
39472
39746
 
@@ -40246,15 +40520,27 @@ function disconnect(harness, projectPath) {
40246
40520
  `, "utf8");
40247
40521
  return { harness, path, action: "removed" };
40248
40522
  }
40249
- function harnessNote(moved, now) {
40250
- if (moved.length === 0) {
40523
+ function harnessNote(repinned, now) {
40524
+ const alsoByHand = repinned.byHand === 0 ? "" : `
40525
+ ${repinned.byHand === 1 ? "One harness above was" : `${repinned.byHand} harnesses above were`} handed a command to run instead; run it too.`;
40526
+ if (repinned.moved.length > 0) {
40527
+ return ` 2. Your harness is still running the server it spawned from ${repinned.moved.join(", ")}.
40528
+ ` + ` Its config names ${now} now.
40529
+ ` + ` Reconnect the MCP server, or restart the harness.${alsoByHand}`;
40530
+ }
40531
+ if (repinned.written > 0) {
40251
40532
  return ` 2. Your harness config already named this version, but the server it is running is
40252
40533
  ` + ` whichever one it spawned when the session started, which this cannot see from here.
40253
- ` + " Reconnect the MCP server, or restart the harness.";
40534
+ ` + ` Reconnect the MCP server, or restart the harness.${alsoByHand}`;
40254
40535
  }
40255
- return ` 2. Your harness is still running the server it spawned from ${moved.join(", ")}.
40256
- ` + ` Its config names ${now} now.
40257
- ` + " Reconnect the MCP server, or restart the harness.";
40536
+ if (repinned.byHand > 0) {
40537
+ return ` 2. None of the harness configs found here can be written for you, so nothing was
40538
+ ` + ` re-pinned and a reconnect on its own would come back on the same server.
40539
+ ` + " Run what is printed above, then reconnect the MCP server or restart the harness.";
40540
+ }
40541
+ return ` 2. No harness config here names gdharness, so nothing was re-pinned and there is no
40542
+ ` + ` config for a reconnect to read. Run gdharness setup to write one, or point your
40543
+ ` + ` harness at ${now} yourself, then restart it.`;
40258
40544
  }
40259
40545
  function launchedBy(entry) {
40260
40546
  if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
@@ -40470,15 +40756,26 @@ function filledTemplate(where, message, godotVersion) {
40470
40756
  ].join(`
40471
40757
  `);
40472
40758
  }
40473
- function defectReport(where, error, godotVersion) {
40759
+ function defectReport(where, error, context = {}) {
40760
+ const { godotVersion, seenBefore = 0 } = context;
40474
40761
  const message = errorMessage(error);
40475
40762
  const title = `Defect ${defectSignature(where, message)}: ${where}`;
40476
40763
  const url = `${NEW_ISSUE}?template=bug_report.md&title=${encodeURIComponent(title)}` + `&body=${encodeURIComponent(filledTemplate(where, message, godotVersion))}`;
40477
40764
  const width = Math.max(...facts(where, message, godotVersion).map(([label]) => (label ?? "").length));
40478
40765
  return [
40479
- "gdharness failed in a way it does not model. This is a defect in gdharness rather than",
40480
- "anything about the call: the arguments did not cause it, and sending it again will not",
40481
- "change it.",
40766
+ "gdharness failed in a way it does not model. Whatever went wrong underneath, it reached you",
40767
+ "as a crash rather than as a refusal naming what would have worked, and that much is a defect",
40768
+ "in gdharness however the rest of it turns out.",
40769
+ "",
40770
+ ...seenBefore === 0 ? [
40771
+ "Send the call once more before anything else. Some of what reaches this line is a file",
40772
+ "another process had open, or a connection that dropped, and those are gone by the next",
40773
+ "attempt. If the same Signature comes back, it is not one of those, and repeating it",
40774
+ "further only spends turns."
40775
+ ] : [
40776
+ `You have had this exact failure ${seenBefore === 1 ? "once" : `${seenBefore} times`} in this session already, so it is a`,
40777
+ "state rather than a passing one, and sending the call again will not change it."
40778
+ ],
40482
40779
  "",
40483
40780
  ...facts(where, message, godotVersion).map(([label, value]) => ` ${(label ?? "").padEnd(width)} ${value ?? ""}`),
40484
40781
  "",
@@ -40615,14 +40912,14 @@ function loaderAmong(projectPath, named) {
40615
40912
  if (entry === RUNTIME_AUTOLOAD.name || path === RUNTIME_AUTOLOAD.path) {
40616
40913
  continue;
40617
40914
  }
40618
- let source = "";
40915
+ let source = null;
40619
40916
  try {
40620
40917
  source = readFileSync7(join8(projectPath, path), "utf8");
40621
40918
  } catch {}
40622
- if (source.includes(RUNTIME_AUTOLOAD.path)) {
40623
- return { name: entry, path };
40919
+ if (source?.includes(RUNTIME_AUTOLOAD.path)) {
40920
+ return { name: entry, path, exists: true };
40624
40921
  }
40625
- byName ??= path.toLowerCase().includes("gdharness") ? { name: entry, path } : null;
40922
+ byName ??= path.toLowerCase().includes("gdharness") ? { name: entry, path, exists: source !== null } : null;
40626
40923
  }
40627
40924
  return byName;
40628
40925
  }
@@ -40685,8 +40982,15 @@ function inspectProject(projectPath) {
40685
40982
  const runtimeLoaderAutoload = loaderAmong(projectPath, named);
40686
40983
  const runtimeAutoload = runtimeAutoloadPath !== null || runtimeLoaderAutoload !== null;
40687
40984
  const tracked = trackedByGit(projectPath, [...named.values()]);
40985
+ const addonMissing = (path) => addons.some((addon) => !addon.installed && path.startsWith(`addons/${addon.name}/`));
40688
40986
  for (const [name, path] of named) {
40689
- if (tracked !== null && !tracked.has(path) && existsSync7(join8(projectPath, path))) {
40987
+ if (!existsSync7(join8(projectPath, path))) {
40988
+ if (!addonMissing(path)) {
40989
+ problems.push(`the ${name} autoload names res://${path}, which is not in this project, so it boots with a missing script; restore that file, or point the autoload at one that is here`);
40990
+ }
40991
+ continue;
40992
+ }
40993
+ if (tracked !== null && !tracked.has(path)) {
40690
40994
  problems.push(`the ${name} autoload names res://${path}, which git does not carry, so a clone boots with a missing script; commit that file, or point the autoload at one the project tracks`);
40691
40995
  }
40692
40996
  }
@@ -40844,8 +41148,13 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
40844
41148
  with their backtraces, and a \`clean\` verdict, so a run that printed an error is one call away
40845
41149
  from being known. For a long run it also names a transcript file: read that for output and this
40846
41150
  answer for state. Never decide a run has ended by watching the file stop growing, or a process
40847
- list, or a timeout. \`running\` is asked of the operating system and \`endedBy\` says whether
40848
- gdharness ended it; a bench between prints looks exactly like a bench that died to everything else.
41151
+ list, or a timeout; a bench between prints looks exactly like a bench that died to everything else.
41152
+ \`endedBy\` says whether gdharness ended it, and \`running\` is asked of the operating system
41153
+ whenever there is a process to ask about: every run started through \`editor_run\`, and every
41154
+ editor-played run whose game announced its runtime. An editor-played run in a project with no
41155
+ runtime addon announces nothing, so there is no process id and \`running\` is the editor's answer,
41156
+ which can lag by seconds after the game has gone. \`editor_status\` names the game under
41157
+ \`runtimes\`, which does not depend on the editor answering.
40849
41158
  - One server, one editor. A second project is a second harness session with its own server, and
40850
41159
  that works: each editor is opened on its own language server and debug adapter ports, and says
40851
41160
  where it serves. Two editors on one project is the thing to refuse.
@@ -40856,10 +41165,12 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
40856
41165
  | --- | --- |
40857
41166
  | What a screen says | \`runtime_inspect text\`, which reads what is drawn and leaves out what is hidden. Read \`omitted\`: a long list above a dialog pushes it past the limit, and what comes back then is the top of the screen rather than the screen |
40858
41167
  | Find a control by the word on it | \`runtime_inspect find\` with \`says\`, rather than listing a screen and reading each one |
41168
+ | What a control is showing | \`runtime_inspect find\` with \`property\` as well: the value comes back on each match in the same answer, so the game cannot tick between finding the node and reading it. A \`find\` followed by a \`property\` read of the path it answered is two calls with a frame between them, and a panel that rebuilds itself answers the second with "Node not found" |
40859
41169
  | Where a control is, and whether it is visible | \`runtime_inspect\` \`find\`, \`rect\` |
40860
- | What a property reads right now | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
41170
+ | What a property reads right now, on a node whose path you already hold | \`runtime_inspect\` \`property\`, \`runtime_invoke\` |
40861
41171
  | What a property on an object a node holds reads | the same two, with colons: \`_game:clock:speed\` |
40862
41172
  | Press a button | \`runtime_input click\`, which says what was under the pointer |
41173
+ | Press the next thing after a click that changed screen | \`runtime_wait until\` with \`says\` and a word the new screen shows, then the click. A click sent straight after a screen change lands on a control the new screen is about to free, answers \`landed: true\`, and does nothing; a frame count is a number nobody can pick correctly across a cold start and a warm one, and waiting for the words answered in about 130ms where twenty frames had not been enough |
40863
41174
  | Fill in a field | \`runtime_input click\` on it, then \`runtime_input text\` with \`replace\`; a submitted field has to be clicked again |
40864
41175
  | Click somebody standing in a 3D room | \`runtime_input click\` on the Node3D, which aims at what it draws |
40865
41176
  | Pick something out of a dropdown | \`runtime_input choose\` on it, by what the item says |
@@ -40915,8 +41226,9 @@ its own copy. So a long run is not a reason to stop editing, and the new code ar
40915
41226
  A wall of "Could not find type" on the project's own classes means the editor is not holding
40916
41227
  them. \`editor_rescan\` asks it to scan and names any class it still cannot resolve under
40917
41228
  \`unseenByEditor\`: its walk skips a file a headless engine has already imported, so the
40918
- declaration and the cache can both be right while the editor stays blind to it. Change the
40919
- declaring script, or restart the editor.
41229
+ declaration and the cache can both be right while the editor stays blind to it. Rescan again on
41230
+ its own, which is the measured cure and needs no change to the declaring script; \`editor_launch
41231
+ restart\` also does it and costs a window.
40920
41232
 
40921
41233
  The scan writes \`.godot/global_script_class_cache.cfg\` from the list the editor is holding, so
40922
41234
  a class it cannot resolve is dropped out of that file too, and the next fresh engine, CI run or
@@ -41039,17 +41351,22 @@ class UsageError extends Refusal {
41039
41351
  function projectArgument(at) {
41040
41352
  const named = args.slice(at).find((value) => !value.startsWith("--"));
41041
41353
  const projectPath = resolve5(named ?? ".");
41042
- if (!existsSync15(join18(projectPath, "project.godot"))) {
41354
+ if (!existsSync15(join19(projectPath, "project.godot"))) {
41043
41355
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
41044
41356
  }
41045
41357
  return projectPath;
41046
41358
  }
41047
41359
  async function engine(projectPath) {
41048
- const godotPath = await new GodotLocator().find() ?? (projectPath === undefined ? null : recordedEnginePath(projectPath));
41049
- if (godotPath === null) {
41050
- throw new UsageError(`No Godot executable found. ${GodotLocator.ADVICE.join(". ")}.`);
41360
+ const located = await new GodotLocator().find();
41361
+ if (located.ok) {
41362
+ return { godotPath: located.path, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
41363
+ }
41364
+ const recorded = located.named === null && projectPath !== undefined ? recordedEnginePath(projectPath) : null;
41365
+ if (recorded === null) {
41366
+ const refusal = GodotLocator.refusal(located);
41367
+ throw new UsageError(`${refusal.message} ${refusal.advice.join(". ")}.`);
41051
41368
  }
41052
- return { godotPath, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
41369
+ return { godotPath: recorded, script: shippedOperationsScript(), debug: GODOT_DEBUG_MODE_DEFAULT2 };
41053
41370
  }
41054
41371
  function said2(outcome, what) {
41055
41372
  if (!outcome.ok) {
@@ -41073,7 +41390,7 @@ async function registerRuntime(godot, projectPath) {
41073
41390
  return;
41074
41391
  }
41075
41392
  console.log(`${RUNTIME_AUTOLOAD.name} autoload left at ${named}, which this project registers itself`);
41076
- if (!existsSync15(join18(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41393
+ if (!existsSync15(join19(projectPath, (named ?? "").replace(/^res:\/\//, "")))) {
41077
41394
  console.log(` that file is not in this project, so it boots with a missing script: restore it, or point the entry at res://${RUNTIME_AUTOLOAD.path}`);
41078
41395
  }
41079
41396
  }
@@ -41131,7 +41448,7 @@ function reportConnection(group, launch, projectPath) {
41131
41448
  const written = connect(group.writer, projectPath, launch);
41132
41449
  if (written.action === "command") {
41133
41450
  console.log(`${who}: run ${(written.command ?? []).join(" ")}`);
41134
- return;
41451
+ return { kind: "byHand" };
41135
41452
  }
41136
41453
  if (written.action === "snippet") {
41137
41454
  console.log(`${who}: put this in ${written.path}`);
@@ -41139,7 +41456,7 @@ function reportConnection(group, launch, projectPath) {
41139
41456
  `)) {
41140
41457
  console.log(` ${line}`);
41141
41458
  }
41142
- return;
41459
+ return { kind: "byHand" };
41143
41460
  }
41144
41461
  console.log(`${who}: ${written.action} ${written.path}`);
41145
41462
  if (written.wasLaunchedBy !== undefined) {
@@ -41151,7 +41468,7 @@ function reportConnection(group, launch, projectPath) {
41151
41468
  console.log(` ${harness.name}: ${harness.manual}`);
41152
41469
  }
41153
41470
  }
41154
- return written.wasLaunchedBy;
41471
+ return { kind: "written", from: written.wasLaunchedBy ?? null };
41155
41472
  }
41156
41473
  function nextSteps(projectPath, runtime) {
41157
41474
  console.log(`
@@ -41285,14 +41602,18 @@ async function upgrade() {
41285
41602
  const launch = launchFor(version, godot.godotPath, projectPath);
41286
41603
  const already = HARNESSES.filter((harness) => registered(harness, projectPath));
41287
41604
  const moved = [];
41605
+ let written = 0;
41606
+ let byHand = 0;
41288
41607
  for (const group of groupByFile(already, projectPath)) {
41289
- const was = reportConnection(group, launch, projectPath);
41290
- if (was !== undefined && !moved.includes(was)) {
41291
- moved.push(was);
41608
+ const took = reportConnection(group, launch, projectPath);
41609
+ if (took.kind === "byHand") {
41610
+ byHand += 1;
41611
+ continue;
41612
+ }
41613
+ written += 1;
41614
+ if (took.from !== null && !moved.includes(took.from)) {
41615
+ moved.push(took.from);
41292
41616
  }
41293
- }
41294
- if (already.length === 0) {
41295
- console.log("no harness config names gdharness, so none was re-pinned");
41296
41617
  }
41297
41618
  for (const written of writeSkill(skillDirectories(already, projectPath, existsSync15), version)) {
41298
41619
  console.log(`skill: ${written.replaced ? "replaced" : "written"} ${written.path}`);
@@ -41301,7 +41622,7 @@ async function upgrade() {
41301
41622
  On ${version}. Two things this could not do for you:`);
41302
41623
  console.log(` 1. The open editor is still running the addons it loaded at startup. Restart it with the
41303
41624
  ` + " editor_launch restart tool, which closes and reopens the window.");
41304
- console.log(harnessNote(moved, [launch.command, ...launch.args].join(" ")));
41625
+ console.log(harnessNote({ moved, written, byHand }, [launch.command, ...launch.args].join(" ")));
41305
41626
  }
41306
41627
  function doctorReport(projectPath) {
41307
41628
  const report = inspectProject(projectPath);
@@ -41312,7 +41633,8 @@ function doctorReport(projectPath) {
41312
41633
  console.log(`addons/${addon.name}: ${addon.installed ? `installed, ${addon.version ?? "unknown version"}${addon.current ? "" : " (not this version)"}` : "missing"}`);
41313
41634
  }
41314
41635
  console.log(`editor plugins enabled: ${report.pluginsEnabled.join(", ") || "none"}`);
41315
- console.log(`runtime autoload: ${report.runtimeLoaderAutoload === null ? report.runtimeAutoload ? "registered" : "not registered" : `registered through res://${report.runtimeLoaderAutoload.path}, as ${report.runtimeLoaderAutoload.name}`}`);
41636
+ const loader = report.runtimeLoaderAutoload;
41637
+ console.log(`runtime autoload: ${loader === null ? report.runtimeAutoload ? "registered" : "not registered" : `registered through res://${loader.path}, as ${loader.name}${loader.exists ? "" : ", and that file is not in this project"}`}`);
41316
41638
  console.log(report.classCacheExists ? `class cache: ${report.staleClasses.length === 0 ? "current" : `stale for ${report.staleClasses.join(", ")}`}` : "class cache: missing");
41317
41639
  for (const problem of report.problems) {
41318
41640
  console.log(`problem: ${problem}`);