gdharness 0.9.5 → 0.9.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
@@ -15382,7 +15382,10 @@ var init_tool_definitions = __esm(() => {
15382
15382
  parameters: {
15383
15383
  projectPath: PROJECT_PATH,
15384
15384
  preset: { type: "string", description: "Preset name from export_presets.cfg." },
15385
- outputPath: { type: "string", description: "Where the export is written, inside the project." },
15385
+ outputPath: {
15386
+ type: "string",
15387
+ description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
15388
+ },
15386
15389
  debug: { type: "boolean", description: "run: a debug export. Default false." }
15387
15390
  },
15388
15391
  requires: ["projectPath"],
@@ -15713,7 +15716,7 @@ var init_tool_definitions = __esm(() => {
15713
15716
  },
15714
15717
  {
15715
15718
  name: "editor_rescan",
15716
- 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.",
15719
+ 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 the editor has already recorded and reads as unchanged is one it does not look inside again: a class_name usually becomes visible this way and on one project did not, with the cache on disk still missing it after the scan said it was done. project_import refresh_classes is the one that rebuilds the class list from the declarations on disk, whatever the editor thinks it has seen. Needs the editor connected.",
15717
15720
  parameters: {
15718
15721
  projectPath: PROJECT_PATH,
15719
15722
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -15791,7 +15794,7 @@ var init_tool_definitions = __esm(() => {
15791
15794
  says: {
15792
15795
  type: "string",
15793
15796
  ops: ["find"],
15794
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match."
15797
+ description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern."
15795
15798
  },
15796
15799
  limit: {
15797
15800
  type: "number",
@@ -35261,12 +35264,14 @@ class GodotLSPClient {
35261
35264
  if (!this.socket) {
35262
35265
  this.connected = false;
35263
35266
  this.initialized = false;
35267
+ this.forgetOpenDocuments();
35264
35268
  return;
35265
35269
  }
35266
35270
  const socketToClose = this.socket;
35267
35271
  this.socket = null;
35268
35272
  this.connected = false;
35269
35273
  this.initialized = false;
35274
+ this.forgetOpenDocuments();
35270
35275
  await new Promise((resolveClose) => {
35271
35276
  socketToClose.once("close", () => {
35272
35277
  resolveClose();
@@ -35398,6 +35403,7 @@ class GodotLSPClient {
35398
35403
  this.connected = false;
35399
35404
  this.initialized = false;
35400
35405
  this.socket = null;
35406
+ this.forgetOpenDocuments();
35401
35407
  const failure = new Error(`Godot LSP connection error: ${error.message}`);
35402
35408
  this.rejectAllPending(failure);
35403
35409
  this.rejectAllDiagnosticsWaiters(failure);
@@ -35406,10 +35412,14 @@ class GodotLSPClient {
35406
35412
  this.connected = false;
35407
35413
  this.initialized = false;
35408
35414
  this.socket = null;
35415
+ this.forgetOpenDocuments();
35409
35416
  const closed = new Error("Godot LSP socket closed");
35410
35417
  this.rejectAllPending(closed);
35411
35418
  this.rejectAllDiagnosticsWaiters(closed);
35412
35419
  }
35420
+ forgetOpenDocuments() {
35421
+ this.documentVersions.clear();
35422
+ }
35413
35423
  rejectAllPending(error) {
35414
35424
  this.pendingRequests.forEach((pending, id) => {
35415
35425
  clearTimeout(pending.timer);
@@ -36002,7 +36012,7 @@ var init_setup = __esm(() => {
36002
36012
 
36003
36013
  // src/server.ts
36004
36014
  import { execFile as execFile5, spawn } from "node:child_process";
36005
- import { existsSync as existsSync12, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
36015
+ import { existsSync as existsSync12, mkdirSync as mkdirSync7, mkdtempSync as mkdtempSync3, readdirSync as readdirSync6, readFileSync as readFileSync13, realpathSync as realpathSync2, rmSync as rmSync8 } from "node:fs";
36006
36016
  import { tmpdir as tmpdir5 } from "node:os";
36007
36017
  import { basename as basename2, dirname as dirname9, join as join15, normalize as normalize3 } from "node:path";
36008
36018
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -36454,9 +36464,10 @@ class GodotServer {
36454
36464
  const known = new Set([...Object.keys(spec.parameters), ...spec.operations ? ["op"] : []]);
36455
36465
  const unknown = Object.keys(args).filter((key) => !known.has(key));
36456
36466
  if (unknown.length > 0) {
36467
+ const takes = known.size === 0 ? "It takes no arguments." : `It takes: ${[...known].join(", ")}.`;
36457
36468
  return {
36458
36469
  ok: false,
36459
- response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. It takes: ${[...known].join(", ")}.`)
36470
+ response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. ${takes}`)
36460
36471
  };
36461
36472
  }
36462
36473
  const wrong = wrongTypes(spec, args);
@@ -36935,6 +36946,12 @@ class GodotServer {
36935
36946
  if (!engine.ok) {
36936
36947
  return engine.response;
36937
36948
  }
36949
+ const folder = dirname9(output.absolutePath);
36950
+ try {
36951
+ mkdirSync7(folder, { recursive: true });
36952
+ } catch (error) {
36953
+ return this.createErrorResponse(`Cannot create ${dirname9(output.relativePath)} to export into: ${errorMessage(error)}`);
36954
+ }
36938
36955
  const debug = readBoolean(args, "debug") ?? false;
36939
36956
  const exportArgs = [
36940
36957
  "--headless",
@@ -38930,6 +38947,7 @@ init_class_cache();
38930
38947
  init_headless();
38931
38948
  init_resources();
38932
38949
  init_server_version();
38950
+ import { spawnSync } from "node:child_process";
38933
38951
  import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readFileSync as readFileSync7, rmSync as rmSync4, writeFileSync as writeFileSync5 } from "node:fs";
38934
38952
  import { dirname as dirname3, join as join8 } from "node:path";
38935
38953
  import { fileURLToPath } from "node:url";
@@ -38992,6 +39010,29 @@ async function enablePlugins(engine, projectPath, names) {
38992
39010
  async function setRuntime(engine, projectPath, on) {
38993
39011
  return on ? await runOperation2(engine, "add_autoload", { name: RUNTIME_AUTOLOAD.name, path: `res://${RUNTIME_AUTOLOAD.path}`, enabled: true }, projectPath) : await runOperation2(engine, "remove_autoload", { name: RUNTIME_AUTOLOAD.name }, projectPath);
38994
39012
  }
39013
+ function autoloadPaths(settings) {
39014
+ const named = new Map;
39015
+ for (const [name, value] of Object.entries(settings["autoload"] ?? {})) {
39016
+ const said = typeof value === "string" ? value.replace(/^\*/, "") : "";
39017
+ if (said.startsWith("res://")) {
39018
+ named.set(name, said.slice("res://".length));
39019
+ }
39020
+ }
39021
+ return named;
39022
+ }
39023
+ function trackedByGit(projectPath, wanted) {
39024
+ if (wanted.length === 0) {
39025
+ return new Set;
39026
+ }
39027
+ const asked = spawnSync("git", ["ls-files", "-z", "--", ...wanted], {
39028
+ cwd: projectPath,
39029
+ encoding: "utf8"
39030
+ });
39031
+ if (asked.status !== 0) {
39032
+ return null;
39033
+ }
39034
+ return new Set(asked.stdout.split("\x00").filter(Boolean));
39035
+ }
38995
39036
  function enabledPlugins(settings) {
38996
39037
  const raw = settings["editor_plugins"]?.["enabled"];
38997
39038
  if (typeof raw !== "string") {
@@ -39023,6 +39064,13 @@ function inspectProject(projectPath) {
39023
39064
  }
39024
39065
  const autoload = settings["autoload"];
39025
39066
  const runtimeAutoload = autoload !== undefined && typeof readString(autoload, RUNTIME_AUTOLOAD.name) === "string";
39067
+ const named = autoloadPaths(settings);
39068
+ const tracked = trackedByGit(projectPath, [...named.values()]);
39069
+ for (const [name, path] of named) {
39070
+ if (tracked !== null && !tracked.has(path) && existsSync7(join8(projectPath, path))) {
39071
+ problems.push(`the ${name} autoload names res://${path}, which git does not carry, so a clone boots with a missing script; commit that file, or point the autoload at one the project tracks`);
39072
+ }
39073
+ }
39026
39074
  const cached = cachedClasses(projectPath);
39027
39075
  const staleClasses = cached === null ? [] : staleAgainst(cached, projectPath);
39028
39076
  if (cached === null) {
@@ -39157,6 +39205,9 @@ that is running, and the project on disk. ${TOOL_SPECS.length} tools, named \`do
39157
39205
  These need the runtime autoload, which \`gdharness setup\` registers. \`runtime_capture\` needs a
39158
39206
  window and refuses headless rather than handing back the last frame anything drew.
39159
39207
 
39208
+ A headless game answers; a \`godot -s\` script run does not. It has no game in it, so the autoload
39209
+ stays quiet there rather than announcing a test tier as the project.
39210
+
39160
39211
  The autoload reaches an export. \`gdharness runtime off\` in the project before shipping.
39161
39212
 
39162
39213
  ## At a breakpoint
@@ -23,6 +23,12 @@ const BIND_ADDRESS_SETTING: String = "gdharness/runtime/bind_address"
23
23
  ## expects. A fixed port is for a client that cannot read the announcement.
24
24
  const PORT_SETTING: String = "gdharness/runtime/port"
25
25
 
26
+ ## How the engine is told to run a script instead of the game: `godot -s thing.gd`.
27
+ const SCRIPT_FLAGS: PackedStringArray = ["-s", "--script"]
28
+
29
+ ## Serve a script run anyway, for somebody driving a `-s` script rather than a game.
30
+ const SCRIPT_RUNS_SETTING: String = "gdharness/runtime/serve_script_runs"
31
+
26
32
  var values: Values = Values.new()
27
33
 
28
34
  # The modules are members and not locals of _init, because a Callable holds its object by id
@@ -161,6 +167,15 @@ func _start_server() -> void:
161
167
  _enabled = false
162
168
  return
163
169
 
170
+ # And a script run has nothing to serve. Autoloads come up for `godot -s` as well, so a test
171
+ # tier or a batch tool binds a loopback port and writes an announcement under the project's
172
+ # own path: a gate that starts sixteen engines at once announces sixteen games that are not
173
+ # games, and a client asking the runtime anything while they run can be answered by whichever
174
+ # of them replies first. Measured on two projects before it was written here.
175
+ if _script_run() and not bool(ProjectSettings.get_setting(SCRIPT_RUNS_SETTING, false)):
176
+ _enabled = false
177
+ return
178
+
164
179
  _server = TCPServer.new()
165
180
  # listen() defaults bind_address to "*", which exposes the game to the whole network.
166
181
  var bind_address: String = str(ProjectSettings.get_setting(BIND_ADDRESS_SETTING, DEFAULT_BIND_ADDRESS))
@@ -180,6 +195,15 @@ func _start_server() -> void:
180
195
  print("[gdharness] runtime listening on %s:%d, announced at %s" % [bind_address, _port, _announcement])
181
196
 
182
197
 
198
+ ## Whether the engine was told to run a script rather than the game.
199
+ func _script_run() -> bool:
200
+ var given: PackedStringArray = OS.get_cmdline_args()
201
+ for flag: String in SCRIPT_FLAGS:
202
+ if given.has(flag):
203
+ return true
204
+ return false
205
+
206
+
183
207
  ## Where the announcement goes. The server derives the same path with the same precedence, so
184
208
  ## the two only meet if this stays in step with `runtimeDirectory` in src/runtime-client.ts.
185
209
  func _announcement_directory() -> String:
@@ -160,6 +160,17 @@ static func _is_literal(pattern: String) -> bool:
160
160
  return not pattern.is_empty() and not pattern.contains("*") and not pattern.contains("?")
161
161
 
162
162
 
163
+ ## Whether [param said] is what a find asked for in [param wanted].
164
+ ##
165
+ ## A plain word is a contains, which is what somebody looking for the row about a person means. A
166
+ ## pattern is a glob, because [code]namePattern[/code] beside it is one and nobody writes `*Still*`
167
+ ## in one field meaning a glob and in the other meaning those characters. Written as a contains
168
+ ## only, a glob matched nothing at all and an empty answer reads as a control that is not on the
169
+ ## screen: twice in one session here, over a button that was.
170
+ static func _says(said: String, wanted: String) -> bool:
171
+ return said.containsn(wanted) if _is_literal(wanted) else said.matchn(wanted)
172
+
173
+
163
174
  ## Every filter but the name, so a find that came back empty can say how many nodes the name was
164
175
  ## the only thing standing between it and.
165
176
  func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) -> bool:
@@ -168,7 +179,7 @@ func _matches_apart_from_name(node: Node, wanted: Dictionary[String, String]) ->
168
179
  # What this node says, rather than everything said underneath it. A row is then found by the
169
180
  # label in it, and the path answered is that label's, which is where the words a caller is
170
181
  # looking at actually are: matching every container above it would answer with the screen.
171
- if not wanted["says"].is_empty() and not said_by(node).containsn(wanted["says"]):
182
+ if not wanted["says"].is_empty() and not _says(said_by(node), wanted["says"]):
172
183
  return false
173
184
  var script: Variant = node.get_script()
174
185
  if not wanted["script"].is_empty():
package/build/index.js CHANGED
@@ -10126,7 +10126,7 @@ function defectReport(where, error, godotVersion) {
10126
10126
 
10127
10127
  // src/server.ts
10128
10128
  import { execFile as execFile3, spawn } from "node:child_process";
10129
- import { existsSync as existsSync7, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10129
+ import { existsSync as existsSync7, mkdirSync as mkdirSync4, mkdtempSync as mkdtempSync2, readdirSync as readdirSync5, readFileSync as readFileSync9, realpathSync as realpathSync2, rmSync as rmSync4 } from "node:fs";
10130
10130
  import { tmpdir as tmpdir4 } from "node:os";
10131
10131
  import { basename, dirname as dirname4, join as join9, normalize as normalize2 } from "node:path";
10132
10132
  import { setTimeout as delay2 } from "node:timers/promises";
@@ -26694,12 +26694,14 @@ class GodotLSPClient {
26694
26694
  if (!this.socket) {
26695
26695
  this.connected = false;
26696
26696
  this.initialized = false;
26697
+ this.forgetOpenDocuments();
26697
26698
  return;
26698
26699
  }
26699
26700
  const socketToClose = this.socket;
26700
26701
  this.socket = null;
26701
26702
  this.connected = false;
26702
26703
  this.initialized = false;
26704
+ this.forgetOpenDocuments();
26703
26705
  await new Promise((resolveClose) => {
26704
26706
  socketToClose.once("close", () => {
26705
26707
  resolveClose();
@@ -26831,6 +26833,7 @@ class GodotLSPClient {
26831
26833
  this.connected = false;
26832
26834
  this.initialized = false;
26833
26835
  this.socket = null;
26836
+ this.forgetOpenDocuments();
26834
26837
  const failure = new Error(`Godot LSP connection error: ${error.message}`);
26835
26838
  this.rejectAllPending(failure);
26836
26839
  this.rejectAllDiagnosticsWaiters(failure);
@@ -26839,10 +26842,14 @@ class GodotLSPClient {
26839
26842
  this.connected = false;
26840
26843
  this.initialized = false;
26841
26844
  this.socket = null;
26845
+ this.forgetOpenDocuments();
26842
26846
  const closed = new Error("Godot LSP socket closed");
26843
26847
  this.rejectAllPending(closed);
26844
26848
  this.rejectAllDiagnosticsWaiters(closed);
26845
26849
  }
26850
+ forgetOpenDocuments() {
26851
+ this.documentVersions.clear();
26852
+ }
26846
26853
  rejectAllPending(error) {
26847
26854
  this.pendingRequests.forEach((pending, id) => {
26848
26855
  clearTimeout(pending.timer);
@@ -27985,7 +27992,10 @@ var TOOL_SPECS = [
27985
27992
  parameters: {
27986
27993
  projectPath: PROJECT_PATH,
27987
27994
  preset: { type: "string", description: "Preset name from export_presets.cfg." },
27988
- outputPath: { type: "string", description: "Where the export is written, inside the project." },
27995
+ outputPath: {
27996
+ type: "string",
27997
+ description: `Where the export is written, inside the project. The directory is created if it is not there: Godot's command-line exporter refuses a missing one with "The given export path doesn't exist", which reads as a wrong path in the preset.`
27998
+ },
27989
27999
  debug: { type: "boolean", description: "run: a debug export. Default false." }
27990
28000
  },
27991
28001
  requires: ["projectPath"],
@@ -28316,7 +28326,7 @@ var TOOL_SPECS = [
28316
28326
  },
28317
28327
  {
28318
28328
  name: "editor_rescan",
28319
- 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.",
28329
+ 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 the editor has already recorded and reads as unchanged is one it does not look inside again: a class_name usually becomes visible this way and on one project did not, with the cache on disk still missing it after the scan said it was done. project_import refresh_classes is the one that rebuilds the class list from the declarations on disk, whatever the editor thinks it has seen. Needs the editor connected.",
28320
28330
  parameters: {
28321
28331
  projectPath: PROJECT_PATH,
28322
28332
  timeoutMs: { type: "number", description: "How long to wait for the scan. Default 30000." }
@@ -28394,7 +28404,7 @@ var TOOL_SPECS = [
28394
28404
  says: {
28395
28405
  type: "string",
28396
28406
  ops: ["find"],
28397
- description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match."
28407
+ description: "find: part of what the node has written on it, case-insensitively, which is how a button is reached by the word on it rather than by a generated path. Its own text, so a row is found by the label in it, and hidden nodes match. A bare word is a contains; write a glob and it is one, matched against the whole of what the node says, the same as namePattern."
28398
28408
  },
28399
28409
  limit: {
28400
28410
  type: "number",
@@ -29234,9 +29244,10 @@ class GodotServer {
29234
29244
  const known = new Set([...Object.keys(spec.parameters), ...spec.operations ? ["op"] : []]);
29235
29245
  const unknown = Object.keys(args).filter((key) => !known.has(key));
29236
29246
  if (unknown.length > 0) {
29247
+ const takes = known.size === 0 ? "It takes no arguments." : `It takes: ${[...known].join(", ")}.`;
29237
29248
  return {
29238
29249
  ok: false,
29239
- response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. It takes: ${[...known].join(", ")}.`)
29250
+ response: this.createErrorResponse(`${spec.name} does not take ${unknown.join(", ")}. ${takes}`)
29240
29251
  };
29241
29252
  }
29242
29253
  const wrong = wrongTypes(spec, args);
@@ -29715,6 +29726,12 @@ class GodotServer {
29715
29726
  if (!engine.ok) {
29716
29727
  return engine.response;
29717
29728
  }
29729
+ const folder = dirname4(output.absolutePath);
29730
+ try {
29731
+ mkdirSync4(folder, { recursive: true });
29732
+ } catch (error) {
29733
+ return this.createErrorResponse(`Cannot create ${dirname4(output.relativePath)} to export into: ${errorMessage(error)}`);
29734
+ }
29718
29735
  const debug = readBoolean(args, "debug") ?? false;
29719
29736
  const exportArgs = [
29720
29737
  "--headless",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.9.5",
3
+ "version": "0.9.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",