gdharness 1.3.7 → 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 +869 -805
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +3 -1
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +47 -23
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +27 -1
- 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/keeper.js +1 -0
- package/package.json +1 -1
|
@@ -524,7 +524,9 @@ static func _not_a_method_to_call(holder: Object, named: String, called: String)
|
|
|
524
524
|
% [called, named]
|
|
525
525
|
)
|
|
526
526
|
method = GdhPaths.method_of(named)
|
|
527
|
-
|
|
527
|
+
if holder.has_method(method):
|
|
528
|
+
return ""
|
|
529
|
+
return GdhPaths.with_methods_offered("%s has no method %s" % [called, method], holder, method)
|
|
528
530
|
|
|
529
531
|
|
|
530
532
|
## Whether a call's `properties` asks for its result to be read: true, or a list of names.
|
|
@@ -157,6 +157,7 @@ func inject_action(params: Dictionary) -> Dictionary:
|
|
|
157
157
|
return {"type": "error", "message": holding}
|
|
158
158
|
var frames: int = holding
|
|
159
159
|
|
|
160
|
+
var focused: String = _focus_owner()
|
|
160
161
|
_say_action(action, whole or held, strength)
|
|
161
162
|
var lasted: Variant = null
|
|
162
163
|
if whole:
|
|
@@ -166,15 +167,34 @@ func inject_action(params: Dictionary) -> Dictionary:
|
|
|
166
167
|
_say_action(action, false, strength)
|
|
167
168
|
await _host.get_tree().process_frame
|
|
168
169
|
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
return _focus_noted(
|
|
171
|
+
{
|
|
172
|
+
"type": "input_injected",
|
|
173
|
+
"input_type": "action",
|
|
174
|
+
"action": action,
|
|
175
|
+
"pressed": held and not whole,
|
|
176
|
+
"whole": whole,
|
|
177
|
+
"held_frames": GdhValues.held_answer(whole, frames),
|
|
178
|
+
"held_for": lasted,
|
|
179
|
+
},
|
|
180
|
+
focused
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
## The path of the Control holding the keyboard focus, or "" when none does. Read before a key or an
|
|
185
|
+
## action goes in, since pressing a focused button can move the focus.
|
|
186
|
+
func _focus_owner() -> String:
|
|
187
|
+
var holding: Control = _host.get_viewport().gui_get_focus_owner()
|
|
188
|
+
return "" if holding == null else str(holding.get_path())
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
## [param said] with [param focused], the Control that had the focus, under focused. A key or an action
|
|
192
|
+
## reaches that Control before the game's _unhandled_input, so Space on a focused Button pressed it
|
|
193
|
+
## and the game's own handler counted the key as well, with nothing in the answer saying why.
|
|
194
|
+
static func _focus_noted(said: Dictionary, focused: String) -> Dictionary:
|
|
195
|
+
if not focused.is_empty():
|
|
196
|
+
said["focused"] = focused
|
|
197
|
+
return said
|
|
178
198
|
|
|
179
199
|
|
|
180
200
|
func _say_action(action: String, down: bool, strength: float) -> void:
|
|
@@ -230,6 +250,7 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
230
250
|
# keys are the code points themselves, which is what makes this a mapping and not a table.
|
|
231
251
|
event.unicode = GdhValues.glyph_of(event.keycode, event.shift_pressed)
|
|
232
252
|
|
|
253
|
+
var focused: String = _focus_owner()
|
|
233
254
|
Input.parse_input_event(event)
|
|
234
255
|
var lasted: Variant = null
|
|
235
256
|
if whole:
|
|
@@ -239,20 +260,23 @@ func inject_key(params: Dictionary) -> Dictionary:
|
|
|
239
260
|
Input.parse_input_event(up)
|
|
240
261
|
await _host.get_tree().process_frame
|
|
241
262
|
|
|
242
|
-
return
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
263
|
+
return _focus_noted(
|
|
264
|
+
{
|
|
265
|
+
"type": "input_injected",
|
|
266
|
+
"input_type": "key",
|
|
267
|
+
"held_frames": GdhValues.held_answer(whole, frames),
|
|
268
|
+
"held_for": lasted,
|
|
269
|
+
"keycode": event.keycode,
|
|
270
|
+
"physical_keycode": event.physical_keycode,
|
|
271
|
+
"shift": event.shift_pressed,
|
|
272
|
+
"ctrl": event.ctrl_pressed,
|
|
273
|
+
"alt": event.alt_pressed,
|
|
274
|
+
"unicode": event.unicode,
|
|
275
|
+
"pressed": held and not whole,
|
|
276
|
+
"whole": whole,
|
|
277
|
+
},
|
|
278
|
+
focused
|
|
279
|
+
)
|
|
256
280
|
|
|
257
281
|
|
|
258
282
|
## Clicks a mouse button at a position, and lets go of it again unless asked to hold it.
|
|
@@ -416,7 +416,7 @@ static func _arity(object: Object, method: String) -> Dictionary:
|
|
|
416
416
|
static func call_refused(object: Object, method: String, called_as: String, given: int = 0) -> String:
|
|
417
417
|
var arity: Dictionary = _arity(object, method)
|
|
418
418
|
if arity.is_empty():
|
|
419
|
-
return "%s has no method %s" % [called_as, method]
|
|
419
|
+
return with_methods_offered("%s has no method %s" % [called_as, method], object, method)
|
|
420
420
|
var least: int = arity["least"]
|
|
421
421
|
var most: int = arity["most"]
|
|
422
422
|
var any_more: bool = arity["any_more"]
|
|
@@ -454,6 +454,32 @@ static func call_refused(object: Object, method: String, called_as: String, give
|
|
|
454
454
|
return ""
|
|
455
455
|
|
|
456
456
|
|
|
457
|
+
## [param said], a refusal for [param method], which [param object] lacks, with the methods it has
|
|
458
|
+
## nearest the name, or else those its script declares. The engine's own run to hundreds on a node,
|
|
459
|
+
## so they are offered only when one is near.
|
|
460
|
+
static func with_methods_offered(said: String, object: Object, method: String) -> String:
|
|
461
|
+
var own: Array[String] = []
|
|
462
|
+
var attached: Variant = object.get_script()
|
|
463
|
+
if attached is Script:
|
|
464
|
+
var script: Script = attached
|
|
465
|
+
for entry: Dictionary in script.get_script_method_list():
|
|
466
|
+
var name: String = str(entry.get("name", ""))
|
|
467
|
+
if not name.is_empty() and not own.has(name):
|
|
468
|
+
own.append(name)
|
|
469
|
+
var every: Array[String] = own.duplicate()
|
|
470
|
+
for entry: Dictionary in object.get_method_list():
|
|
471
|
+
var name: String = str(entry.get("name", ""))
|
|
472
|
+
if not every.has(name):
|
|
473
|
+
every.append(name)
|
|
474
|
+
var close: PackedStringArray = GdhRead.nearest(method, PackedStringArray(every))
|
|
475
|
+
if not close.is_empty():
|
|
476
|
+
return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
|
|
477
|
+
if own.is_empty():
|
|
478
|
+
return said
|
|
479
|
+
own.sort()
|
|
480
|
+
return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(own))]
|
|
481
|
+
|
|
482
|
+
|
|
457
483
|
## The entry a script attached to [param object] declares for [param method], or an empty one when
|
|
458
484
|
## the method is the engine's.
|
|
459
485
|
static func _script_method(object: Object, method: String) -> Dictionary:
|
|
@@ -168,6 +168,16 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
168
168
|
var by_class: RegEx = null
|
|
169
169
|
if not class_name_declared.is_empty():
|
|
170
170
|
by_class = GdhPatterns.compiled("\\b" + _regex_escaped(class_name_declared) + "\\b")
|
|
171
|
+
# The script of an autoload is used wherever code names the autoload, and asked what uses it,
|
|
172
|
+
# the answer was its line in project.godot alone, beside the scripts calling it.
|
|
173
|
+
var autoload_named: String = ""
|
|
174
|
+
var autoloads: Dictionary = autoloads_declared()
|
|
175
|
+
for name: String in autoloads:
|
|
176
|
+
if str(autoloads[name]) == resource_path:
|
|
177
|
+
autoload_named = name
|
|
178
|
+
var by_autoload: RegEx = null
|
|
179
|
+
if not autoload_named.is_empty():
|
|
180
|
+
by_autoload = GdhPatterns.compiled("\\b" + _regex_escaped(autoload_named) + "\\b")
|
|
171
181
|
var target: Dictionary = {
|
|
172
182
|
"path": resource_path,
|
|
173
183
|
"uid": str(GdhResourceFiles.uid_of(resource_path).get("uid", "")) if exists else ""
|
|
@@ -203,7 +213,12 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
203
213
|
# Strings are text, so a class named in one is not a use of it.
|
|
204
214
|
by_its_name = by_class.search(_code_of(line)) != null
|
|
205
215
|
name_in_comment = not by_its_name and by_class.search(_comment_of(line)) != null
|
|
206
|
-
|
|
216
|
+
var by_its_autoload: bool = false
|
|
217
|
+
if not by_its_path and not by_its_name and code and by_autoload != null:
|
|
218
|
+
by_its_autoload = by_autoload.search(_code_of(line)) != null
|
|
219
|
+
if not by_its_autoload and not name_in_comment:
|
|
220
|
+
name_in_comment = by_autoload.search(_comment_of(line)) != null
|
|
221
|
+
if not by_its_path and not by_its_name and not by_its_autoload and not name_in_comment:
|
|
207
222
|
continue
|
|
208
223
|
var trimmed: String = line.strip_edges()
|
|
209
224
|
var kind: String = ""
|
|
@@ -219,6 +234,8 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
219
234
|
kind = "comment"
|
|
220
235
|
elif by_its_path:
|
|
221
236
|
kind = _path_reference_kind(line, file_path)
|
|
237
|
+
elif by_its_autoload:
|
|
238
|
+
kind = "autoload"
|
|
222
239
|
else:
|
|
223
240
|
kind = (
|
|
224
241
|
"extends"
|
|
@@ -244,7 +261,7 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
244
261
|
var declared: Variant = null
|
|
245
262
|
if not class_name_declared.is_empty():
|
|
246
263
|
declared = class_name_declared
|
|
247
|
-
|
|
264
|
+
var answer: Dictionary = {
|
|
248
265
|
"resource_path": resource_path,
|
|
249
266
|
"exists": exists,
|
|
250
267
|
"class_name": declared,
|
|
@@ -260,6 +277,9 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
260
277
|
"by_kind": by_kind,
|
|
261
278
|
},
|
|
262
279
|
}
|
|
280
|
+
if not autoload_named.is_empty():
|
|
281
|
+
answer["autoload"] = autoload_named
|
|
282
|
+
return answer
|
|
263
283
|
|
|
264
284
|
|
|
265
285
|
# The class_name a script declares, or empty for a scene, a resource or a script without one.
|
|
@@ -477,15 +497,19 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
|
|
|
477
497
|
if not walk.include_built_in and dep_path.begins_with("res://."):
|
|
478
498
|
continue
|
|
479
499
|
|
|
480
|
-
_depend(deps, path, dep_path,
|
|
500
|
+
_depend(deps, path, dep_path, {}, current_depth, walk)
|
|
481
501
|
|
|
482
502
|
# A script uses another by its class_name as surely as by a preload, and only the reverse walk
|
|
483
503
|
# looked for that: asked what a script depends on, the answer left out the script it typed a
|
|
484
|
-
# variable with, while asking what used that script named this one.
|
|
504
|
+
# variable with, while asking what used that script named this one. An autoload's global
|
|
505
|
+
# name is the same kind of use, of the script registered under it.
|
|
485
506
|
if path.get_extension() == "gd":
|
|
486
507
|
var classes: Dictionary = classes_declared()
|
|
487
508
|
for used: String in classes_used_in(content, classes):
|
|
488
|
-
_depend(deps, path, str(classes[used]), used, current_depth, walk)
|
|
509
|
+
_depend(deps, path, str(classes[used]), {"by_class": used}, current_depth, walk)
|
|
510
|
+
var autoloads: Dictionary = autoloads_declared()
|
|
511
|
+
for used: String in classes_used_in(content, autoloads):
|
|
512
|
+
_depend(deps, path, str(autoloads[used]), {"by_autoload": used}, current_depth, walk)
|
|
489
513
|
|
|
490
514
|
walk.path_stack.pop_back()
|
|
491
515
|
walk.visited[path] = {"deps": deps, "remaining": remaining}
|
|
@@ -493,13 +517,13 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
|
|
|
493
517
|
|
|
494
518
|
|
|
495
519
|
# Adds [param dep_path] to [param deps], what [param path] depends on, walked as far as the depth left
|
|
496
|
-
# allows, once however many times it is named. [param
|
|
497
|
-
# or empty for a path.
|
|
520
|
+
# allows, once however many times it is named. [param named_by] is the class or autoload name it was
|
|
521
|
+
# named by, as by_class or by_autoload, or empty for a path.
|
|
498
522
|
func _depend(
|
|
499
523
|
deps: Array[Dictionary],
|
|
500
524
|
path: String,
|
|
501
525
|
dep_path: String,
|
|
502
|
-
|
|
526
|
+
named_by: Dictionary,
|
|
503
527
|
current_depth: int,
|
|
504
528
|
walk: GdhDependencyWalk
|
|
505
529
|
) -> void:
|
|
@@ -509,8 +533,7 @@ func _depend(
|
|
|
509
533
|
if existing.get("path", "") == dep_path:
|
|
510
534
|
return
|
|
511
535
|
var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
|
|
512
|
-
|
|
513
|
-
dep_info["by_class"] = by_class
|
|
536
|
+
dep_info.merge(named_by)
|
|
514
537
|
if dep_info["exists"] and current_depth + 1 < walk.max_depth:
|
|
515
538
|
var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
|
|
516
539
|
if sub_deps.size() > 0:
|
|
@@ -518,6 +541,22 @@ func _depend(
|
|
|
518
541
|
deps.append(dep_info)
|
|
519
542
|
|
|
520
543
|
|
|
544
|
+
## Every autoload the project registers as a global name, with its script. One that is not global
|
|
545
|
+
## is no name code can use.
|
|
546
|
+
static func autoloads_declared() -> Dictionary:
|
|
547
|
+
var autoloads: Dictionary = {}
|
|
548
|
+
for property: Dictionary in ProjectSettings.get_property_list():
|
|
549
|
+
var setting: String = str(property.get("name", ""))
|
|
550
|
+
if not setting.begins_with("autoload/"):
|
|
551
|
+
continue
|
|
552
|
+
var value: String = str(ProjectSettings.get_setting(setting))
|
|
553
|
+
if value.begins_with("*"):
|
|
554
|
+
autoloads[setting.trim_prefix("autoload/")] = _resolved(
|
|
555
|
+
value.trim_prefix("*"), "res://project.godot"
|
|
556
|
+
)
|
|
557
|
+
return autoloads
|
|
558
|
+
|
|
559
|
+
|
|
521
560
|
## Every class_name the project's scripts declare, with the script declaring it.
|
|
522
561
|
func classes_declared() -> Dictionary:
|
|
523
562
|
if _classes_read:
|
|
@@ -530,6 +569,18 @@ func classes_declared() -> Dictionary:
|
|
|
530
569
|
return _classes_declared
|
|
531
570
|
|
|
532
571
|
|
|
572
|
+
## Every class the project's scripts declare with class_name, sorted by name, with the script
|
|
573
|
+
## declaring it. Read from the files rather than the class cache, which only a scan rewrites.
|
|
574
|
+
func list_script_classes(_params: Dictionary) -> Dictionary:
|
|
575
|
+
var declared: Dictionary = classes_declared()
|
|
576
|
+
var names: Array = declared.keys()
|
|
577
|
+
names.sort()
|
|
578
|
+
var classes: Array[Dictionary] = []
|
|
579
|
+
for name: Variant in names:
|
|
580
|
+
classes.append({"name": str(name), "path": str(declared[name])})
|
|
581
|
+
return {"classes": classes, "count": classes.size()}
|
|
582
|
+
|
|
583
|
+
|
|
533
584
|
## The names in [param classes] that the code in [param content] uses, in the order first used: words
|
|
534
585
|
## outside strings and comments, so a class named in prose or in a message is not a use of it.
|
|
535
586
|
static func classes_used_in(content: String, classes: Dictionary) -> Array[String]:
|
|
@@ -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",
|
package/build/keeper.js
CHANGED
|
@@ -1668,6 +1668,7 @@ function emptyRecord() {
|
|
|
1668
1668
|
// src/project-scan.ts
|
|
1669
1669
|
var SKIPPED = new Set(["node_modules"]);
|
|
1670
1670
|
var SCENE_EXTENSIONS = new Set(["tscn", "scn"]);
|
|
1671
|
+
var SIDECAR_EXTENSIONS = new Set(["import", "uid"]);
|
|
1671
1672
|
var ASSET_EXTENSIONS = new Set([
|
|
1672
1673
|
"png",
|
|
1673
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",
|