gdharness 1.3.6 → 1.3.8
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 +1602 -1374
- package/build/godot/addons/gdharness_editor/reading.gd +1 -1
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +12 -10
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +11 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +1 -1
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +45 -1
- package/build/godot/addons/gdharness_runtime/runtime_capture.gd +19 -4
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +3 -1
- package/build/godot/addons/gdharness_runtime/runtime_hover.gd +1 -1
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +47 -23
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -2
- package/build/godot/operations/dependencies.gd +61 -10
- package/build/godot/operations/gdscript_analysis.gd +62 -6
- package/build/godot/operations/gdscript_authoring.gd +4 -1
- package/build/godot/operations/godot_operations.gd +2 -0
- package/build/godot/operations/import_pipeline.gd +45 -1
- package/build/godot/operations/project_diagnostics.gd +6 -5
- package/build/godot/operations/reading.gd +1 -1
- package/build/godot/tests/listed.gd +26 -4
- package/build/keeper.js +26 -3
- package/package.json +1 -1
|
@@ -23,6 +23,8 @@ class GdhMembers:
|
|
|
23
23
|
var constants: Array[Dictionary] = []
|
|
24
24
|
var enums: Array[Dictionary] = []
|
|
25
25
|
var inner_classes: Array[Dictionary] = []
|
|
26
|
+
## The `##` comment the class is documented with, empty when it has none.
|
|
27
|
+
var doc: String = ""
|
|
26
28
|
## The index of the last line the body holds.
|
|
27
29
|
var last: int = -1
|
|
28
30
|
## An inner class's name and the line its `class` is on.
|
|
@@ -43,6 +45,22 @@ class GdhMembers:
|
|
|
43
45
|
"inner_classes": inner_classes,
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
## Whether a member is declared yet, past which a `##` block no longer documents the class.
|
|
49
|
+
func declares_members() -> bool:
|
|
50
|
+
return not (
|
|
51
|
+
signals.is_empty()
|
|
52
|
+
and variables.is_empty()
|
|
53
|
+
and functions.is_empty()
|
|
54
|
+
and constants.is_empty()
|
|
55
|
+
and enums.is_empty()
|
|
56
|
+
and inner_classes.is_empty()
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
## [param lines] as the class's documentation, when it has none yet.
|
|
60
|
+
func take_doc(lines: Array[String]) -> void:
|
|
61
|
+
if doc.is_empty() and not lines.is_empty():
|
|
62
|
+
doc = "\n".join(PackedStringArray(lines))
|
|
63
|
+
|
|
46
64
|
|
|
47
65
|
func _init(p_log: GdhLog) -> void:
|
|
48
66
|
_log = p_log
|
|
@@ -130,6 +148,8 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
|
|
|
130
148
|
"autoloads_used": autoloads_used,
|
|
131
149
|
"line_count": lines.size() - 1 if content.is_empty() or content.ends_with("\n") else lines.size()
|
|
132
150
|
}
|
|
151
|
+
if not own.doc.is_empty():
|
|
152
|
+
answer["class_doc"] = own.doc
|
|
133
153
|
if GdhRead.as_bool(params.get("include_inherited", false)):
|
|
134
154
|
_add_inherited(answer, [full_script_path])
|
|
135
155
|
return answer
|
|
@@ -143,6 +163,7 @@ func _members(
|
|
|
143
163
|
) -> GdhMembers:
|
|
144
164
|
var own: GdhMembers = GdhMembers.new()
|
|
145
165
|
var in_multiline_string: bool = false
|
|
166
|
+
var doc: Array[String] = []
|
|
146
167
|
|
|
147
168
|
var i: int = first - 1
|
|
148
169
|
while i + 1 < lines.size():
|
|
@@ -158,9 +179,25 @@ func _members(
|
|
|
158
179
|
if _triple_quotes(raw) % 2 == 1:
|
|
159
180
|
in_multiline_string = false
|
|
160
181
|
continue
|
|
182
|
+
# A `##` block documents what it sits directly above: the class when that is its class_name
|
|
183
|
+
# or extends line, or when a blank line follows it before any member, and otherwise the next
|
|
184
|
+
# member. One at a shallower indent is the outer body's, for what follows this inner class.
|
|
185
|
+
var trimmed: String = raw.strip_edges()
|
|
186
|
+
if trimmed.begins_with("##"):
|
|
187
|
+
if not raw.begins_with(margin):
|
|
188
|
+
own.last = i - 1
|
|
189
|
+
return own
|
|
190
|
+
if _indentation(raw).length() <= margin.length():
|
|
191
|
+
var said: String = trimmed.substr(2)
|
|
192
|
+
doc.append(said.substr(1) if said.begins_with(" ") else said)
|
|
193
|
+
continue
|
|
161
194
|
var stripped: String = GdhPatterns.without_comment(raw)
|
|
162
195
|
|
|
163
196
|
if stripped.is_empty():
|
|
197
|
+
if trimmed.is_empty() and not doc.is_empty():
|
|
198
|
+
if not own.declares_members():
|
|
199
|
+
own.take_doc(doc)
|
|
200
|
+
doc.clear()
|
|
164
201
|
continue
|
|
165
202
|
|
|
166
203
|
# A line back at a shallower indent closes an inner class's body, and is the outer body's
|
|
@@ -210,6 +247,7 @@ func _members(
|
|
|
210
247
|
stripped = (stripped + " " + GdhPatterns.without_comment(lines[i])).strip_edges()
|
|
211
248
|
header = GdhPatterns.without_annotations(stripped)
|
|
212
249
|
var declaration: RegExMatch = GdhPatterns.declared_class(header)
|
|
250
|
+
var entry: Dictionary = {}
|
|
213
251
|
if declaration != null:
|
|
214
252
|
own.declared_class_name = declaration.get_string(1)
|
|
215
253
|
# `class_name X extends Y` is also one line, and taking the rest of it as the name
|
|
@@ -217,29 +255,47 @@ func _members(
|
|
|
217
255
|
# `RefCounted`: the name unusable and the base flatly wrong.
|
|
218
256
|
if not declaration.get_string(2).is_empty():
|
|
219
257
|
own.extends_name = declaration.get_string(2)
|
|
258
|
+
own.take_doc(doc)
|
|
220
259
|
elif header.begins_with("extends "):
|
|
221
260
|
own.extends_name = header.substr(8).strip_edges()
|
|
261
|
+
own.take_doc(doc)
|
|
222
262
|
elif header.begins_with("signal "):
|
|
223
263
|
# `at`, not `i`: a wrapped declaration starts where it starts, and `i` is now sitting on
|
|
224
264
|
# the line that closed it.
|
|
225
|
-
|
|
265
|
+
entry = _parse_signal(header, at + 1)
|
|
266
|
+
own.signals.append(entry)
|
|
226
267
|
elif header.begins_with("const "):
|
|
227
|
-
|
|
268
|
+
entry = _parse_constant(header, at + 1)
|
|
269
|
+
own.constants.append(entry)
|
|
228
270
|
elif header.begins_with("enum "):
|
|
229
|
-
|
|
271
|
+
entry = _parse_enum(header, at + 1)
|
|
272
|
+
own.enums.append(entry)
|
|
230
273
|
elif header.begins_with("var ") or header.begins_with("static var "):
|
|
231
274
|
# The whole line here, not the header: which annotations a variable carries is the
|
|
232
275
|
# answer rather than noise in front of it, and `@export_range(0, 1)` is the hint.
|
|
233
|
-
|
|
276
|
+
entry = _parse_variable(stripped, at + 1)
|
|
277
|
+
own.variables.append(entry)
|
|
234
278
|
elif header.begins_with("func ") or header.begins_with("static func "):
|
|
235
|
-
|
|
279
|
+
entry = _parse_function(header, at + 1, stripped)
|
|
280
|
+
own.functions.append(entry)
|
|
236
281
|
elif header.begins_with("class "):
|
|
237
282
|
_collect_dependencies(stripped, dependencies)
|
|
238
283
|
var inner: GdhMembers = _inner_class(lines, i, margin, header, dependencies)
|
|
239
|
-
|
|
284
|
+
entry = inner.as_inner()
|
|
285
|
+
if not doc.is_empty():
|
|
286
|
+
entry["doc"] = "\n".join(PackedStringArray(doc))
|
|
287
|
+
doc.clear()
|
|
288
|
+
own.inner_classes.append(entry)
|
|
240
289
|
i = inner.last
|
|
241
290
|
continue
|
|
242
291
|
|
|
292
|
+
if not entry.is_empty() and not doc.is_empty():
|
|
293
|
+
entry["doc"] = "\n".join(PackedStringArray(doc))
|
|
294
|
+
# A line of annotations alone, `@tool` or `@export` above its variable, is between a comment
|
|
295
|
+
# and what it documents rather than after it.
|
|
296
|
+
if not header.is_empty():
|
|
297
|
+
doc.clear()
|
|
298
|
+
|
|
243
299
|
_collect_dependencies(stripped, dependencies)
|
|
244
300
|
|
|
245
301
|
own.last = lines.size() - 1
|
|
@@ -196,7 +196,10 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
|
|
|
196
196
|
if not problems.is_empty():
|
|
197
197
|
var refusal: String = "Nothing was changed: " + "; ".join(problems)
|
|
198
198
|
if _redeclares(modifications, declared):
|
|
199
|
-
refusal +=
|
|
199
|
+
refusal += (
|
|
200
|
+
". modify only adds members; to change one the script declares already,"
|
|
201
|
+
+ " edit its lines in the file"
|
|
202
|
+
)
|
|
200
203
|
return _log.failure(refusal)
|
|
201
204
|
# Each placed line as a zero-based index that moves down whenever a later insertion lands above
|
|
202
205
|
# it, so the numbers answered are where the lines are in the file written, not where each went
|
|
@@ -237,6 +237,8 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
|
|
|
237
237
|
payload = GdhDependencies.new(_log).get_dependencies(params)
|
|
238
238
|
"find_resource_usages":
|
|
239
239
|
payload = GdhDependencies.new(_log).find_resource_usages(params)
|
|
240
|
+
"list_script_classes":
|
|
241
|
+
payload = GdhDependencies.new(_log).list_script_classes(params)
|
|
240
242
|
"get_project_health":
|
|
241
243
|
payload = GdhProjectDiagnostics.new(_log).get_project_health(params)
|
|
242
244
|
|
|
@@ -227,6 +227,7 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
227
227
|
# One already holding the value given changes nothing, and listing it as updated said it had.
|
|
228
228
|
var updated_keys: Array[String] = []
|
|
229
229
|
var unchanged_keys: Array[String] = []
|
|
230
|
+
var changes: Dictionary = {}
|
|
230
231
|
for name: String in fitted_values:
|
|
231
232
|
var held: Variant = config.get_value("params", name, null)
|
|
232
233
|
var value: Variant = fitted_values[name]
|
|
@@ -235,6 +236,10 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
235
236
|
continue
|
|
236
237
|
config.set_value("params", name, value)
|
|
237
238
|
updated_keys.append(name)
|
|
239
|
+
changes[name] = {
|
|
240
|
+
"was": null if held == null else _values.serialize_value(held),
|
|
241
|
+
"now": _values.serialize_value(value),
|
|
242
|
+
}
|
|
238
243
|
_log.debug("Set " + name + " = " + str(value))
|
|
239
244
|
|
|
240
245
|
if not updated_keys.is_empty():
|
|
@@ -244,6 +249,8 @@ func set_import_options(params: Dictionary) -> Dictionary:
|
|
|
244
249
|
|
|
245
250
|
# The reimport that applies them is the server's, through the editor or the engine's import.
|
|
246
251
|
var answer: Dictionary = {"resource_path": resource_path, "updated_options": updated_keys}
|
|
252
|
+
if not changes.is_empty():
|
|
253
|
+
answer["changed"] = changes
|
|
247
254
|
if not unchanged_keys.is_empty():
|
|
248
255
|
answer["unchanged_options"] = unchanged_keys
|
|
249
256
|
return answer
|
|
@@ -387,6 +394,25 @@ func validate_project(params: Dictionary) -> Dictionary:
|
|
|
387
394
|
)
|
|
388
395
|
)
|
|
389
396
|
|
|
397
|
+
# A file never imported is on disk, so the reference checks pass it, and still nothing loads it:
|
|
398
|
+
# a scene using one fails to load in a run or an export.
|
|
399
|
+
checks_performed.append("imports")
|
|
400
|
+
var never: Array[String] = []
|
|
401
|
+
for path: String in _files.find_files_with_extensions("res://", IMPORTABLE_EXTENSIONS):
|
|
402
|
+
if not FileAccess.file_exists(path + ".import"):
|
|
403
|
+
never.append(path)
|
|
404
|
+
if not never.is_empty():
|
|
405
|
+
never.sort()
|
|
406
|
+
warnings.append(
|
|
407
|
+
_finding(
|
|
408
|
+
"warning",
|
|
409
|
+
"imports",
|
|
410
|
+
never_imported_line(never),
|
|
411
|
+
"Run project_import reimport, or open the project in the editor, whose scan imports them",
|
|
412
|
+
include_suggestions
|
|
413
|
+
)
|
|
414
|
+
)
|
|
415
|
+
|
|
390
416
|
checks_performed.append("export_presets")
|
|
391
417
|
if not FileAccess.file_exists("res://export_presets.cfg"):
|
|
392
418
|
warnings.append(
|
|
@@ -602,7 +628,25 @@ func _no_sidecar(resource_path: String) -> String:
|
|
|
602
628
|
return resource_path + " has no import options: " + never
|
|
603
629
|
return (
|
|
604
630
|
resource_path
|
|
605
|
-
+ " has not been imported yet, so it has no import file: project_import reimport imports
|
|
631
|
+
+ " has not been imported yet, so it has no import file: project_import reimport imports"
|
|
632
|
+
+ " it with the importer's defaults and writes that file"
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
## [param paths], files the engine imports and never has, as health and validation say it: at most
|
|
637
|
+
## five named, since a project never opened in an editor has every one of its assets here.
|
|
638
|
+
static func never_imported_line(paths: Array[String]) -> String:
|
|
639
|
+
var named: String = ", ".join(paths.slice(0, 5))
|
|
640
|
+
if paths.size() > 5:
|
|
641
|
+
named += " and %d more" % (paths.size() - 5)
|
|
642
|
+
if paths.size() == 1:
|
|
643
|
+
return (
|
|
644
|
+
named
|
|
645
|
+
+ " has never been imported, so nothing can load it until project_import reimport imports it"
|
|
646
|
+
)
|
|
647
|
+
return (
|
|
648
|
+
"%d files have never been imported, so nothing can load them until project_import reimport imports them: %s"
|
|
649
|
+
% [paths.size(), named]
|
|
606
650
|
)
|
|
607
651
|
|
|
608
652
|
|
|
@@ -50,14 +50,15 @@ func get_project_health(params: Dictionary) -> Dictionary:
|
|
|
50
50
|
"res://", GdhImportPipeline.IMPORTABLE_EXTENSIONS
|
|
51
51
|
)
|
|
52
52
|
|
|
53
|
-
var missing_imports:
|
|
53
|
+
var missing_imports: Array[String] = []
|
|
54
54
|
for res: String in resources:
|
|
55
55
|
if not FileAccess.file_exists(res + ".import"):
|
|
56
|
-
missing_imports
|
|
56
|
+
missing_imports.append(res)
|
|
57
57
|
|
|
58
|
-
if missing_imports
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
if not missing_imports.is_empty():
|
|
59
|
+
missing_imports.sort()
|
|
60
|
+
details.append(GdhImportPipeline.never_imported_line(missing_imports))
|
|
61
|
+
deductions += missing_imports.size()
|
|
61
62
|
|
|
62
63
|
checks["resources"] = {
|
|
63
64
|
"name": "Resources",
|
|
@@ -152,7 +152,7 @@ static func not_of_kind(
|
|
|
152
152
|
if not found.is_empty():
|
|
153
153
|
told += ' The node at "%s" is %s, not %s.' % [node_path, with_article(found), with_article(kind)]
|
|
154
154
|
if holders.is_empty():
|
|
155
|
-
return told + " The scene holds no %s." % kind
|
|
155
|
+
return told + " The scene holds no %s; scene_node add with nodeType %s puts one in it." % [kind, kind]
|
|
156
156
|
var quoted: PackedStringArray = []
|
|
157
157
|
for holder: String in holders.slice(0, NAMED_CHILDREN):
|
|
158
158
|
var _quoted: bool = quoted.append('"%s"' % holder)
|
|
@@ -218,12 +218,14 @@ static func argument_after(args: PackedStringArray, flag: String) -> String:
|
|
|
218
218
|
|
|
219
219
|
|
|
220
220
|
## The errors each of [param suites] declares it expects, by suite path and then case name, read off
|
|
221
|
-
## the compiled suite's constants so a declaration is whatever the engine made of it. A
|
|
222
|
-
##
|
|
221
|
+
## the compiled suite's constants so a declaration is whatever the engine made of it. A directory
|
|
222
|
+
## listed is walked for its scripts, as gdUnit4 walks it for suites. Only a script whose source names
|
|
223
|
+
## the constant is loaded: gdUnit4 stops on a suite that does not load only when its own load is the
|
|
224
|
+
## one that fails, so a broken suite loaded here first let the rest of its directory run.
|
|
223
225
|
static func expected_by_suite(suites: PackedStringArray) -> Dictionary:
|
|
224
226
|
var expected: Dictionary = {}
|
|
225
|
-
for path: String in suites:
|
|
226
|
-
if not
|
|
227
|
+
for path: String in scripts_in(suites):
|
|
228
|
+
if not FileAccess.file_exists(path) or not FileAccess.get_file_as_string(path).contains(EXPECTED):
|
|
227
229
|
continue
|
|
228
230
|
var suite: Script = load(path)
|
|
229
231
|
var declared: Variant = suite.get_script_constant_map().get(EXPECTED)
|
|
@@ -232,6 +234,26 @@ static func expected_by_suite(suites: PackedStringArray) -> Dictionary:
|
|
|
232
234
|
return expected
|
|
233
235
|
|
|
234
236
|
|
|
237
|
+
## The scripts [param paths] names: each script, and every script under each directory.
|
|
238
|
+
static func scripts_in(paths: PackedStringArray) -> PackedStringArray:
|
|
239
|
+
var found: PackedStringArray = []
|
|
240
|
+
var pending: PackedStringArray = []
|
|
241
|
+
for path: String in paths:
|
|
242
|
+
if path.ends_with(".gd"):
|
|
243
|
+
var _script: bool = found.append(path)
|
|
244
|
+
else:
|
|
245
|
+
var _directory: bool = pending.append(path.trim_suffix("/"))
|
|
246
|
+
while not pending.is_empty():
|
|
247
|
+
var directory: String = pending[pending.size() - 1]
|
|
248
|
+
pending.remove_at(pending.size() - 1)
|
|
249
|
+
for file: String in DirAccess.get_files_at(directory):
|
|
250
|
+
if file.ends_with(".gd"):
|
|
251
|
+
var _file: bool = found.append("%s/%s" % [directory, file])
|
|
252
|
+
for inner: String in DirAccess.get_directories_at(directory):
|
|
253
|
+
var _inner: bool = pending.append("%s/%s" % [directory, inner])
|
|
254
|
+
return found
|
|
255
|
+
|
|
256
|
+
|
|
235
257
|
static func write_expected(path: String, suites: PackedStringArray) -> void:
|
|
236
258
|
var file: FileAccess = FileAccess.open(path, FileAccess.WRITE)
|
|
237
259
|
if file == null:
|
package/build/keeper.js
CHANGED
|
@@ -703,6 +703,8 @@ function readMessage(body) {
|
|
|
703
703
|
// src/game-debugger.ts
|
|
704
704
|
var PAUSE_MS = 30000;
|
|
705
705
|
var STEP_MS = 1e4;
|
|
706
|
+
var ADDON_STEP_MS = 5000;
|
|
707
|
+
var RUNTIME_ADDON = "res://addons/gdharness_runtime/";
|
|
706
708
|
var READ_MS = 5000;
|
|
707
709
|
var REPLIES = new Set([
|
|
708
710
|
"debug_enter",
|
|
@@ -1085,9 +1087,29 @@ class GameDebugger {
|
|
|
1085
1087
|
};
|
|
1086
1088
|
}
|
|
1087
1089
|
}
|
|
1090
|
+
let frames = await this.stack();
|
|
1091
|
+
const stepping = Date.now();
|
|
1092
|
+
while (frames !== null && this.held !== null && (frames[0]?.script.startsWith(RUNTIME_ADDON) ?? false) && Date.now() - stepping < ADDON_STEP_MS) {
|
|
1093
|
+
this.forget("debug_enter");
|
|
1094
|
+
this.send("step", [], this.held.thread);
|
|
1095
|
+
if (await this.next(GameDebugger.named("debug_enter"), STEP_MS) === null) {
|
|
1096
|
+
return {
|
|
1097
|
+
paused: false,
|
|
1098
|
+
note: `The game stopped in gdharness's runtime addon and was stepped on to reach its own script, and did not stop again within ${STEP_MS / 1000}s. It stops at the next line of script it runs; debug_state stack reads it then.`
|
|
1099
|
+
};
|
|
1100
|
+
}
|
|
1101
|
+
frames = await this.stack();
|
|
1102
|
+
}
|
|
1088
1103
|
this.stoppedBy("Paused");
|
|
1089
|
-
const
|
|
1090
|
-
return {
|
|
1104
|
+
const stack = frames ?? [];
|
|
1105
|
+
return {
|
|
1106
|
+
paused: true,
|
|
1107
|
+
held: this.heldState(),
|
|
1108
|
+
stack,
|
|
1109
|
+
...stack[0]?.script.startsWith(RUNTIME_ADDON) === true ? {
|
|
1110
|
+
note: `The game is held in gdharness's runtime addon, whose _process runs first in every frame: ${ADDON_STEP_MS / 1000}s of stepping reached no line of the game's own script, so it runs none from frame to frame. A breakpoint in the game's script stops it there.`
|
|
1111
|
+
} : {}
|
|
1112
|
+
};
|
|
1091
1113
|
}
|
|
1092
1114
|
stoppedBy(cause) {
|
|
1093
1115
|
if (this.held !== null) {
|
|
@@ -1108,7 +1130,7 @@ class GameDebugger {
|
|
|
1108
1130
|
return await exited === null ? { refused: `The game did not say it had gone on within ${READ_MS / 1000}s.` } : { continued: true };
|
|
1109
1131
|
}
|
|
1110
1132
|
this.forget("debug_enter");
|
|
1111
|
-
this.send(how, [], held.thread);
|
|
1133
|
+
this.send(held.hasStack ? how : "step", [], held.thread);
|
|
1112
1134
|
const entered = await this.next(GameDebugger.named("debug_enter"), STEP_MS);
|
|
1113
1135
|
if (entered === null) {
|
|
1114
1136
|
return {
|
|
@@ -1646,6 +1668,7 @@ function emptyRecord() {
|
|
|
1646
1668
|
// src/project-scan.ts
|
|
1647
1669
|
var SKIPPED = new Set(["node_modules"]);
|
|
1648
1670
|
var SCENE_EXTENSIONS = new Set(["tscn", "scn"]);
|
|
1671
|
+
var SIDECAR_EXTENSIONS = new Set(["import", "uid"]);
|
|
1649
1672
|
var ASSET_EXTENSIONS = new Set([
|
|
1650
1673
|
"png",
|
|
1651
1674
|
"jpg",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gdharness",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.8",
|
|
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",
|