gdharness 1.0.5 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -15694,7 +15694,7 @@ var init_tool_definitions = __esm(() => {
15694
15694
  requires: []
15695
15695
  },
15696
15696
  refresh_classes: {
15697
- summary: 'rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, and name under unseenByEditor any class the editor open on this project still cannot resolve: rewriting the file does not reach the list a running editor loaded, so "added: []" means the file was already right rather than that nothing is wrong',
15697
+ summary: 'rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, and name under unseenByEditor any class the editor open on this project still cannot resolve: rewriting the file does not reach the list a running editor loaded, so "added: []" means the file was already right rather than that nothing is wrong. A class the cache held after the last rebuild and had lost when this one began is named under lostSinceLastRebuild: the editor wrote the file without it in between, on a save or a scan, and will again until restarted',
15698
15698
  requires: []
15699
15699
  }
15700
15700
  }
@@ -15719,7 +15719,7 @@ var init_tool_definitions = __esm(() => {
15719
15719
  },
15720
15720
  {
15721
15721
  name: "project_test",
15722
- 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.",
15722
+ 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, and the rebuild's answer is under classes: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. 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.",
15723
15723
  parameters: {
15724
15724
  projectPath: PROJECT_PATH,
15725
15725
  path: {
@@ -15936,7 +15936,7 @@ var init_tool_definitions = __esm(() => {
15936
15936
  },
15937
15937
  {
15938
15938
  name: "script_diagnostics",
15939
- description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says what to do, and for both states it is editor_rescan first: it asks the editor already running, starts nothing, and has cleared each in every reproduction measured. What follows differs: for a stale analysed type, editor_rescan with reloadScript recompiles the held copy and editor_launch restart is the last resort that costs a window; for a class missing from the cache, project_import refresh_classes rewrites the cache from disk at the cost of a short headless engine, which matters beside a running bench.",
15939
+ description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says what to do, and for both states it is editor_rescan first: it asks the editor already running, starts nothing, and has cleared each in every reproduction measured. The one exception is named when it applies: an editor whose last scan wrote the class cache shorter than the file holds a list behind the files and writes it on every scan, so on that editor the rescan loads nothing and staleAnalysis says editor_launch restart instead. What follows differs: for a stale analysed type, editor_rescan with reloadScript recompiles the held copy and editor_launch restart is the last resort that costs a window; for a class missing from the cache, project_import refresh_classes rewrites the cache from disk at the cost of a short headless engine, which matters beside a running bench.",
15940
15940
  parameters: {
15941
15941
  projectPath: PROJECT_PATH,
15942
15942
  scriptPath: SCRIPT_PATH
@@ -31601,11 +31601,13 @@ function announcedIn(directory) {
31601
31601
  }
31602
31602
  const strangers = strangersAmong(candidates, Date.now());
31603
31603
  for (const { file, pid, alive } of entries) {
31604
- const announced = alive && !strangers.has(file) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
31604
+ const gone = !alive || strangers.has(file);
31605
+ const announced = gone ? { kind: "rubbish" } : parseAnnouncement(file, pid);
31605
31606
  if (announced.kind === "rubbish") {
31606
- if (!alive || strangers.has(file)) {
31607
- wentAway(file, pid);
31607
+ if (!gone) {
31608
+ continue;
31608
31609
  }
31610
+ wentAway(file, pid);
31609
31611
  try {
31610
31612
  unlinkSync(file);
31611
31613
  } catch {}
@@ -31856,11 +31858,14 @@ function writeBootNote(projectPath, announcedAfterMs) {
31856
31858
  writeFileSync8(path, JSON.stringify({ announcedAfterMs: Math.round(announcedAfterMs), at: new Date().toISOString() }, null, 2), "utf8");
31857
31859
  } catch {}
31858
31860
  }
31861
+ function halfAsLongAgain(lastBootMs) {
31862
+ return Math.round(lastBootMs * 1.5);
31863
+ }
31859
31864
  function waitSizedTo(lastBootMs, usual = ANNOUNCE_BUDGET_MS) {
31860
31865
  if (lastBootMs === null) {
31861
31866
  return usual;
31862
31867
  }
31863
- return Math.min(LONGEST_SIZED_WAIT_MS, Math.max(usual, Math.round(lastBootMs * 1.5)));
31868
+ return Math.min(LONGEST_SIZED_WAIT_MS, Math.max(usual, halfAsLongAgain(lastBootMs)));
31864
31869
  }
31865
31870
  var LONGEST_SIZED_WAIT_MS = 60000;
31866
31871
  var init_boot_note = __esm(() => {
@@ -37611,7 +37616,7 @@ import {
37611
37616
  statSync as statSync5
37612
37617
  } from "node:fs";
37613
37618
  import { tmpdir as tmpdir5 } from "node:os";
37614
- import { basename as basename4, dirname as dirname13, join as join20, normalize as normalize3, relative as relative3 } from "node:path";
37619
+ import { basename as basename4, dirname as dirname13, join as join20, normalize as normalize3, relative as relative3, resolve as resolve5 } from "node:path";
37615
37620
  import { setTimeout as delay3 } from "node:timers/promises";
37616
37621
  import { fileURLToPath as fileURLToPath4 } from "node:url";
37617
37622
  import { promisify as promisify7 } from "node:util";
@@ -37707,6 +37712,13 @@ function aboutTheChildren(ended, throughEditor) {
37707
37712
  const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: neither announced as a game of this project nor this project's engine run with --path on it, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
37708
37713
  return `${endedToo}${leftToo}`;
37709
37714
  }
37715
+ function uidsLeftNote(left) {
37716
+ if (left === 0) {
37717
+ return "No scene was written: this reads the project and imports it, and does not resave anything.";
37718
+ }
37719
+ const them = left === 1 ? "it" : "them";
37720
+ return `${left === 1 ? "One file" : `${left} files`} still ${left === 1 ? "has" : "have"} no .uid, named under stillWithoutUid, which is the engine declining to import ${them} rather than this op skipping ${them}. No scene was written.`;
37721
+ }
37710
37722
  function endedToStartThis(ended) {
37711
37723
  if (ended === null) {
37712
37724
  return "";
@@ -37734,7 +37746,7 @@ function runtimeVerdict(endpoint, after) {
37734
37746
  };
37735
37747
  }
37736
37748
  const state = after.playingSeen === false ? "the editor has not yet reported the game as playing, which a restarted editor does not until its scan is over" : "the game is still running";
37737
- const sized = after.sizedToMs === undefined ? "" : `, half as long again as the ${after.sizedToMs}ms its last game took,`;
37749
+ const sized = after.sizedToMs === undefined ? "" : halfAsLongAgain(after.sizedToMs) > after.budgetMs ? `, the longest a start waits unasked though its last game took ${after.sizedToMs}ms to announce,` : `, half as long again as the ${after.sizedToMs}ms its last game took,`;
37738
37750
  return {
37739
37751
  listening: false,
37740
37752
  mayYetAnnounce: after.running,
@@ -37838,7 +37850,10 @@ function aboveTheRunner(entry) {
37838
37850
  const cut = entry.detail.length - runner;
37839
37851
  return {
37840
37852
  ...entry,
37841
- detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
37853
+ detail: [
37854
+ ...entry.detail.slice(0, runner),
37855
+ `[and ${cut === 1 ? "one frame" : `${cut} frames`} inside ${RUNNER_DIRECTORY}]`
37856
+ ]
37842
37857
  };
37843
37858
  }
37844
37859
  function elsewhereIn(projectPath, asked) {
@@ -37905,6 +37920,8 @@ class GodotServer {
37905
37920
  successorWatch = null;
37906
37921
  lastProjectPath = null;
37907
37922
  shutdownInitiated = false;
37923
+ shortListEditors = new Map;
37924
+ cacheLastRebuilt = new Map;
37908
37925
  launchedEditor = null;
37909
37926
  launchedFrom = null;
37910
37927
  ownProject;
@@ -38318,8 +38335,15 @@ class GodotServer {
38318
38335
  const contained = this.containProjectFiles(asks);
38319
38336
  return contained.ok ? await this.handleViaBridge(editorSide, contained.value) : contained.response;
38320
38337
  }
38321
- const answered = await this.headless(headless, answerable);
38322
- return headless === "refresh_class_cache" && this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
38338
+ if (headless !== "refresh_class_cache") {
38339
+ return await this.headless(headless, answerable);
38340
+ }
38341
+ const project = this.project(answerable);
38342
+ if (!project.ok) {
38343
+ return project.response;
38344
+ }
38345
+ const answered = this.answer(await this.rebuildClassCache(project.value.path));
38346
+ return this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
38323
38347
  }
38324
38348
  const { op: _op, ...arguments_ } = args;
38325
38349
  const bridge = async (command, extra = {}) => {
@@ -38726,7 +38750,7 @@ class GodotServer {
38726
38750
  payload: {
38727
38751
  uidsCreated: given,
38728
38752
  stillWithoutUid: after,
38729
- note: after.length > 0 ? `${after.length} still have no .uid, which is the engine declining to import them rather than this op skipping them. No scene was written.` : "No scene was written: this reads the project and imports it, and does not resave anything."
38753
+ note: uidsLeftNote(after.length)
38730
38754
  }
38731
38755
  });
38732
38756
  }
@@ -38908,7 +38932,7 @@ class GodotServer {
38908
38932
  if (!engine.ok) {
38909
38933
  return engine.response;
38910
38934
  }
38911
- const classes = await this.operation("refresh_class_cache", {}, project.value.path);
38935
+ const classes = await this.rebuildClassCache(project.value.path);
38912
38936
  if (!classes.ok) {
38913
38937
  return this.answer(classes);
38914
38938
  }
@@ -39125,7 +39149,8 @@ class GodotServer {
39125
39149
  }
39126
39150
  const stale = unloaded.filter((type) => type.inTheClassCache);
39127
39151
  if (stale.length > 0) {
39128
- notes.push(`${stale.map((type) => type.type).join(", ")} ${stale.length === 1 ? "is" : "are"} in the class cache and still unresolved here, which is the editor's loaded list being behind rather than anything on disk. A game launched now reads the cache and resolves ${stale.length === 1 ? "it" : "them"}, so the run and the diagnostics disagree. editor_rescan clears it, measured against a real editor with waiting the same length ruled out; editor_launch restart also does, and costs more.`);
39152
+ const remedy = this.editorWritesAShortList(projectPath) ? "editor_rescan will not clear it here: this editor wrote the class cache from a list shorter than the file on its last scan and writes the same list on every scan, so editor_launch restart is what loads it." : "editor_rescan clears it, measured against a real editor with waiting the same length ruled out; editor_launch restart also does, and costs more.";
39153
+ notes.push(`${stale.map((type) => type.type).join(", ")} ${stale.length === 1 ? "is" : "are"} in the class cache and still unresolved here, which is the editor's loaded list being behind rather than anything on disk. A game launched now reads the cache and resolves ${stale.length === 1 ? "it" : "them"}, so the run and the diagnostics disagree. ${remedy}`);
39129
39154
  }
39130
39155
  return {
39131
39156
  ...contradicted.length === 0 ? {} : { contradictedByTheFile: contradicted },
@@ -39133,6 +39158,10 @@ class GodotServer {
39133
39158
  ...notes.length === 0 ? {} : { staleAnalysis: notes.join(" ") }
39134
39159
  };
39135
39160
  }
39161
+ editorWritesAShortList(projectPath) {
39162
+ const key = resolve5(projectPath);
39163
+ return this.shortListEditors.has(key) && this.shortListEditors.get(key) === this.godotBridge.getStatus().editorPid;
39164
+ }
39136
39165
  sourceOfClass(projectPath, resourcePath) {
39137
39166
  const contained = resolveWithinProject(projectPath, resourcePath);
39138
39167
  if (!contained.ok || !existsSync14(contained.absolutePath)) {
@@ -39642,7 +39671,8 @@ class GodotServer {
39642
39671
  return !editorSaysGoing;
39643
39672
  }
39644
39673
  });
39645
- this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${endpoint === null ? "nothing announced" : `pid ${endpoint.pid} announced`}, editor asked ${editorAsks} times, last said ${lastSaid()}`);
39674
+ const asked = editorAsks === 0 ? "editor not asked" : `editor asked ${editorAsks === 1 ? "once" : `${editorAsks} times`}, last said ${lastSaid()}`;
39675
+ this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${endpoint === null ? "nothing announced" : `pid ${endpoint.pid} announced`}, ${asked}`);
39646
39676
  const going = this.currentRun();
39647
39677
  if (endpoint !== null && going !== null && going.announcedPid === undefined) {
39648
39678
  this.tie(going, endpoint);
@@ -40481,6 +40511,9 @@ class GodotServer {
40481
40511
  }
40482
40512
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
40483
40513
  const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
40514
+ if (lost.length > 0) {
40515
+ this.shortListEditors.set(resolve5(projectPath), this.godotBridge.getStatus().editorPid);
40516
+ }
40484
40517
  const atMissingPaths = after === null ? [] : cachedAtMissingPaths(after, projectPath);
40485
40518
  const restored = lost.length > 0 || atMissingPaths.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
40486
40519
  const rebuilt = atMissingPaths.length > 0 ? cachedClasses(projectPath) : null;
@@ -40536,7 +40569,7 @@ class GodotServer {
40536
40569
  if (projectPath === "") {
40537
40570
  return [];
40538
40571
  }
40539
- const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
40572
+ const rebuilt = await this.rebuildClassCache(projectPath);
40540
40573
  if (!rebuilt.ok) {
40541
40574
  this.logDebug(`Could not rebuild the class cache after a scan: ${rebuilt.message}`);
40542
40575
  return [];
@@ -40544,6 +40577,34 @@ class GodotServer {
40544
40577
  const now = cachedClasses(projectPath);
40545
40578
  return now === null ? [] : lost.filter((name) => now.has(name));
40546
40579
  }
40580
+ async rebuildClassCache(projectPath) {
40581
+ const key = resolve5(projectPath);
40582
+ const before = cachedClasses(projectPath);
40583
+ const remembered = this.cacheLastRebuilt.get(key);
40584
+ const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
40585
+ if (!rebuilt.ok) {
40586
+ return rebuilt;
40587
+ }
40588
+ const after = cachedClasses(projectPath);
40589
+ if (after === null) {
40590
+ return rebuilt;
40591
+ }
40592
+ this.cacheLastRebuilt.set(key, new Set(after.keys()));
40593
+ const lost = remembered === undefined || before === null ? [] : [...remembered].filter((name) => !before.has(name) && after.has(name)).sort();
40594
+ if (lost.length === 0 || !this.godotBridge.isConnected()) {
40595
+ return rebuilt;
40596
+ }
40597
+ this.shortListEditors.set(key, this.godotBridge.getStatus().editorPid);
40598
+ const them = lost.length === 1 ? "it" : "them";
40599
+ return {
40600
+ ...rebuilt,
40601
+ payload: {
40602
+ ...rebuilt.payload,
40603
+ lostSinceLastRebuild: lost,
40604
+ note: `The cache held ${lost.join(", ")} after the last rebuild and not when this one began, so the editor holding this project wrote it without ${them} in between: it holds a list shorter than the files and writes that list on every save and scan, and each rebuild puts ${them} back. editor_launch restart is what ends it.`
40605
+ }
40606
+ };
40607
+ }
40547
40608
  async alsoSayWhatTheEditorCannotSee(answered, args) {
40548
40609
  const first = answered.content[0];
40549
40610
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -40554,15 +40615,18 @@ class GodotServer {
40554
40615
  if (checked.unseen.length === 0 && stillHeld.length === 0 && checked.unchecked === undefined) {
40555
40616
  return answered;
40556
40617
  }
40557
- const notes = [];
40618
+ const payload = asParams(JSON.parse(first.text));
40619
+ const said = readString(payload, "note");
40620
+ const notes = said === undefined ? [] : [said];
40558
40621
  if (checked.unseen.length > 0) {
40559
- notes.push("The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. editor_rescan does, on its own and with no change to the declaring script; editor_launch restart also does, and costs more.");
40622
+ const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
40623
+ notes.push(projectPath !== "" && this.editorWritesAShortList(projectPath) ? "The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. editor_rescan will not reach it either on this editor, whose last scan wrote the cache from a list shorter than the file and would again; editor_launch restart is what loads them." : "The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. editor_rescan does, on its own and with no change to the declaring script; editor_launch restart also does, and costs more.");
40560
40624
  }
40561
40625
  if (stillHeld.length > 0) {
40562
40626
  notes.push(`The editor is still holding ${stillHeld.join(", ")}, which this project no longer declares, and it will write that list back over the cache this call just corrected. The entry returns pointing at a script that is gone, and the next engine to walk get_global_class_list() dies on "File not found". A rescan does not clear this one: it picks up a class that has appeared and does not drop one that has gone. editor_launch restart does.`);
40563
40627
  }
40564
40628
  return this.jsonTextResponse({
40565
- ...asParams(JSON.parse(first.text)),
40629
+ ...payload,
40566
40630
  unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
40567
40631
  stillHeldByEditor: stillHeld.length > 0 ? stillHeld : undefined,
40568
40632
  classesUnchecked: checked.unchecked,
@@ -40855,7 +40919,7 @@ var init_server3 = __esm(() => {
40855
40919
 
40856
40920
  // src/cli.ts
40857
40921
  import { existsSync as existsSync15 } from "fs";
40858
- import { join as join21, resolve as resolve5 } from "path";
40922
+ import { join as join21, resolve as resolve6 } from "path";
40859
40923
 
40860
40924
  // src/errors.ts
40861
40925
  class Refusal extends Error {
@@ -42559,7 +42623,7 @@ class UsageError extends Refusal {
42559
42623
  }
42560
42624
  function projectArgument(at) {
42561
42625
  const named = args.slice(at).find((value) => !value.startsWith("--"));
42562
- const projectPath = resolve5(named ?? ".");
42626
+ const projectPath = resolve6(named ?? ".");
42563
42627
  if (!existsSync15(join21(projectPath, "project.godot"))) {
42564
42628
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
42565
42629
  }
@@ -267,23 +267,46 @@ func _announce(bind_address: String) -> void:
267
267
  )
268
268
  return
269
269
  var path: String = directory.path_join("runtime-%d.json" % OS.get_process_id())
270
- var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
270
+ # Written whole under a name no server reads and moved into place, so no look ever meets
271
+ # the file empty: a server sweeping the directory between an open and a write took the
272
+ # announcement for a dead game's leftover, and the directory is shared by every server on
273
+ # the machine, older ones included.
274
+ var staged: String = path + ".tmp"
275
+ var file: FileAccess = FileAccess.open(staged, FileAccess.WRITE)
271
276
  if file == null:
272
277
  push_warning(
273
278
  (
274
279
  "[gdharness] cannot write %s (%s); the server will not find this game"
275
- % [path, FileAccess.get_open_error()]
280
+ % [staged, FileAccess.get_open_error()]
276
281
  )
277
282
  )
278
283
  return
279
- var announced: bool = file.store_string(JSON.stringify(_identity(bind_address)))
284
+ var written: bool = file.store_string(JSON.stringify(_identity(bind_address)))
280
285
  file.close()
281
- if not announced:
282
- push_warning("[gdharness] cannot write %s; the server will not find this game" % path)
286
+ if not written:
287
+ push_warning("[gdharness] cannot write %s; the server will not find this game" % staged)
288
+ _unstage(staged)
289
+ return
290
+ var moved: Error = DirAccess.rename_absolute(staged, path)
291
+ if moved != OK:
292
+ push_warning(
293
+ (
294
+ "[gdharness] cannot move %s to %s (%s); the server will not find this game"
295
+ % [staged, path, moved]
296
+ )
297
+ )
298
+ _unstage(staged)
283
299
  return
284
300
  _announcement = path
285
301
 
286
302
 
303
+ ## Takes a staged announcement down after a write or a move that failed. The answer is taken
304
+ ## rather than dropped, since these scripts compile under the game's own warning levels; a
305
+ ## staged file that will not go either is nothing any server reads.
306
+ func _unstage(staged: String) -> void:
307
+ var _removed: Error = DirAccess.remove_absolute(staged)
308
+
309
+
287
310
  ## What the announcement file and the welcome both carry: enough to pick this game out of
288
311
  ## several and to know whether the server speaks its protocol.
289
312
  func _identity(bind_address: String) -> Dictionary:
package/build/index.js CHANGED
@@ -10184,7 +10184,7 @@ import {
10184
10184
  statSync as statSync5
10185
10185
  } from "node:fs";
10186
10186
  import { tmpdir as tmpdir4 } from "node:os";
10187
- import { basename as basename3, dirname as dirname8, join as join14, normalize as normalize2, relative as relative3 } from "node:path";
10187
+ import { basename as basename3, dirname as dirname8, join as join14, normalize as normalize2, relative as relative3, resolve as resolve5 } from "node:path";
10188
10188
  import { setTimeout as delay3 } from "node:timers/promises";
10189
10189
  import { fileURLToPath as fileURLToPath2 } from "node:url";
10190
10190
  import { promisify as promisify5 } from "node:util";
@@ -25233,11 +25233,13 @@ function announcedIn(directory) {
25233
25233
  }
25234
25234
  const strangers = strangersAmong(candidates, Date.now());
25235
25235
  for (const { file, pid, alive } of entries) {
25236
- const announced = alive && !strangers.has(file) ? parseAnnouncement(file, pid) : { kind: "rubbish" };
25236
+ const gone = !alive || strangers.has(file);
25237
+ const announced = gone ? { kind: "rubbish" } : parseAnnouncement(file, pid);
25237
25238
  if (announced.kind === "rubbish") {
25238
- if (!alive || strangers.has(file)) {
25239
- wentAway(file, pid);
25239
+ if (!gone) {
25240
+ continue;
25240
25241
  }
25242
+ wentAway(file, pid);
25241
25243
  try {
25242
25244
  unlinkSync(file);
25243
25245
  } catch {}
@@ -25482,11 +25484,14 @@ function writeBootNote(projectPath, announcedAfterMs) {
25482
25484
  } catch {}
25483
25485
  }
25484
25486
  var LONGEST_SIZED_WAIT_MS = 60000;
25487
+ function halfAsLongAgain(lastBootMs) {
25488
+ return Math.round(lastBootMs * 1.5);
25489
+ }
25485
25490
  function waitSizedTo(lastBootMs, usual = ANNOUNCE_BUDGET_MS) {
25486
25491
  if (lastBootMs === null) {
25487
25492
  return usual;
25488
25493
  }
25489
- return Math.min(LONGEST_SIZED_WAIT_MS, Math.max(usual, Math.round(lastBootMs * 1.5)));
25494
+ return Math.min(LONGEST_SIZED_WAIT_MS, Math.max(usual, halfAsLongAgain(lastBootMs)));
25490
25495
  }
25491
25496
 
25492
25497
  // src/breakpoint-note.ts
@@ -29463,7 +29468,7 @@ var TOOL_SPECS = [
29463
29468
  requires: []
29464
29469
  },
29465
29470
  refresh_classes: {
29466
- summary: 'rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, and name under unseenByEditor any class the editor open on this project still cannot resolve: rewriting the file does not reach the list a running editor loaded, so "added: []" means the file was already right rather than that nothing is wrong',
29471
+ summary: 'rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, and name under unseenByEditor any class the editor open on this project still cannot resolve: rewriting the file does not reach the list a running editor loaded, so "added: []" means the file was already right rather than that nothing is wrong. A class the cache held after the last rebuild and had lost when this one began is named under lostSinceLastRebuild: the editor wrote the file without it in between, on a save or a scan, and will again until restarted',
29467
29472
  requires: []
29468
29473
  }
29469
29474
  }
@@ -29488,7 +29493,7 @@ var TOOL_SPECS = [
29488
29493
  },
29489
29494
  {
29490
29495
  name: "project_test",
29491
- 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.",
29496
+ 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, and the rebuild's answer is under classes: a class it puts back that the cache had after the last rebuild is named under classes.lostSinceLastRebuild, which is the editor writing its shorter list over the file on every save until restarted. 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.",
29492
29497
  parameters: {
29493
29498
  projectPath: PROJECT_PATH,
29494
29499
  path: {
@@ -29705,7 +29710,7 @@ var TOOL_SPECS = [
29705
29710
  },
29706
29711
  {
29707
29712
  name: "script_diagnostics",
29708
- description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says what to do, and for both states it is editor_rescan first: it asks the editor already running, starts nothing, and has cleared each in every reproduction measured. What follows differs: for a stale analysed type, editor_rescan with reloadScript recompiles the held copy and editor_launch restart is the last resort that costs a window; for a class missing from the cache, project_import refresh_classes rewrites the cache from disk at the cost of a short headless engine, which matters beside a running bench.",
29713
+ description: "Errors and warnings for a script from the editor's language server, and whether the script is clean. Needs the editor running. Diagnostics are checked against the project's own files, and the two that the files disprove are named rather than passed on: a member the class cache's file declares comes back under contradictedByTheFile, and a class this project declares that the diagnostic could not resolve comes back under typesTheEditorHasNotLoaded, with inTheClassCache saying whether a launched game would resolve it. staleAnalysis then says what to do, and for both states it is editor_rescan first: it asks the editor already running, starts nothing, and has cleared each in every reproduction measured. The one exception is named when it applies: an editor whose last scan wrote the class cache shorter than the file holds a list behind the files and writes it on every scan, so on that editor the rescan loads nothing and staleAnalysis says editor_launch restart instead. What follows differs: for a stale analysed type, editor_rescan with reloadScript recompiles the held copy and editor_launch restart is the last resort that costs a window; for a class missing from the cache, project_import refresh_classes rewrites the cache from disk at the cost of a short headless engine, which matters beside a running bench.",
29709
29714
  parameters: {
29710
29715
  projectPath: PROJECT_PATH,
29711
29716
  scriptPath: SCRIPT_PATH
@@ -30402,6 +30407,13 @@ function aboutTheChildren(ended, throughEditor) {
30402
30407
  const leftToo = ended.left.length === 0 ? "" : ` ${counted(ended.left.length)} it had started ${ended.left.length === 1 ? "was" : "were"} left, named under childrenLeft: neither announced as a game of this project nor this project's engine run with --path on it, so what ${ended.left.length === 1 ? "it is" : "they are"} cannot be told from here.`;
30403
30408
  return `${endedToo}${leftToo}`;
30404
30409
  }
30410
+ function uidsLeftNote(left) {
30411
+ if (left === 0) {
30412
+ return "No scene was written: this reads the project and imports it, and does not resave anything.";
30413
+ }
30414
+ const them = left === 1 ? "it" : "them";
30415
+ return `${left === 1 ? "One file" : `${left} files`} still ${left === 1 ? "has" : "have"} no .uid, named under stillWithoutUid, which is the engine declining to import ${them} rather than this op skipping ${them}. No scene was written.`;
30416
+ }
30405
30417
  function endedToStartThis(ended) {
30406
30418
  if (ended === null) {
30407
30419
  return "";
@@ -30430,7 +30442,7 @@ function runtimeVerdict(endpoint, after) {
30430
30442
  };
30431
30443
  }
30432
30444
  const state = after.playingSeen === false ? "the editor has not yet reported the game as playing, which a restarted editor does not until its scan is over" : "the game is still running";
30433
- const sized = after.sizedToMs === undefined ? "" : `, half as long again as the ${after.sizedToMs}ms its last game took,`;
30445
+ const sized = after.sizedToMs === undefined ? "" : halfAsLongAgain(after.sizedToMs) > after.budgetMs ? `, the longest a start waits unasked though its last game took ${after.sizedToMs}ms to announce,` : `, half as long again as the ${after.sizedToMs}ms its last game took,`;
30434
30446
  return {
30435
30447
  listening: false,
30436
30448
  mayYetAnnounce: after.running,
@@ -30576,7 +30588,10 @@ function aboveTheRunner(entry) {
30576
30588
  const cut = entry.detail.length - runner;
30577
30589
  return {
30578
30590
  ...entry,
30579
- detail: [...entry.detail.slice(0, runner), `[and ${cut} frames inside ${RUNNER_DIRECTORY}]`]
30591
+ detail: [
30592
+ ...entry.detail.slice(0, runner),
30593
+ `[and ${cut === 1 ? "one frame" : `${cut} frames`} inside ${RUNNER_DIRECTORY}]`
30594
+ ]
30580
30595
  };
30581
30596
  }
30582
30597
  function elsewhereIn(projectPath, asked) {
@@ -30643,6 +30658,8 @@ class GodotServer {
30643
30658
  successorWatch = null;
30644
30659
  lastProjectPath = null;
30645
30660
  shutdownInitiated = false;
30661
+ shortListEditors = new Map;
30662
+ cacheLastRebuilt = new Map;
30646
30663
  launchedEditor = null;
30647
30664
  launchedFrom = null;
30648
30665
  ownProject;
@@ -31056,8 +31073,15 @@ class GodotServer {
31056
31073
  const contained = this.containProjectFiles(asks);
31057
31074
  return contained.ok ? await this.handleViaBridge(editorSide, contained.value) : contained.response;
31058
31075
  }
31059
- const answered = await this.headless(headless, answerable);
31060
- return headless === "refresh_class_cache" && this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
31076
+ if (headless !== "refresh_class_cache") {
31077
+ return await this.headless(headless, answerable);
31078
+ }
31079
+ const project = this.project(answerable);
31080
+ if (!project.ok) {
31081
+ return project.response;
31082
+ }
31083
+ const answered = this.answer(await this.rebuildClassCache(project.value.path));
31084
+ return this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
31061
31085
  }
31062
31086
  const { op: _op, ...arguments_ } = args;
31063
31087
  const bridge = async (command, extra = {}) => {
@@ -31464,7 +31488,7 @@ class GodotServer {
31464
31488
  payload: {
31465
31489
  uidsCreated: given,
31466
31490
  stillWithoutUid: after,
31467
- note: after.length > 0 ? `${after.length} still have no .uid, which is the engine declining to import them rather than this op skipping them. No scene was written.` : "No scene was written: this reads the project and imports it, and does not resave anything."
31491
+ note: uidsLeftNote(after.length)
31468
31492
  }
31469
31493
  });
31470
31494
  }
@@ -31646,7 +31670,7 @@ class GodotServer {
31646
31670
  if (!engine.ok) {
31647
31671
  return engine.response;
31648
31672
  }
31649
- const classes = await this.operation("refresh_class_cache", {}, project.value.path);
31673
+ const classes = await this.rebuildClassCache(project.value.path);
31650
31674
  if (!classes.ok) {
31651
31675
  return this.answer(classes);
31652
31676
  }
@@ -31863,7 +31887,8 @@ class GodotServer {
31863
31887
  }
31864
31888
  const stale = unloaded.filter((type) => type.inTheClassCache);
31865
31889
  if (stale.length > 0) {
31866
- notes.push(`${stale.map((type) => type.type).join(", ")} ${stale.length === 1 ? "is" : "are"} in the class cache and still unresolved here, which is the editor's loaded list being behind rather than anything on disk. A game launched now reads the cache and resolves ${stale.length === 1 ? "it" : "them"}, so the run and the diagnostics disagree. editor_rescan clears it, measured against a real editor with waiting the same length ruled out; editor_launch restart also does, and costs more.`);
31890
+ const remedy = this.editorWritesAShortList(projectPath) ? "editor_rescan will not clear it here: this editor wrote the class cache from a list shorter than the file on its last scan and writes the same list on every scan, so editor_launch restart is what loads it." : "editor_rescan clears it, measured against a real editor with waiting the same length ruled out; editor_launch restart also does, and costs more.";
31891
+ notes.push(`${stale.map((type) => type.type).join(", ")} ${stale.length === 1 ? "is" : "are"} in the class cache and still unresolved here, which is the editor's loaded list being behind rather than anything on disk. A game launched now reads the cache and resolves ${stale.length === 1 ? "it" : "them"}, so the run and the diagnostics disagree. ${remedy}`);
31867
31892
  }
31868
31893
  return {
31869
31894
  ...contradicted.length === 0 ? {} : { contradictedByTheFile: contradicted },
@@ -31871,6 +31896,10 @@ class GodotServer {
31871
31896
  ...notes.length === 0 ? {} : { staleAnalysis: notes.join(" ") }
31872
31897
  };
31873
31898
  }
31899
+ editorWritesAShortList(projectPath) {
31900
+ const key = resolve5(projectPath);
31901
+ return this.shortListEditors.has(key) && this.shortListEditors.get(key) === this.godotBridge.getStatus().editorPid;
31902
+ }
31874
31903
  sourceOfClass(projectPath, resourcePath) {
31875
31904
  const contained = resolveWithinProject(projectPath, resourcePath);
31876
31905
  if (!contained.ok || !existsSync9(contained.absolutePath)) {
@@ -32380,7 +32409,8 @@ class GodotServer {
32380
32409
  return !editorSaysGoing;
32381
32410
  }
32382
32411
  });
32383
- this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${endpoint === null ? "nothing announced" : `pid ${endpoint.pid} announced`}, editor asked ${editorAsks} times, last said ${lastSaid()}`);
32412
+ const asked = editorAsks === 0 ? "editor not asked" : `editor asked ${editorAsks === 1 ? "once" : `${editorAsks} times`}, last said ${lastSaid()}`;
32413
+ this.logDebug(`announce wait ended after ${Date.now() - waitBegan}ms: ${endpoint === null ? "nothing announced" : `pid ${endpoint.pid} announced`}, ${asked}`);
32384
32414
  const going = this.currentRun();
32385
32415
  if (endpoint !== null && going !== null && going.announcedPid === undefined) {
32386
32416
  this.tie(going, endpoint);
@@ -33219,6 +33249,9 @@ class GodotServer {
33219
33249
  }
33220
33250
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
33221
33251
  const lost = before === null || after === null ? [] : [...before.keys()].filter((name) => !after.has(name));
33252
+ if (lost.length > 0) {
33253
+ this.shortListEditors.set(resolve5(projectPath), this.godotBridge.getStatus().editorPid);
33254
+ }
33222
33255
  const atMissingPaths = after === null ? [] : cachedAtMissingPaths(after, projectPath);
33223
33256
  const restored = lost.length > 0 || atMissingPaths.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
33224
33257
  const rebuilt = atMissingPaths.length > 0 ? cachedClasses(projectPath) : null;
@@ -33274,7 +33307,7 @@ class GodotServer {
33274
33307
  if (projectPath === "") {
33275
33308
  return [];
33276
33309
  }
33277
- const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
33310
+ const rebuilt = await this.rebuildClassCache(projectPath);
33278
33311
  if (!rebuilt.ok) {
33279
33312
  this.logDebug(`Could not rebuild the class cache after a scan: ${rebuilt.message}`);
33280
33313
  return [];
@@ -33282,6 +33315,34 @@ class GodotServer {
33282
33315
  const now = cachedClasses(projectPath);
33283
33316
  return now === null ? [] : lost.filter((name) => now.has(name));
33284
33317
  }
33318
+ async rebuildClassCache(projectPath) {
33319
+ const key = resolve5(projectPath);
33320
+ const before = cachedClasses(projectPath);
33321
+ const remembered = this.cacheLastRebuilt.get(key);
33322
+ const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
33323
+ if (!rebuilt.ok) {
33324
+ return rebuilt;
33325
+ }
33326
+ const after = cachedClasses(projectPath);
33327
+ if (after === null) {
33328
+ return rebuilt;
33329
+ }
33330
+ this.cacheLastRebuilt.set(key, new Set(after.keys()));
33331
+ const lost = remembered === undefined || before === null ? [] : [...remembered].filter((name) => !before.has(name) && after.has(name)).sort();
33332
+ if (lost.length === 0 || !this.godotBridge.isConnected()) {
33333
+ return rebuilt;
33334
+ }
33335
+ this.shortListEditors.set(key, this.godotBridge.getStatus().editorPid);
33336
+ const them = lost.length === 1 ? "it" : "them";
33337
+ return {
33338
+ ...rebuilt,
33339
+ payload: {
33340
+ ...rebuilt.payload,
33341
+ lostSinceLastRebuild: lost,
33342
+ note: `The cache held ${lost.join(", ")} after the last rebuild and not when this one began, so the editor holding this project wrote it without ${them} in between: it holds a list shorter than the files and writes that list on every save and scan, and each rebuild puts ${them} back. editor_launch restart is what ends it.`
33343
+ }
33344
+ };
33345
+ }
33285
33346
  async alsoSayWhatTheEditorCannotSee(answered, args) {
33286
33347
  const first = answered.content[0];
33287
33348
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -33292,15 +33353,18 @@ class GodotServer {
33292
33353
  if (checked.unseen.length === 0 && stillHeld.length === 0 && checked.unchecked === undefined) {
33293
33354
  return answered;
33294
33355
  }
33295
- const notes = [];
33356
+ const payload = asParams(JSON.parse(first.text));
33357
+ const said = readString(payload, "note");
33358
+ const notes = said === undefined ? [] : [said];
33296
33359
  if (checked.unseen.length > 0) {
33297
- notes.push("The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. editor_rescan does, on its own and with no change to the declaring script; editor_launch restart also does, and costs more.");
33360
+ const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
33361
+ notes.push(projectPath !== "" && this.editorWritesAShortList(projectPath) ? "The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. editor_rescan will not reach it either on this editor, whose last scan wrote the cache from a list shorter than the file and would again; editor_launch restart is what loads them." : "The cache on disk is right now, and the editor holding this project is still not resolving these: rewriting the file does not reach the list it already loaded. editor_rescan does, on its own and with no change to the declaring script; editor_launch restart also does, and costs more.");
33298
33362
  }
33299
33363
  if (stillHeld.length > 0) {
33300
33364
  notes.push(`The editor is still holding ${stillHeld.join(", ")}, which this project no longer declares, and it will write that list back over the cache this call just corrected. The entry returns pointing at a script that is gone, and the next engine to walk get_global_class_list() dies on "File not found". A rescan does not clear this one: it picks up a class that has appeared and does not drop one that has gone. editor_launch restart does.`);
33301
33365
  }
33302
33366
  return this.jsonTextResponse({
33303
- ...asParams(JSON.parse(first.text)),
33367
+ ...payload,
33304
33368
  unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
33305
33369
  stillHeldByEditor: stillHeld.length > 0 ? stillHeld : undefined,
33306
33370
  classesUnchecked: checked.unchecked,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "mcpName": "io.github.Aureliolo/gdharness",
5
5
  "description": "A harness for driving a Godot 4 project from an agent: editor addons, a runtime bridge into the running game, an MCP server in front of them, and a CLI that installs and diagnoses the Godot side.",
6
6
  "type": "module",