gdharness 1.0.6 → 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
@@ -37616,7 +37616,7 @@ import {
37616
37616
  statSync as statSync5
37617
37617
  } from "node:fs";
37618
37618
  import { tmpdir as tmpdir5 } from "node:os";
37619
- 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";
37620
37620
  import { setTimeout as delay3 } from "node:timers/promises";
37621
37621
  import { fileURLToPath as fileURLToPath4 } from "node:url";
37622
37622
  import { promisify as promisify7 } from "node:util";
@@ -37920,6 +37920,8 @@ class GodotServer {
37920
37920
  successorWatch = null;
37921
37921
  lastProjectPath = null;
37922
37922
  shutdownInitiated = false;
37923
+ shortListEditors = new Map;
37924
+ cacheLastRebuilt = new Map;
37923
37925
  launchedEditor = null;
37924
37926
  launchedFrom = null;
37925
37927
  ownProject;
@@ -38333,8 +38335,15 @@ class GodotServer {
38333
38335
  const contained = this.containProjectFiles(asks);
38334
38336
  return contained.ok ? await this.handleViaBridge(editorSide, contained.value) : contained.response;
38335
38337
  }
38336
- const answered = await this.headless(headless, answerable);
38337
- 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;
38338
38347
  }
38339
38348
  const { op: _op, ...arguments_ } = args;
38340
38349
  const bridge = async (command, extra = {}) => {
@@ -38923,7 +38932,7 @@ class GodotServer {
38923
38932
  if (!engine.ok) {
38924
38933
  return engine.response;
38925
38934
  }
38926
- const classes = await this.operation("refresh_class_cache", {}, project.value.path);
38935
+ const classes = await this.rebuildClassCache(project.value.path);
38927
38936
  if (!classes.ok) {
38928
38937
  return this.answer(classes);
38929
38938
  }
@@ -39140,7 +39149,8 @@ class GodotServer {
39140
39149
  }
39141
39150
  const stale = unloaded.filter((type) => type.inTheClassCache);
39142
39151
  if (stale.length > 0) {
39143
- 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}`);
39144
39154
  }
39145
39155
  return {
39146
39156
  ...contradicted.length === 0 ? {} : { contradictedByTheFile: contradicted },
@@ -39148,6 +39158,10 @@ class GodotServer {
39148
39158
  ...notes.length === 0 ? {} : { staleAnalysis: notes.join(" ") }
39149
39159
  };
39150
39160
  }
39161
+ editorWritesAShortList(projectPath) {
39162
+ const key = resolve5(projectPath);
39163
+ return this.shortListEditors.has(key) && this.shortListEditors.get(key) === this.godotBridge.getStatus().editorPid;
39164
+ }
39151
39165
  sourceOfClass(projectPath, resourcePath) {
39152
39166
  const contained = resolveWithinProject(projectPath, resourcePath);
39153
39167
  if (!contained.ok || !existsSync14(contained.absolutePath)) {
@@ -40497,6 +40511,9 @@ class GodotServer {
40497
40511
  }
40498
40512
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
40499
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
+ }
40500
40517
  const atMissingPaths = after === null ? [] : cachedAtMissingPaths(after, projectPath);
40501
40518
  const restored = lost.length > 0 || atMissingPaths.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
40502
40519
  const rebuilt = atMissingPaths.length > 0 ? cachedClasses(projectPath) : null;
@@ -40552,7 +40569,7 @@ class GodotServer {
40552
40569
  if (projectPath === "") {
40553
40570
  return [];
40554
40571
  }
40555
- const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
40572
+ const rebuilt = await this.rebuildClassCache(projectPath);
40556
40573
  if (!rebuilt.ok) {
40557
40574
  this.logDebug(`Could not rebuild the class cache after a scan: ${rebuilt.message}`);
40558
40575
  return [];
@@ -40560,6 +40577,34 @@ class GodotServer {
40560
40577
  const now = cachedClasses(projectPath);
40561
40578
  return now === null ? [] : lost.filter((name) => now.has(name));
40562
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
+ }
40563
40608
  async alsoSayWhatTheEditorCannotSee(answered, args) {
40564
40609
  const first = answered.content[0];
40565
40610
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -40570,15 +40615,18 @@ class GodotServer {
40570
40615
  if (checked.unseen.length === 0 && stillHeld.length === 0 && checked.unchecked === undefined) {
40571
40616
  return answered;
40572
40617
  }
40573
- const notes = [];
40618
+ const payload = asParams(JSON.parse(first.text));
40619
+ const said = readString(payload, "note");
40620
+ const notes = said === undefined ? [] : [said];
40574
40621
  if (checked.unseen.length > 0) {
40575
- 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.");
40576
40624
  }
40577
40625
  if (stillHeld.length > 0) {
40578
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.`);
40579
40627
  }
40580
40628
  return this.jsonTextResponse({
40581
- ...asParams(JSON.parse(first.text)),
40629
+ ...payload,
40582
40630
  unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
40583
40631
  stillHeldByEditor: stillHeld.length > 0 ? stillHeld : undefined,
40584
40632
  classesUnchecked: checked.unchecked,
@@ -40871,7 +40919,7 @@ var init_server3 = __esm(() => {
40871
40919
 
40872
40920
  // src/cli.ts
40873
40921
  import { existsSync as existsSync15 } from "fs";
40874
- import { join as join21, resolve as resolve5 } from "path";
40922
+ import { join as join21, resolve as resolve6 } from "path";
40875
40923
 
40876
40924
  // src/errors.ts
40877
40925
  class Refusal extends Error {
@@ -42575,7 +42623,7 @@ class UsageError extends Refusal {
42575
42623
  }
42576
42624
  function projectArgument(at) {
42577
42625
  const named = args.slice(at).find((value) => !value.startsWith("--"));
42578
- const projectPath = resolve5(named ?? ".");
42626
+ const projectPath = resolve6(named ?? ".");
42579
42627
  if (!existsSync15(join21(projectPath, "project.godot"))) {
42580
42628
  throw new UsageError(`Not a Godot project: ${projectPath} holds no project.godot.`);
42581
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";
@@ -29468,7 +29468,7 @@ var TOOL_SPECS = [
29468
29468
  requires: []
29469
29469
  },
29470
29470
  refresh_classes: {
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',
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',
29472
29472
  requires: []
29473
29473
  }
29474
29474
  }
@@ -29493,7 +29493,7 @@ var TOOL_SPECS = [
29493
29493
  },
29494
29494
  {
29495
29495
  name: "project_test",
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. 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.",
29497
29497
  parameters: {
29498
29498
  projectPath: PROJECT_PATH,
29499
29499
  path: {
@@ -29710,7 +29710,7 @@ var TOOL_SPECS = [
29710
29710
  },
29711
29711
  {
29712
29712
  name: "script_diagnostics",
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. 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.",
29714
29714
  parameters: {
29715
29715
  projectPath: PROJECT_PATH,
29716
29716
  scriptPath: SCRIPT_PATH
@@ -30658,6 +30658,8 @@ class GodotServer {
30658
30658
  successorWatch = null;
30659
30659
  lastProjectPath = null;
30660
30660
  shutdownInitiated = false;
30661
+ shortListEditors = new Map;
30662
+ cacheLastRebuilt = new Map;
30661
30663
  launchedEditor = null;
30662
30664
  launchedFrom = null;
30663
30665
  ownProject;
@@ -31071,8 +31073,15 @@ class GodotServer {
31071
31073
  const contained = this.containProjectFiles(asks);
31072
31074
  return contained.ok ? await this.handleViaBridge(editorSide, contained.value) : contained.response;
31073
31075
  }
31074
- const answered = await this.headless(headless, answerable);
31075
- 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;
31076
31085
  }
31077
31086
  const { op: _op, ...arguments_ } = args;
31078
31087
  const bridge = async (command, extra = {}) => {
@@ -31661,7 +31670,7 @@ class GodotServer {
31661
31670
  if (!engine.ok) {
31662
31671
  return engine.response;
31663
31672
  }
31664
- const classes = await this.operation("refresh_class_cache", {}, project.value.path);
31673
+ const classes = await this.rebuildClassCache(project.value.path);
31665
31674
  if (!classes.ok) {
31666
31675
  return this.answer(classes);
31667
31676
  }
@@ -31878,7 +31887,8 @@ class GodotServer {
31878
31887
  }
31879
31888
  const stale = unloaded.filter((type) => type.inTheClassCache);
31880
31889
  if (stale.length > 0) {
31881
- 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}`);
31882
31892
  }
31883
31893
  return {
31884
31894
  ...contradicted.length === 0 ? {} : { contradictedByTheFile: contradicted },
@@ -31886,6 +31896,10 @@ class GodotServer {
31886
31896
  ...notes.length === 0 ? {} : { staleAnalysis: notes.join(" ") }
31887
31897
  };
31888
31898
  }
31899
+ editorWritesAShortList(projectPath) {
31900
+ const key = resolve5(projectPath);
31901
+ return this.shortListEditors.has(key) && this.shortListEditors.get(key) === this.godotBridge.getStatus().editorPid;
31902
+ }
31889
31903
  sourceOfClass(projectPath, resourcePath) {
31890
31904
  const contained = resolveWithinProject(projectPath, resourcePath);
31891
31905
  if (!contained.ok || !existsSync9(contained.absolutePath)) {
@@ -33235,6 +33249,9 @@ class GodotServer {
33235
33249
  }
33236
33250
  const after = projectPath === "" || busy ? null : cachedClasses(projectPath);
33237
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
+ }
33238
33255
  const atMissingPaths = after === null ? [] : cachedAtMissingPaths(after, projectPath);
33239
33256
  const restored = lost.length > 0 || atMissingPaths.length > 0 ? await this.rebuildCacheAfterLoss(projectPath, lost) : [];
33240
33257
  const rebuilt = atMissingPaths.length > 0 ? cachedClasses(projectPath) : null;
@@ -33290,7 +33307,7 @@ class GodotServer {
33290
33307
  if (projectPath === "") {
33291
33308
  return [];
33292
33309
  }
33293
- const rebuilt = await this.operation("refresh_class_cache", {}, projectPath);
33310
+ const rebuilt = await this.rebuildClassCache(projectPath);
33294
33311
  if (!rebuilt.ok) {
33295
33312
  this.logDebug(`Could not rebuild the class cache after a scan: ${rebuilt.message}`);
33296
33313
  return [];
@@ -33298,6 +33315,34 @@ class GodotServer {
33298
33315
  const now = cachedClasses(projectPath);
33299
33316
  return now === null ? [] : lost.filter((name) => now.has(name));
33300
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
+ }
33301
33346
  async alsoSayWhatTheEditorCannotSee(answered, args) {
33302
33347
  const first = answered.content[0];
33303
33348
  if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
@@ -33308,15 +33353,18 @@ class GodotServer {
33308
33353
  if (checked.unseen.length === 0 && stillHeld.length === 0 && checked.unchecked === undefined) {
33309
33354
  return answered;
33310
33355
  }
33311
- const notes = [];
33356
+ const payload = asParams(JSON.parse(first.text));
33357
+ const said = readString(payload, "note");
33358
+ const notes = said === undefined ? [] : [said];
33312
33359
  if (checked.unseen.length > 0) {
33313
- 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.");
33314
33362
  }
33315
33363
  if (stillHeld.length > 0) {
33316
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.`);
33317
33365
  }
33318
33366
  return this.jsonTextResponse({
33319
- ...asParams(JSON.parse(first.text)),
33367
+ ...payload,
33320
33368
  unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
33321
33369
  stillHeldByEditor: stillHeld.length > 0 ? stillHeld : undefined,
33322
33370
  classesUnchecked: checked.unchecked,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.0.6",
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",