gdharness 0.13.30 → 0.13.32

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.
@@ -13,10 +13,74 @@ extends RefCounted
13
13
  # `engine_messages`. The empty RegEx it then returns matches nothing, which is the honest result of
14
14
  # a pattern that does not exist.
15
15
 
16
+ # Kept, because the readers below call these per line of every script in a project and a fresh
17
+ # compile each time is the whole cost of the scan. The patterns are literals in this repository, so
18
+ # the set is bounded by the source rather than by anything a caller passes.
19
+ #
20
+ # Held by `test/support/gd/patterns_cache.gd`, with `is_same` rather than `==`: two RegEx built from
21
+ # one pattern match the same text and compare equal on everything else, so identity is the only
22
+ # question that separates a cache from a recompile. Without that case, taking the cache away would
23
+ # have broken no answer anywhere and failed nothing, which is the argued-and-unheld shape.
24
+ static var _compiled: Dictionary = {}
25
+
16
26
 
17
27
  static func compiled(expression: String) -> RegEx:
28
+ if _compiled.has(expression):
29
+ var held: RegEx = _compiled[expression]
30
+ return held
18
31
  var regex: RegEx = RegEx.new()
19
32
  var built: Error = regex.compile(expression)
20
33
  if built != OK:
21
34
  push_error("gdharness: could not compile the pattern " + expression)
35
+ _compiled[expression] = regex
22
36
  return regex
37
+
38
+
39
+ # What a header line declares: the class name, and the base named on the same line if there is one.
40
+ #
41
+ # `class_name X extends Y` is one line, so a reader that takes the rest of the line as the name gets
42
+ # `Blade extends Node2D` for the class and never sees the base at all.
43
+ static func declared_class(line: String) -> RegExMatch:
44
+ return compiled("^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s+extends\\s+(\\S+))?").search(line)
45
+
46
+
47
+ # The annotations at the front of a line, in order, written as they are without the leading `@`.
48
+ #
49
+ # For anything that has to answer about the annotations rather than see past them. Splitting one on
50
+ # whitespace loses half of it, since `@export_range(0, 10)` has a space inside the parentheses and
51
+ # was reported as the hint `range(0,`.
52
+ # An `Array[String]` rather than a `PackedStringArray`, because the packed one's `append` answers
53
+ # with a bool and a project holding `return_value_discarded` at error will not compile a script that
54
+ # throws it away, which is the whole reason `compiled` exists a few lines up.
55
+ static func annotations_on(line: String) -> Array[String]:
56
+ var annotation: RegEx = compiled('^@([a-z_]+)(\\(\\s*(?:"[^"]*")?[^)]*\\))?\\s*')
57
+ var found: Array[String] = []
58
+ var rest: String = line.strip_edges()
59
+ var matched: RegExMatch = annotation.search(rest)
60
+ while matched != null:
61
+ found.append(matched.get_string(1) + matched.get_string(2))
62
+ rest = rest.substr(matched.get_end()).strip_edges()
63
+ matched = annotation.search(rest)
64
+ return found
65
+
66
+
67
+ # A line with its leading annotations taken off, for anything reading a script's header.
68
+ #
69
+ # Annotations may share a line with what they annotate: `@abstract class_name X` is one line in
70
+ # Godot 4.5 and later, and `@tool` and `@icon("res://x.svg")` sit there too. Four readers here
71
+ # matched on `class_name` at the start of a line and so saw none of those as a declaration at all,
72
+ # which cost a project with gdUnit4 in it 23 classes reported as declared nowhere, and put a
73
+ # `script_edit` insertion above the `class_name` of a script whose whole header is that one line,
74
+ # where it does not parse. The scanner that writes the class cache had its own copy of this and was
75
+ # right, which is why only the readings were wrong.
76
+ #
77
+ # One home, because five spellings of the same rule is five things to remember when the language
78
+ # adds the next annotation.
79
+ static func without_annotations(line: String) -> String:
80
+ var annotation: RegEx = compiled('^@[a-z_]+(?:\\(\\s*(?:"[^"]*")?[^)]*\\))?\\s*')
81
+ var rest: String = line.strip_edges()
82
+ var found: RegExMatch = annotation.search(rest)
83
+ while found != null:
84
+ rest = rest.substr(found.get_end()).strip_edges()
85
+ found = annotation.search(rest)
86
+ return rest
package/build/index.js CHANGED
@@ -10028,14 +10028,14 @@ var SERVER_VERSION = (() => {
10028
10028
  return "0.0.0";
10029
10029
  }
10030
10030
  })();
10031
- var UNVERSIONED = "addon from before versions were reported";
10031
+ var UNVERSIONED = "an addon from before versions were reported";
10032
10032
  function addonMismatch(addonVersion, serverVersion) {
10033
10033
  if (addonVersion === serverVersion) {
10034
10034
  return;
10035
10035
  }
10036
10036
  const reported = addonVersion ?? "";
10037
- const editor = reported === "" ? UNVERSIONED : reported;
10038
- const both = `The editor is running the ${editor} addon while this server ships ${serverVersion}.`;
10037
+ const editor = reported === "" ? UNVERSIONED : `the ${reported} addon`;
10038
+ const both = `The editor is running ${editor} while this server ships ${serverVersion}.`;
10039
10039
  if (reported !== "" && isNewer(reported, serverVersion)) {
10040
10040
  return `${both} This server is the older half: reconnect it in your harness so it spawns ${reported}.`;
10041
10041
  }
@@ -24781,6 +24781,7 @@ function withdrawBridge(path) {
24781
24781
  // src/class-cache.ts
24782
24782
  import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync4, statSync } from "node:fs";
24783
24783
  import { join as join3 } from "node:path";
24784
+ var DECLARATION = /^(?:@[a-z_]+(?:\([^)\n]*\))?\s+)*class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m;
24784
24785
  function declaredClasses(projectPath) {
24785
24786
  const declared = new Map;
24786
24787
  const visit = (directory, prefix) => {
@@ -24795,7 +24796,7 @@ function declaredClasses(projectPath) {
24795
24796
  if (entry.isDirectory()) {
24796
24797
  visit(path, `${prefix}${entry.name}/`);
24797
24798
  } else if (entry.isFile() && entry.name.endsWith(".gd")) {
24798
- const found = /^class_name\s+([A-Za-z_][A-Za-z0-9_]*)/m.exec(readFileSync4(path, "utf8"));
24799
+ const found = DECLARATION.exec(readFileSync4(path, "utf8"));
24799
24800
  if (found?.[1]) {
24800
24801
  declared.set(found[1], `res://${prefix}${entry.name}`);
24801
24802
  }
@@ -24916,7 +24917,7 @@ function heldButGone(projectPath, editorHolds) {
24916
24917
  }
24917
24918
  function unseenByEditor(projectPath, editorHolds) {
24918
24919
  const held = new Set(editorHolds);
24919
- return [...declaredClasses(projectPath)].filter(([name]) => !held.has(name)).map(([className, path]) => ({ className, path }));
24920
+ return [...declaredClasses(projectPath)].filter(([name]) => !held.has(name)).map(([className, path]) => ({ className, path })).sort((one, other) => one.className < other.className ? -1 : one.className > other.className ? 1 : 0);
24920
24921
  }
24921
24922
 
24922
24923
  // src/config-pin.ts
@@ -25756,6 +25757,9 @@ var CONNECT_WINDOW_MS = 35000;
25756
25757
  function mayYetConnect(listeningSince, now = Date.now()) {
25757
25758
  return listeningSince === undefined || now - listeningSince.getTime() < CONNECT_WINDOW_MS;
25758
25759
  }
25760
+ function theEditorHasComeBack(status, startedAt) {
25761
+ return status.connected && (status.connectedAt?.getTime() ?? 0) > startedAt && status.addonVersion !== undefined;
25762
+ }
25759
25763
  function anEditorIsStillComing(listeningSince, launchedEditorIsAlive, now = Date.now()) {
25760
25764
  return mayYetConnect(listeningSince, now) || launchedEditorIsAlive;
25761
25765
  }
@@ -28671,7 +28675,7 @@ var TOOL_SPECS = [
28671
28675
  direction: {
28672
28676
  type: "string",
28673
28677
  enum: ["forward", "reverse"],
28674
- description: "forward: what this resource loads, with cycles reported. reverse: every file that refers to it and how, a scene instancing it, a script extending, preloading or loading it, and for a script with a class_name every use of that name. Default forward."
28678
+ description: "forward: what this resource loads, with cycles reported. reverse: every file that refers to it and how, a scene instancing it, a script extending, preloading or loading it, and for a script with a class_name every use of that name. Each reference carries a kind, and two of them are mentions rather than uses: doc is a name inside a ## documentation comment, which renaming would break in the generated docs, and comment is one in ordinary prose, which it would only make stale. summary.in_code is the count with both of those left out, which is the number to read when the question is whether anything still uses this. Default forward."
28675
28679
  },
28676
28680
  depth: { type: "number", description: "forward: how many levels to follow. Default unlimited." },
28677
28681
  includeBuiltin: {
@@ -28941,7 +28945,7 @@ var TOOL_SPECS = [
28941
28945
  scriptPath: SCRIPT_PATH,
28942
28946
  includeInherited: {
28943
28947
  type: "boolean",
28944
- description: "structure: include inherited members. Default false."
28948
+ description: "structure: also list what the script inherits, walking extends through the project's other scripts. Each such member carries inherited_from, the file declaring it, and the answer gains inherits_from, the chain that was walked. A name the script overrides is listed at both its lines rather than once. A native base is not walked, since it declares nothing in a file: editor_classes info answers for those. Default false."
28945
28949
  },
28946
28950
  line: { type: "number", description: "completion, hover: zero-based line." },
28947
28951
  character: { type: "number", description: "completion, hover: zero-based column." }
@@ -29039,7 +29043,7 @@ var TOOL_SPECS = [
29039
29043
  runtimeWaitMs: {
29040
29044
  type: "number",
29041
29045
  ops: ["start"],
29042
- description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. Worth raising for a project that takes longer than that to reach its first frame, which is what leaves runtime listening false on a game whose runtime arrives a moment later. 0 does not wait at all, which is the one to pass for a scene that announces nothing by construction, such as a bench that prints and quits."
29046
+ description: "start: how long to wait for the game to announce its runtime before answering. Default 5000. What it waits for is the first frame, so everything the game does before drawing one is inside it, including work the args just asked for: a flag that simulates six years of game time before anything is drawn makes the announcement that late, on a project that announces promptly without it. Raise it for such a run rather than reading runtime listening false as a fault. 0 does not wait at all, which is the one to pass for a scene that announces nothing by construction, such as a bench that prints and quits."
29043
29047
  },
29044
29048
  frames: {
29045
29049
  type: "number",
@@ -29560,7 +29564,7 @@ function runtimeVerdict(endpoint, after) {
29560
29564
  return {
29561
29565
  listening: false,
29562
29566
  mayYetAnnounce: after.running,
29563
- note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
29567
+ note: after.running ? `nothing announced itself within ${after.budgetMs}ms and the game is still running, so it may announce a moment from now: editor_status says whether it has, and editor_run start takes runtimeWaitMs to wait longer than this${after.withArgs ? ", which a run carrying its own arguments may well need, since whatever they ask the game to do before its first frame is inside this wait" : ""}` : `nothing announced itself within ${after.budgetMs}ms and the game is no longer running, so nothing is going to: editor_output has what it printed on the way down`,
29564
29568
  heldAt: null
29565
29569
  };
29566
29570
  }
@@ -31096,10 +31100,7 @@ class GodotServer {
31096
31100
  }
31097
31101
  const startedAt = before.connectedAt?.getTime() ?? 0;
31098
31102
  const began = Date.now();
31099
- const back = await this.waitForBridge(() => {
31100
- const status = this.godotBridge.getStatus();
31101
- return status.connected && (status.connectedAt?.getTime() ?? 0) > startedAt;
31102
- }, began + EDITOR_RESTART_TIMEOUT_MS);
31103
+ const back = await this.waitForBridge(() => theEditorHasComeBack(this.godotBridge.getStatus(), startedAt), began + EDITOR_RESTART_TIMEOUT_MS);
31103
31104
  if (!back) {
31104
31105
  return this.createErrorResponse(`The editor was asked to restart and has not come back within ${EDITOR_RESTART_TIMEOUT_MS / 1000}s.`, [
31105
31106
  "It may be asking what to do about an unsaved scene: look at the editor window",
@@ -31294,7 +31295,7 @@ class GodotServer {
31294
31295
  transcript: started.transcript,
31295
31296
  refreshedClasses: refreshed.value,
31296
31297
  endedPreviousRun: ended ?? undefined,
31297
- runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs),
31298
+ runtime: await this.runtimeUp(project.value.path, alreadyPlaying, runtimeWaitMs, given.value.length > 0),
31298
31299
  message: `Use editor_output for what it prints and editor_run stop to end it.${spawnedInstead}${endedForThis}`
31299
31300
  });
31300
31301
  }
@@ -31303,9 +31304,9 @@ class GodotServer {
31303
31304
  const editorSays = run?.throughEditor === true ? (await this.editorPlayingState())?.playing ?? null : null;
31304
31305
  return runIsUp(run, editorSays);
31305
31306
  }
31306
- async runtimeUp(projectPath, before, budgetMs) {
31307
+ async runtimeUp(projectPath, before, budgetMs, withArgs) {
31307
31308
  if (!existsSync9(join11(projectPath, RUNTIME_AUTOLOAD.path))) {
31308
- return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false });
31309
+ return runtimeVerdict(null, { addon: false, budgetMs, heldAt: null, running: false, withArgs });
31309
31310
  }
31310
31311
  const endpoint = await announcedSince(projectPath, before, {
31311
31312
  budgetMs,
@@ -31315,7 +31316,8 @@ class GodotServer {
31315
31316
  addon: true,
31316
31317
  budgetMs,
31317
31318
  heldAt: this.dapClient?.whereItStopped() ?? null,
31318
- running: await this.gameIsUp()
31319
+ running: await this.gameIsUp(),
31320
+ withArgs
31319
31321
  });
31320
31322
  }
31321
31323
  async playThroughEditor(scene, refreshedClasses, projectPath, alreadyPlaying, runtimeWaitMs, ended) {
@@ -31366,7 +31368,7 @@ class GodotServer {
31366
31368
  refreshedClasses,
31367
31369
  debugPort: readNumber(asParams(JSON.parse(answer.content[0]?.text ?? "{}")), "debugPort"),
31368
31370
  endedPreviousRun: ended ?? undefined,
31369
- runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs),
31371
+ runtime: await this.runtimeUp(projectPath, alreadyPlaying, runtimeWaitMs, false),
31370
31372
  message: "The editor is playing it, so its debugger holds it: the debug_* tools can reach it, " + "editor_output reads its console through the debug adapter, and editor_run stop ends it." + (ended === null ? "" : ` The run that was going, pid ${ended}, was ended to start this one; its output is no longer what editor_output answers about.`)
31371
31373
  });
31372
31374
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "0.13.30",
3
+ "version": "0.13.32",
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",