gdharness 0.9.6 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/build/cli.js CHANGED
@@ -7474,6 +7474,9 @@ import { join as join6 } from "node:path";
7474
7474
  function declaredClasses(projectPath) {
7475
7475
  const declared = new Map;
7476
7476
  const visit = (directory, prefix) => {
7477
+ if (existsSync6(join6(directory, ".gdignore"))) {
7478
+ return;
7479
+ }
7477
7480
  for (const entry of readdirSync2(directory, { withFileTypes: true })) {
7478
7481
  if (entry.name.startsWith(".")) {
7479
7482
  continue;
@@ -7511,6 +7514,12 @@ function staleClassNames(projectPath) {
7511
7514
  const cached = cachedClasses(projectPath);
7512
7515
  return cached === null ? [...declaredClasses(projectPath).keys()] : staleAgainst(cached, projectPath);
7513
7516
  }
7517
+ function unseenByEditor(projectPath, editorHolds) {
7518
+ const held = new Set(editorHolds);
7519
+ const declared = declaredClasses(projectPath);
7520
+ const source = cachedClasses(projectPath) ?? declared;
7521
+ return [...source].filter(([name]) => !held.has(name) && declared.has(name)).map(([className, path]) => ({ className, path: declared.get(className) ?? path }));
7522
+ }
7514
7523
  var init_class_cache = () => {};
7515
7524
 
7516
7525
  // src/headless.ts
@@ -15371,7 +15380,7 @@ var init_tool_definitions = __esm(() => {
15371
15380
  uid: { summary: "the UID of one file", requires: ["resourcePath"] },
15372
15381
  refresh_uids: { summary: "resave every resource so UID references are current", requires: [] },
15373
15382
  refresh_classes: {
15374
- summary: "rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, for an editor whose list has gone stale",
15383
+ 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',
15375
15384
  requires: []
15376
15385
  }
15377
15386
  }
@@ -15716,7 +15725,7 @@ var init_tool_definitions = __esm(() => {
15716
15725
  },
15717
15726
  {
15718
15727
  name: "editor_rescan",
15719
- description: "Makes the running editor scan the project filesystem, so files written outside it, and any class_name they declare, become visible. Needs the editor connected.",
15728
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is a change to the declaring script, or editor_launch restart; project_import refresh_classes rewrites the cache and does not touch what the editor is holding. Needs the editor connected.",
15720
15729
  parameters: {
15721
15730
  projectPath: PROJECT_PATH,
15722
15731
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -35264,12 +35273,14 @@ class GodotLSPClient {
35264
35273
  if (!this.socket) {
35265
35274
  this.connected = false;
35266
35275
  this.initialized = false;
35276
+ this.forgetOpenDocuments();
35267
35277
  return;
35268
35278
  }
35269
35279
  const socketToClose = this.socket;
35270
35280
  this.socket = null;
35271
35281
  this.connected = false;
35272
35282
  this.initialized = false;
35283
+ this.forgetOpenDocuments();
35273
35284
  await new Promise((resolveClose) => {
35274
35285
  socketToClose.once("close", () => {
35275
35286
  resolveClose();
@@ -35401,6 +35412,7 @@ class GodotLSPClient {
35401
35412
  this.connected = false;
35402
35413
  this.initialized = false;
35403
35414
  this.socket = null;
35415
+ this.forgetOpenDocuments();
35404
35416
  const failure = new Error(`Godot LSP connection error: ${error.message}`);
35405
35417
  this.rejectAllPending(failure);
35406
35418
  this.rejectAllDiagnosticsWaiters(failure);
@@ -35409,10 +35421,14 @@ class GodotLSPClient {
35409
35421
  this.connected = false;
35410
35422
  this.initialized = false;
35411
35423
  this.socket = null;
35424
+ this.forgetOpenDocuments();
35412
35425
  const closed = new Error("Godot LSP socket closed");
35413
35426
  this.rejectAllPending(closed);
35414
35427
  this.rejectAllDiagnosticsWaiters(closed);
35415
35428
  }
35429
+ forgetOpenDocuments() {
35430
+ this.documentVersions.clear();
35431
+ }
35416
35432
  rejectAllPending(error) {
35417
35433
  this.pendingRequests.forEach((pending, id) => {
35418
35434
  clearTimeout(pending.timer);
@@ -36085,6 +36101,10 @@ function realPathOr(path) {
36085
36101
  return path;
36086
36102
  }
36087
36103
  }
36104
+ function samePath(one, other) {
36105
+ const settled = (path) => realPathOr(path).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
36106
+ return settled(one) === settled(other);
36107
+ }
36088
36108
  function aboveTheRunner(entry) {
36089
36109
  const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
36090
36110
  if (runner < 0) {
@@ -36457,9 +36477,10 @@ class GodotServer {
36457
36477
  const known = new Set([...Object.keys(spec.parameters), ...spec.operations ? ["op"] : []]);
36458
36478
  const unknown = Object.keys(args).filter((key) => !known.has(key));
36459
36479
  if (unknown.length > 0) {
36480
+ const takes = known.size === 0 ? "It takes no arguments." : `It takes: ${[...known].join(", ")}.`;
36460
36481
  return {
36461
36482
  ok: false,
36462
- response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. It takes: ${[...known].join(", ")}.`)
36483
+ response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. ${takes}`)
36463
36484
  };
36464
36485
  }
36465
36486
  const wrong = wrongTypes(spec, args);
@@ -36514,7 +36535,8 @@ class GodotServer {
36514
36535
  async dispatch(tool, op, args) {
36515
36536
  const headless = HEADLESS_OPERATIONS[tool]?.[op];
36516
36537
  if (headless !== undefined) {
36517
- return await this.headless(headless, args);
36538
+ const answered = await this.headless(headless, args);
36539
+ return headless === "refresh_class_cache" && this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
36518
36540
  }
36519
36541
  const { op: _op, ...arguments_ } = args;
36520
36542
  const bridge = async (command, extra = {}) => {
@@ -37622,13 +37644,59 @@ class GodotServer {
37622
37644
  const status = asParams(await this.godotBridge.invokeTool("rescan_filesystem", { ...args, statusOnly: true }));
37623
37645
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]);
37624
37646
  }
37647
+ const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
37648
+ const unseen = checked.unseen;
37625
37649
  return this.jsonTextResponse({
37626
- ok: !busy,
37650
+ ok: !busy && unseen.length === 0 && checked.unchecked === undefined,
37627
37651
  stillWorking: busy,
37628
37652
  waitedMs: Date.now() - started,
37629
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : undefined
37653
+ unseenByEditor: unseen.length > 0 ? unseen : undefined,
37654
+ classesUnchecked: checked.unchecked,
37655
+ note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : unseen.length > 0 ? "The scan finished and these classes are still not in the list the editor resolves against, so every use of them reads as an unknown identifier. Its walk skips a file another engine has already imported. Change the declaring script and rescan, or restart the editor with editor_launch restart." : undefined
37630
37656
  });
37631
37657
  }
37658
+ async alsoSayWhatTheEditorCannotSee(answered, args) {
37659
+ const first = answered.content[0];
37660
+ if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
37661
+ return answered;
37662
+ }
37663
+ const checked = await this.classesTheEditorCannotSee(args);
37664
+ if (checked.unseen.length === 0 && checked.unchecked === undefined) {
37665
+ return answered;
37666
+ }
37667
+ return this.jsonTextResponse({
37668
+ ...asParams(JSON.parse(first.text)),
37669
+ unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
37670
+ classesUnchecked: checked.unchecked,
37671
+ note: checked.unseen.length > 0 ? "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. Change the declaring script and editor_rescan, or restart the editor with editor_launch restart." : undefined
37672
+ });
37673
+ }
37674
+ async classesTheEditorCannotSee(args) {
37675
+ const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
37676
+ if (projectPath === "") {
37677
+ return { unseen: [], unchecked: "no projectPath, so there was nothing to read the cache from" };
37678
+ }
37679
+ const open = this.godotBridge.getStatus().projectPath;
37680
+ if (open === undefined || !samePath(open, projectPath)) {
37681
+ return {
37682
+ unseen: [],
37683
+ unchecked: `the connected editor is open on ${open ?? "a project it did not name"}, not this one`
37684
+ };
37685
+ }
37686
+ let held;
37687
+ try {
37688
+ held = asParams(await this.godotBridge.invokeTool("global_classes", args));
37689
+ } catch (error) {
37690
+ return {
37691
+ unseen: [],
37692
+ unchecked: `the editor would not say what classes it is holding: ${errorMessage(error)}`
37693
+ };
37694
+ }
37695
+ if (!Array.isArray(held["classes"])) {
37696
+ return { unseen: [], unchecked: "the editor answered without a class list in it" };
37697
+ }
37698
+ return { unseen: unseenByEditor(projectPath, held["classes"].map(String)) };
37699
+ }
37632
37700
  async handleViaBridge(toolName, args) {
37633
37701
  if (!this.godotBridge.isConnected()) {
37634
37702
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
@@ -39218,6 +39286,12 @@ formatting. \`script_diagnostics\` and \`script_info\` come from the editor's ow
39218
39286
  \`editor_classes\` need nothing open: they run a short headless engine and are gone before the
39219
39287
  answer is printed.
39220
39288
 
39289
+ A wall of "Could not find type" on the project's own classes means the editor is not holding
39290
+ them. \`editor_rescan\` asks it to scan and names any class it still cannot resolve under
39291
+ \`unseenByEditor\`: its walk skips a file a headless engine has already imported, so the
39292
+ declaration and the cache can both be right while the editor stays blind to it. Change the
39293
+ declaring script, or restart the editor.
39294
+
39221
39295
  ## Refusals are useful
39222
39296
 
39223
39297
  An unknown op, an argument the tool does not name, or a missing required one is refused with the
@@ -9,6 +9,7 @@ const SceneTools = preload("tools/scene_tools.gd")
9
9
  const ResourceTools = preload("tools/resource_tools.gd")
10
10
  const AnimationTools = preload("tools/animation_tools.gd")
11
11
  const PlayTools = preload("tools/play_tools.gd")
12
+ const ClassTools = preload("tools/class_tools.gd")
12
13
 
13
14
  var _editor_plugin: EditorPlugin = null
14
15
 
@@ -16,6 +17,7 @@ var _scene_tools: SceneTools = null
16
17
  var _resource_tools: ResourceTools = null
17
18
  var _animation_tools: AnimationTools = null
18
19
  var _play_tools: PlayTools = null
20
+ var _class_tools: ClassTools = null
19
21
 
20
22
  var _tool_map: Dictionary = {}
21
23
  var _initialized: bool = false
@@ -28,6 +30,7 @@ func set_editor_plugin(plugin: EditorPlugin) -> void:
28
30
  _resource_tools.set_editor_plugin(plugin)
29
31
  _animation_tools.set_editor_plugin(plugin)
30
32
  _play_tools.set_editor_plugin(plugin)
33
+ _class_tools.set_editor_plugin(plugin)
31
34
 
32
35
 
33
36
  func _init_tools() -> void:
@@ -51,6 +54,10 @@ func _init_tools() -> void:
51
54
  _play_tools.name = "PlayTools"
52
55
  add_child(_play_tools)
53
56
 
57
+ _class_tools = ClassTools.new()
58
+ _class_tools.name = "ClassTools"
59
+ add_child(_class_tools)
60
+
54
61
  _tool_map = {
55
62
  # Scene tools
56
63
  "create_scene": [_scene_tools, "create_scene"],
@@ -66,6 +73,7 @@ func _init_tools() -> void:
66
73
  "disconnect_signal": [_scene_tools, "disconnect_signal"],
67
74
  "list_connections": [_scene_tools, "list_connections"],
68
75
  "rescan_filesystem": [_scene_tools, "rescan_filesystem"],
76
+ "global_classes": [_class_tools, "global_classes"],
69
77
  # Resource tools
70
78
  "create_resource": [_resource_tools, "create_resource"],
71
79
  "modify_resource": [_resource_tools, "modify_resource"],
@@ -0,0 +1,56 @@
1
+ @tool
2
+ extends Node
3
+
4
+ ## The global classes the running editor is holding.
5
+ ##
6
+ ## The editor fixes this list when it starts and adds to it only from a filesystem scan, and
7
+ ## that scan is change-detecting: a file another engine has already imported looks settled, so
8
+ ## the walk goes past it without reading the declaration inside. The class is then declared on
9
+ ## disk, listed in `.godot/global_script_class_cache.cfg`, and absent from the editor, whose
10
+ ## analyser reports every use of it as an unknown identifier. Rebuilding the cache does not
11
+ ## help, because nothing rereads it.
12
+ ##
13
+ ## Every check there was compares one file on disk against another, so all of them call that
14
+ ## state clean. The editor is the only thing that can say otherwise, so it is asked.
15
+ ##
16
+ ## Read off the editor's own filesystem entries rather than from
17
+ ## `ProjectSettings.get_global_class_list()`. The entries are what the scan itself recorded, so
18
+ ## nothing but a scan can change them; the settings list is loaded from the cache file when it is
19
+ ## first asked for, which makes it a poor witness against a cache that was just rewritten, and
20
+ ## rewriting the cache is the call most in need of an honest answer here.
21
+ ##
22
+ ## A file the scan skipped is either missing from the tree or recorded with no class name against
23
+ ## it, and both come back as a class the editor does not have, which is the whole of the question.
24
+
25
+ var _editor_plugin: EditorPlugin = null
26
+
27
+
28
+ func set_editor_plugin(plugin: EditorPlugin) -> void:
29
+ _editor_plugin = plugin
30
+
31
+
32
+ func global_classes(_args: Dictionary) -> Dictionary:
33
+ if not _editor_plugin:
34
+ return {"ok": false, "error": "Editor plugin unavailable"}
35
+
36
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
37
+ var names: PackedStringArray = []
38
+ _walk(filesystem.get_filesystem(), names)
39
+ names.sort()
40
+ return {
41
+ "ok": true,
42
+ "classes": names,
43
+ "scanning": filesystem.is_scanning(),
44
+ "importing": filesystem.is_importing(),
45
+ }
46
+
47
+
48
+ func _walk(directory: EditorFileSystemDirectory, into: PackedStringArray) -> void:
49
+ if directory == null:
50
+ return
51
+ for index: int in range(directory.get_file_count()):
52
+ var declared: String = directory.get_file_script_class_name(index)
53
+ if not declared.is_empty():
54
+ into.append(declared)
55
+ for index: int in range(directory.get_subdir_count()):
56
+ _walk(directory.get_subdir(index), into)
@@ -0,0 +1 @@
1
+ uid://bodtvclhs3toc
@@ -45,6 +45,17 @@ func set_project_setting(params: Dictionary) -> Dictionary:
45
45
  old_value = ProjectSettings.get_setting(setting_path)
46
46
 
47
47
  var final_value: Variant = _values.deserialize_value(value)
48
+ var wanted: int = _wanted_type(setting_path, old_value if had_value else null)
49
+ if wanted > TYPE_NIL and final_value != null and typeof(final_value) != wanted:
50
+ if not _converts_faithfully(final_value, wanted):
51
+ return _log.failure(
52
+ (
53
+ "%s is declared as %s, and %s (%s) cannot be written as that without losing something."
54
+ % [setting_path, type_string(wanted), str(final_value), type_string(typeof(final_value))]
55
+ )
56
+ )
57
+ final_value = type_convert(final_value, wanted)
58
+
48
59
  ProjectSettings.set_setting(setting_path, final_value)
49
60
  var err: Error = ProjectSettings.save()
50
61
  if err != OK:
@@ -59,6 +70,33 @@ func set_project_setting(params: Dictionary) -> Dictionary:
59
70
  }
60
71
 
61
72
 
73
+ # The type a setting wants, or TYPE_NIL where the engine names none and there is nothing to go on.
74
+ #
75
+ # JSON carries one number type, so every number arrives as a float and an int setting was written
76
+ # to project.godot as "50.0". Every reader casts it back, so the tool, the editor and the running
77
+ # game all answer 50 afterwards and only the committed file says otherwise, where it reads as
78
+ # something having gone wrong.
79
+ #
80
+ # The engine's own property list is what is asked, rather than the value that is there: a setting
81
+ # already written as "50.0" reads back as a float, so the value on hand agrees with the mistake and
82
+ # no project that has made it once could be set right again. The property list still says int.
83
+ # A setting the engine does not declare has only its current value to go on, and a new one has
84
+ # neither, which is the caller's type to choose.
85
+ func _wanted_type(setting_path: String, current: Variant) -> int:
86
+ for info: Dictionary in ProjectSettings.get_property_list():
87
+ if str(info.get("name", "")) == setting_path:
88
+ return int(info.get("type", TYPE_NIL))
89
+ return typeof(current)
90
+
91
+
92
+ # What separates a narrowing from a guess: 50.0 through int and out again is 50.0, so it was an int
93
+ # all along, while 50.5 comes back as 50.0 and "loud" comes back as "0". Those are a caller meaning
94
+ # something the setting cannot hold, and are refused rather than written as whatever the cast
95
+ # happened to produce.
96
+ func _converts_faithfully(value: Variant, to: int) -> bool:
97
+ return type_convert(type_convert(value, to), typeof(value)) == value
98
+
99
+
62
100
  # Add an autoload singleton
63
101
  func add_autoload(params: Dictionary) -> Dictionary:
64
102
  var name: String = str(params.get("name", ""))
package/build/index.js CHANGED
@@ -24758,6 +24758,9 @@ import { join as join3 } from "node:path";
24758
24758
  function declaredClasses(projectPath) {
24759
24759
  const declared = new Map;
24760
24760
  const visit = (directory, prefix) => {
24761
+ if (existsSync2(join3(directory, ".gdignore"))) {
24762
+ return;
24763
+ }
24761
24764
  for (const entry of readdirSync(directory, { withFileTypes: true })) {
24762
24765
  if (entry.name.startsWith(".")) {
24763
24766
  continue;
@@ -24795,6 +24798,12 @@ function staleClassNames(projectPath) {
24795
24798
  const cached = cachedClasses(projectPath);
24796
24799
  return cached === null ? [...declaredClasses(projectPath).keys()] : staleAgainst(cached, projectPath);
24797
24800
  }
24801
+ function unseenByEditor(projectPath, editorHolds) {
24802
+ const held = new Set(editorHolds);
24803
+ const declared = declaredClasses(projectPath);
24804
+ const source = cachedClasses(projectPath) ?? declared;
24805
+ return [...source].filter(([name]) => !held.has(name) && declared.has(name)).map(([className, path]) => ({ className, path: declared.get(className) ?? path }));
24806
+ }
24798
24807
 
24799
24808
  // src/dap_client.ts
24800
24809
  import { createConnection } from "node:net";
@@ -26694,12 +26703,14 @@ class GodotLSPClient {
26694
26703
  if (!this.socket) {
26695
26704
  this.connected = false;
26696
26705
  this.initialized = false;
26706
+ this.forgetOpenDocuments();
26697
26707
  return;
26698
26708
  }
26699
26709
  const socketToClose = this.socket;
26700
26710
  this.socket = null;
26701
26711
  this.connected = false;
26702
26712
  this.initialized = false;
26713
+ this.forgetOpenDocuments();
26703
26714
  await new Promise((resolveClose) => {
26704
26715
  socketToClose.once("close", () => {
26705
26716
  resolveClose();
@@ -26831,6 +26842,7 @@ class GodotLSPClient {
26831
26842
  this.connected = false;
26832
26843
  this.initialized = false;
26833
26844
  this.socket = null;
26845
+ this.forgetOpenDocuments();
26834
26846
  const failure = new Error(`Godot LSP connection error: ${error.message}`);
26835
26847
  this.rejectAllPending(failure);
26836
26848
  this.rejectAllDiagnosticsWaiters(failure);
@@ -26839,10 +26851,14 @@ class GodotLSPClient {
26839
26851
  this.connected = false;
26840
26852
  this.initialized = false;
26841
26853
  this.socket = null;
26854
+ this.forgetOpenDocuments();
26842
26855
  const closed = new Error("Godot LSP socket closed");
26843
26856
  this.rejectAllPending(closed);
26844
26857
  this.rejectAllDiagnosticsWaiters(closed);
26845
26858
  }
26859
+ forgetOpenDocuments() {
26860
+ this.documentVersions.clear();
26861
+ }
26846
26862
  rejectAllPending(error) {
26847
26863
  this.pendingRequests.forEach((pending, id) => {
26848
26864
  clearTimeout(pending.timer);
@@ -27974,7 +27990,7 @@ var TOOL_SPECS = [
27974
27990
  uid: { summary: "the UID of one file", requires: ["resourcePath"] },
27975
27991
  refresh_uids: { summary: "resave every resource so UID references are current", requires: [] },
27976
27992
  refresh_classes: {
27977
- summary: "rewrite .godot/global_script_class_cache.cfg from the class_name declarations on disk, for an editor whose list has gone stale",
27993
+ 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',
27978
27994
  requires: []
27979
27995
  }
27980
27996
  }
@@ -28319,7 +28335,7 @@ var TOOL_SPECS = [
28319
28335
  },
28320
28336
  {
28321
28337
  name: "editor_rescan",
28322
- description: "Makes the running editor scan the project filesystem, so files written outside it, and any class_name they declare, become visible. Needs the editor connected.",
28338
+ description: "Makes the running editor scan the project filesystem, so files written outside it are picked up. The scan is a change-detecting walk rather than an unconditional reparse, so a file another engine has already imported reads as settled and the walk does not look inside it: its class_name then stays out of the list the editor resolves against, however many times you scan. Any class in that state is named under unseenByEditor, which is what no check on disk can see, since the declaration and the cache are both correct there and only the editor disagrees. The cure is a change to the declaring script, or editor_launch restart; project_import refresh_classes rewrites the cache and does not touch what the editor is holding. Needs the editor connected.",
28323
28339
  parameters: {
28324
28340
  projectPath: PROJECT_PATH,
28325
28341
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -28864,6 +28880,10 @@ function realPathOr(path) {
28864
28880
  return path;
28865
28881
  }
28866
28882
  }
28883
+ function samePath(one, other) {
28884
+ const settled = (path) => realPathOr(path).replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
28885
+ return settled(one) === settled(other);
28886
+ }
28867
28887
  var RUNNER_DIRECTORY = "addons/gdUnit4/";
28868
28888
  function aboveTheRunner(entry) {
28869
28889
  const runner = entry.detail.findIndex((line) => line.includes(RUNNER_DIRECTORY));
@@ -29237,9 +29257,10 @@ class GodotServer {
29237
29257
  const known = new Set([...Object.keys(spec.parameters), ...spec.operations ? ["op"] : []]);
29238
29258
  const unknown = Object.keys(args).filter((key) => !known.has(key));
29239
29259
  if (unknown.length > 0) {
29260
+ const takes = known.size === 0 ? "It takes no arguments." : `It takes: ${[...known].join(", ")}.`;
29240
29261
  return {
29241
29262
  ok: false,
29242
- response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. It takes: ${[...known].join(", ")}.`)
29263
+ response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. ${takes}`)
29243
29264
  };
29244
29265
  }
29245
29266
  const wrong = wrongTypes(spec, args);
@@ -29294,7 +29315,8 @@ class GodotServer {
29294
29315
  async dispatch(tool, op, args) {
29295
29316
  const headless = HEADLESS_OPERATIONS[tool]?.[op];
29296
29317
  if (headless !== undefined) {
29297
- return await this.headless(headless, args);
29318
+ const answered = await this.headless(headless, args);
29319
+ return headless === "refresh_class_cache" && this.godotBridge.isConnected() ? await this.alsoSayWhatTheEditorCannotSee(answered, args) : answered;
29298
29320
  }
29299
29321
  const { op: _op, ...arguments_ } = args;
29300
29322
  const bridge = async (command, extra = {}) => {
@@ -30402,13 +30424,59 @@ class GodotServer {
30402
30424
  const status = asParams(await this.godotBridge.invokeTool("rescan_filesystem", { ...args, statusOnly: true }));
30403
30425
  busy = Boolean(status["scanning"]) || Boolean(status["importing"]);
30404
30426
  }
30427
+ const checked = busy ? { unseen: [] } : await this.classesTheEditorCannotSee(args);
30428
+ const unseen = checked.unseen;
30405
30429
  return this.jsonTextResponse({
30406
- ok: !busy,
30430
+ ok: !busy && unseen.length === 0 && checked.unchecked === undefined,
30407
30431
  stillWorking: busy,
30408
30432
  waitedMs: Date.now() - started,
30409
- note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : undefined
30433
+ unseenByEditor: unseen.length > 0 ? unseen : undefined,
30434
+ classesUnchecked: checked.unchecked,
30435
+ note: busy ? "The editor was still scanning or importing when the wait ran out, so new files may not be visible yet." : unseen.length > 0 ? "The scan finished and these classes are still not in the list the editor resolves against, so every use of them reads as an unknown identifier. Its walk skips a file another engine has already imported. Change the declaring script and rescan, or restart the editor with editor_launch restart." : undefined
30410
30436
  });
30411
30437
  }
30438
+ async alsoSayWhatTheEditorCannotSee(answered, args) {
30439
+ const first = answered.content[0];
30440
+ if (answered.isError || first?.type !== "text" || typeof first.text !== "string") {
30441
+ return answered;
30442
+ }
30443
+ const checked = await this.classesTheEditorCannotSee(args);
30444
+ if (checked.unseen.length === 0 && checked.unchecked === undefined) {
30445
+ return answered;
30446
+ }
30447
+ return this.jsonTextResponse({
30448
+ ...asParams(JSON.parse(first.text)),
30449
+ unseenByEditor: checked.unseen.length > 0 ? checked.unseen : undefined,
30450
+ classesUnchecked: checked.unchecked,
30451
+ note: checked.unseen.length > 0 ? "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. Change the declaring script and editor_rescan, or restart the editor with editor_launch restart." : undefined
30452
+ });
30453
+ }
30454
+ async classesTheEditorCannotSee(args) {
30455
+ const projectPath = typeof args["projectPath"] === "string" ? args["projectPath"] : "";
30456
+ if (projectPath === "") {
30457
+ return { unseen: [], unchecked: "no projectPath, so there was nothing to read the cache from" };
30458
+ }
30459
+ const open = this.godotBridge.getStatus().projectPath;
30460
+ if (open === undefined || !samePath(open, projectPath)) {
30461
+ return {
30462
+ unseen: [],
30463
+ unchecked: `the connected editor is open on ${open ?? "a project it did not name"}, not this one`
30464
+ };
30465
+ }
30466
+ let held;
30467
+ try {
30468
+ held = asParams(await this.godotBridge.invokeTool("global_classes", args));
30469
+ } catch (error) {
30470
+ return {
30471
+ unseen: [],
30472
+ unchecked: `the editor would not say what classes it is holding: ${errorMessage(error)}`
30473
+ };
30474
+ }
30475
+ if (!Array.isArray(held["classes"])) {
30476
+ return { unseen: [], unchecked: "the editor answered without a class list in it" };
30477
+ }
30478
+ return { unseen: unseenByEditor(projectPath, held["classes"].map(String)) };
30479
+ }
30412
30480
  async handleViaBridge(toolName, args) {
30413
30481
  if (!this.godotBridge.isConnected()) {
30414
30482
  return this.createErrorResponse("Editor not connected: no Godot editor with the gdharness editor addon has reached this server.", [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.6",
3
+ "version": "0.10.0",
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",