gdharness 1.2.34 → 1.2.36
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 +251 -48
- package/build/godot/addons/gdharness_runtime/call_errors.gd +7 -2
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +9 -5
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +33 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +12 -4
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +7 -5
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +113 -3
- package/build/godot/operations/classdb_queries.gd +22 -0
- package/build/godot/operations/dependencies.gd +90 -20
- package/build/godot/operations/export_presets.gd +232 -0
- package/build/godot/operations/gdscript_analysis.gd +19 -0
- package/build/godot/operations/godot_operations.gd +6 -2
- package/build/godot/operations/import_pipeline.gd +4 -60
- package/build/godot/operations/project_diagnostics.gd +3 -1
- package/build/godot/operations/rename_checks.gd +0 -19
- package/build/godot/operations/script_check.gd +98 -0
- package/build/index.js +247 -46
- package/package.json +1 -1
|
@@ -12,8 +12,10 @@ const REFUSED: String = "Error calling method from 'callv': "
|
|
|
12
12
|
|
|
13
13
|
## The engine's reason for refusing the call, without [constant REFUSED], or "" when it ran.
|
|
14
14
|
var refused: String = ""
|
|
15
|
-
##
|
|
15
|
+
## The errors reported while it ran, a line each.
|
|
16
16
|
var raised: Array[String] = []
|
|
17
|
+
## The warnings reported while it ran, a line each.
|
|
18
|
+
var warned: Array[String] = []
|
|
17
19
|
|
|
18
20
|
|
|
19
21
|
func _log_message(_message: String, _error: bool) -> void:
|
|
@@ -39,4 +41,7 @@ func _log_error(
|
|
|
39
41
|
if function == "callv" and text.begins_with(REFUSED) and refused.is_empty():
|
|
40
42
|
refused = text.trim_prefix(REFUSED)
|
|
41
43
|
return
|
|
42
|
-
|
|
44
|
+
if error_type == ERROR_TYPE_WARNING:
|
|
45
|
+
warned.append(text)
|
|
46
|
+
else:
|
|
47
|
+
raised.append(text)
|
|
@@ -108,6 +108,9 @@ try {
|
|
|
108
108
|
}
|
|
109
109
|
$p = Patterned $element 'RangeValue'
|
|
110
110
|
if ($p) {
|
|
111
|
+
# A reader speaks the Value pattern's words, such as "75%" for a slider whose game sets them,
|
|
112
|
+
# and the number would otherwise replace them.
|
|
113
|
+
if ($node.Contains('value') -and "$($node.value)") { $node.value_text = $node.value }
|
|
111
114
|
$node.value = $p.Current.Value
|
|
112
115
|
$node.range = @($p.Current.Minimum, $p.Current.Maximum)
|
|
113
116
|
if (-not $p.Current.IsReadOnly) { $actions += 'set_value' }
|
|
@@ -389,17 +392,18 @@ static func _narrow(result: Dictionary, params: Dictionary) -> void:
|
|
|
389
392
|
|
|
390
393
|
|
|
391
394
|
## Whether [param element] is made by the node at [param under] or one below it, and says
|
|
392
|
-
## [param said], each where asked: words as find matches them, in the element's name or
|
|
395
|
+
## [param said], each where asked: words as find matches them, in the element's name, value or
|
|
396
|
+
## value_text.
|
|
393
397
|
static func _matches(element: Dictionary, under: String, said: String) -> bool:
|
|
394
398
|
if not under.is_empty():
|
|
395
399
|
var path: String = str(element.get("node", element.get("part_of", "")))
|
|
396
400
|
if path != under and not path.begins_with(under + "/"):
|
|
397
401
|
return false
|
|
398
402
|
if not said.is_empty():
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
+
for field: String in ["name", "value", "value_text"]:
|
|
404
|
+
if element.has(field) and Says.matches(str(element[field]), said):
|
|
405
|
+
return true
|
|
406
|
+
return false
|
|
403
407
|
return true
|
|
404
408
|
|
|
405
409
|
|
|
@@ -18,6 +18,7 @@ const InputCommands = preload("runtime_input.gd")
|
|
|
18
18
|
const Pad = preload("runtime_pad.gd")
|
|
19
19
|
const Queries = preload("runtime_queries.gd")
|
|
20
20
|
const Read = preload("reading.gd")
|
|
21
|
+
const Targets = preload("runtime_targets.gd")
|
|
21
22
|
const Typing = preload("runtime_typing.gd")
|
|
22
23
|
const Values = preload("runtime_values.gd")
|
|
23
24
|
const Waits = preload("runtime_waits.gd")
|
|
@@ -153,6 +154,7 @@ func _init() -> void:
|
|
|
153
154
|
"choose": _typing.choose,
|
|
154
155
|
"wait_frames": _waits.wait_frames,
|
|
155
156
|
"wait_signal": _waits.wait_signal,
|
|
157
|
+
"watch_signal": _waits.watch_signal,
|
|
156
158
|
"wait_until": _waits.wait_until,
|
|
157
159
|
"send_profile": _send_profile_now,
|
|
158
160
|
"close_game": _close_game,
|
|
@@ -160,6 +162,36 @@ func _init() -> void:
|
|
|
160
162
|
_wait_for_the_profiler()
|
|
161
163
|
|
|
162
164
|
|
|
165
|
+
## Gives a game with no window the size the project opens its window at. Measured: a
|
|
166
|
+
## headless engine's root viewport is 64 by 64 whatever the project settings or --resolution say, so
|
|
167
|
+
## a headless game laid its interface out in 64 pixels, and a button every windowed run shows was
|
|
168
|
+
## refused as off screen. The root takes a size set once the autoloads are in the tree, before the
|
|
169
|
+
## main scene enters it, and the scene lays out at it from its first frame. A size set at the
|
|
170
|
+
## autoload's construction does not hold: the root is a 100 by 100 placeholder then, and the engine
|
|
171
|
+
## gives it the 64 by 64 afterwards. A script run is not a game and keeps the viewport its own code
|
|
172
|
+
## gives it, and a release build is left as the engine made it.
|
|
173
|
+
func _size_a_windowless_game() -> void:
|
|
174
|
+
if DisplayServer.get_name() != "headless" or _script_run() or not OS.is_debug_build():
|
|
175
|
+
return
|
|
176
|
+
var root: Window = get_tree().root
|
|
177
|
+
if Vector2(root.size) != Targets.HEADLESS_VIEWPORT:
|
|
178
|
+
return
|
|
179
|
+
root.size = Vector2i(_window_dimension("width", 1152), _window_dimension("height", 648))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
## The window's [param axis] as the project opens it: the override when one is set, otherwise the
|
|
183
|
+
## viewport's size, whose engine default is [param fallback].
|
|
184
|
+
static func _window_dimension(axis: String, fallback: int) -> int:
|
|
185
|
+
var override: int = Read.as_int(
|
|
186
|
+
ProjectSettings.get_setting("display/window/size/window_%s_override" % axis, 0)
|
|
187
|
+
)
|
|
188
|
+
if override > 0:
|
|
189
|
+
return override
|
|
190
|
+
return Read.as_int(
|
|
191
|
+
ProjectSettings.get_setting("display/window/size/viewport_%s" % axis, fallback), fallback
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
163
195
|
## Holds the game's start until the profiler is on, when whatever started the game asked for a
|
|
164
196
|
## profile from its start. The debugger switches the profiler on with a message the game reads only
|
|
165
197
|
## on a poll, which comes every [constant POLLS_PER_READ] lines of script, so the main scene's _ready
|
|
@@ -197,6 +229,7 @@ func _ready() -> void:
|
|
|
197
229
|
# even be un-paused over the socket. A debug server has to stay responsive while the game
|
|
198
230
|
# is frozen, to inspect, capture, inject or resume it.
|
|
199
231
|
process_mode = Node.PROCESS_MODE_ALWAYS
|
|
232
|
+
_size_a_windowless_game()
|
|
200
233
|
var watched: int = get_tree().root.tree_exiting.connect(_send_the_profile)
|
|
201
234
|
if watched != OK:
|
|
202
235
|
push_error("gdharness could not watch for the game quitting: %d" % watched)
|
|
@@ -441,16 +441,24 @@ func call_method(params: Dictionary) -> Dictionary:
|
|
|
441
441
|
"result": _values.serialize(result),
|
|
442
442
|
"elapsed_usec": elapsed
|
|
443
443
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
if caught.raised.size() > ERRORS_KEPT:
|
|
447
|
-
answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
|
|
444
|
+
_keep_reported(answer, "errors", caught.raised)
|
|
445
|
+
_keep_reported(answer, "warnings", caught.warned)
|
|
448
446
|
var asked: Variant = params.get("properties")
|
|
449
447
|
if _reads_the_result(asked):
|
|
450
448
|
answer.merge(_read_off_result(result, asked))
|
|
451
449
|
return answer
|
|
452
450
|
|
|
453
451
|
|
|
452
|
+
## Puts the first [constant ERRORS_KEPT] of [param reported] in [param answer] under [param field],
|
|
453
|
+
## with how many more there were, when anything was reported.
|
|
454
|
+
static func _keep_reported(answer: Dictionary, field: String, reported: Array[String]) -> void:
|
|
455
|
+
if reported.is_empty():
|
|
456
|
+
return
|
|
457
|
+
answer[field] = reported.slice(0, ERRORS_KEPT)
|
|
458
|
+
if reported.size() > ERRORS_KEPT:
|
|
459
|
+
answer[field + "_not_shown"] = reported.size() - ERRORS_KEPT
|
|
460
|
+
|
|
461
|
+
|
|
454
462
|
## Why [param named], the last step of a call's method, names no method of [param holder] to call, or
|
|
455
463
|
## "" when it does. Brackets on it holding arguments are refused rather than read: the arguments are
|
|
456
464
|
## taken from args alone, so a call is never made with half of them in one place and half in the
|
|
@@ -412,17 +412,19 @@ static func _outside_note(outside: int, root: Node) -> String:
|
|
|
412
412
|
## headless game's button twenty pixels in from the corner was refused as off screen, and the
|
|
413
413
|
## refusal read as though the button were not there.
|
|
414
414
|
##
|
|
415
|
-
## The size is
|
|
416
|
-
##
|
|
415
|
+
## The size named is the one in force, which the rect printed beside it agrees with. A game the
|
|
416
|
+
## runtime started headless has the size the project opens its window at, and a window of that size
|
|
417
|
+
## would not reach the control either, so only the engine's own 64 by 64 is answered with a window.
|
|
417
418
|
static func no_window_note(viewport: Viewport, root: Window) -> String:
|
|
418
419
|
if viewport != root or root.can_draw():
|
|
419
420
|
return ""
|
|
420
|
-
|
|
421
|
+
var size: Vector2 = viewport.get_visible_rect().size
|
|
422
|
+
if size == HEADLESS_VIEWPORT:
|
|
421
423
|
return (
|
|
422
424
|
". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
|
|
423
425
|
+ "the project settings say: run it with a window to reach this control"
|
|
424
426
|
)
|
|
425
|
-
return ". This game has no window
|
|
427
|
+
return ". This game has no window, and its viewport is %d by %d" % [roundi(size.x), roundi(size.y)]
|
|
426
428
|
|
|
427
429
|
|
|
428
430
|
## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
|
|
@@ -771,7 +773,7 @@ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[Scrol
|
|
|
771
773
|
## Whether a click could not reach [param target] however it scrolled: its centre, where the click
|
|
772
774
|
## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
|
|
773
775
|
## container the click does not scroll. A drawer parked off the side of the screen, or anything
|
|
774
|
-
## outside the
|
|
776
|
+
## outside the viewport of a game with no window, was listed among the controls on screen.
|
|
775
777
|
static func _off_screen(target: Control) -> bool:
|
|
776
778
|
var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
|
|
777
779
|
var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
|
|
@@ -26,8 +26,13 @@ const LOOKS_APART: int = 9
|
|
|
26
26
|
## two looks and a wait for 32 never sees it.
|
|
27
27
|
const COMPARES: Array[String] = ["==", "!=", ">=", "<=", ">", "<"]
|
|
28
28
|
|
|
29
|
+
## The most signal watches kept at once; arming one past this lets the oldest go.
|
|
30
|
+
const MOST_WATCHES: int = 16
|
|
31
|
+
|
|
29
32
|
var _host: Node
|
|
30
33
|
var _values: Values
|
|
34
|
+
var _watches: Dictionary[int, Watch] = {}
|
|
35
|
+
var _next_watch: int = 1
|
|
31
36
|
|
|
32
37
|
|
|
33
38
|
func _init(host: Node, values: Values) -> void:
|
|
@@ -56,13 +61,16 @@ func wait_frames(params: Dictionary) -> Dictionary:
|
|
|
56
61
|
return {"type": "waited", "frames": frames, "elapsed_ms": Time.get_ticks_msec() - started}
|
|
57
62
|
|
|
58
63
|
|
|
59
|
-
## Waits for a signal to fire, and answers with what it carried, or that the time ran out.
|
|
64
|
+
## Waits for a signal to fire, and answers with what it carried, or that the time ran out. Given a
|
|
65
|
+
## watch, collects that instead: see [method watch_signal].
|
|
60
66
|
func wait_signal(params: Dictionary) -> Dictionary:
|
|
61
67
|
var node_path: String = str(params.get("path", ""))
|
|
62
68
|
var signal_name: String = str(params.get("signal", ""))
|
|
63
69
|
var timeout_ms: int = Read.as_int(params.get("timeout_ms", 5000), 5000)
|
|
64
70
|
if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
|
|
65
71
|
return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
|
|
72
|
+
if params.has("watch"):
|
|
73
|
+
return await _collect(Read.as_int(params["watch"], 0), timeout_ms)
|
|
66
74
|
if node_path.is_empty() or signal_name.is_empty():
|
|
67
75
|
return {"type": "error", "message": "Node path and signal name required"}
|
|
68
76
|
|
|
@@ -84,7 +92,7 @@ func wait_signal(params: Dictionary) -> Dictionary:
|
|
|
84
92
|
if not catcher.fired and is_instance_valid(node) and node.is_connected(signal_name, callable):
|
|
85
93
|
node.disconnect(signal_name, callable)
|
|
86
94
|
|
|
87
|
-
|
|
95
|
+
var answer: Dictionary = {
|
|
88
96
|
"type": "signal",
|
|
89
97
|
"path": node_path,
|
|
90
98
|
"signal": signal_name,
|
|
@@ -92,6 +100,90 @@ func wait_signal(params: Dictionary) -> Dictionary:
|
|
|
92
100
|
"args": _values.serialize(catcher.args),
|
|
93
101
|
"elapsed_ms": Time.get_ticks_msec() - started,
|
|
94
102
|
}
|
|
103
|
+
if not catcher.fired:
|
|
104
|
+
answer["note"] = (
|
|
105
|
+
"If a call meant to set the signal off was sent beside this wait, it may have run only after"
|
|
106
|
+
+ " the wait answered, as a client can run its calls in turn: arm runtime_wait watch first,"
|
|
107
|
+
+ " make the call, then wait with signal and that watch"
|
|
108
|
+
)
|
|
109
|
+
return answer
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
## Starts recording a signal and answers at once with the watch to collect it by.
|
|
113
|
+
##
|
|
114
|
+
## A wait sent beside the call meant to set its signal off does not see it when the client runs the
|
|
115
|
+
## two in turn: Claude Code ran a key press only after the signal wait beside it had answered, so the
|
|
116
|
+
## wait ran out and the press landed after it. A watch is armed before the call and collected after.
|
|
117
|
+
func watch_signal(params: Dictionary) -> Dictionary:
|
|
118
|
+
var node_path: String = str(params.get("path", ""))
|
|
119
|
+
var signal_name: String = str(params.get("signal", ""))
|
|
120
|
+
if node_path.is_empty() or signal_name.is_empty():
|
|
121
|
+
return {"type": "error", "message": "Node path and signal name required"}
|
|
122
|
+
var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
|
|
123
|
+
if standing.has("message"):
|
|
124
|
+
return standing
|
|
125
|
+
var node: Node = standing["node"]
|
|
126
|
+
if not node.has_signal(signal_name):
|
|
127
|
+
return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
|
|
128
|
+
var watch: Watch = Watch.new(node, node_path, signal_name)
|
|
129
|
+
var listening: Error = node.connect(signal_name, watch.catcher._on_fired)
|
|
130
|
+
if listening != OK:
|
|
131
|
+
return {"type": "error", "message": "Could not listen to signal: " + signal_name}
|
|
132
|
+
while _watches.size() >= MOST_WATCHES:
|
|
133
|
+
var oldest: int = _watches.keys().min()
|
|
134
|
+
_let_go(oldest)
|
|
135
|
+
var id: int = _next_watch
|
|
136
|
+
_next_watch += 1
|
|
137
|
+
_watches[id] = watch
|
|
138
|
+
return {"type": "watching", "watch": id, "path": node_path, "signal": signal_name}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
## Answers at once with the first emission recorded since watch [param id] was armed, otherwise
|
|
142
|
+
## waits for the next until the time runs out. The watch goes either way.
|
|
143
|
+
func _collect(id: int, timeout_ms: int) -> Dictionary:
|
|
144
|
+
if not _watches.has(id):
|
|
145
|
+
return {
|
|
146
|
+
"type": "error",
|
|
147
|
+
"message":
|
|
148
|
+
(
|
|
149
|
+
(
|
|
150
|
+
"No watch %d is armed in this game: it was collected already, %d armed after it let it"
|
|
151
|
+
+ " go, or the game started again since"
|
|
152
|
+
)
|
|
153
|
+
% [id, MOST_WATCHES]
|
|
154
|
+
),
|
|
155
|
+
}
|
|
156
|
+
var watch: Watch = _watches[id]
|
|
157
|
+
var started: int = Time.get_ticks_msec()
|
|
158
|
+
var before: bool = watch.catcher.fired
|
|
159
|
+
while not watch.catcher.fired and Time.get_ticks_msec() - started < timeout_ms:
|
|
160
|
+
await _host.get_tree().process_frame
|
|
161
|
+
_let_go(id)
|
|
162
|
+
var answer: Dictionary = {
|
|
163
|
+
"type": "signal",
|
|
164
|
+
"path": watch.node_path,
|
|
165
|
+
"signal": watch.signal_name,
|
|
166
|
+
"watch": id,
|
|
167
|
+
"fired": watch.catcher.fired,
|
|
168
|
+
"times": watch.catcher.times,
|
|
169
|
+
"args": _values.serialize(watch.catcher.args),
|
|
170
|
+
"elapsed_ms": Time.get_ticks_msec() - started,
|
|
171
|
+
}
|
|
172
|
+
if before:
|
|
173
|
+
answer["fired_before_wait"] = true
|
|
174
|
+
return answer
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
func _let_go(id: int) -> void:
|
|
178
|
+
var watch: Watch = _watches.get(id)
|
|
179
|
+
var _gone: bool = _watches.erase(id)
|
|
180
|
+
if watch == null:
|
|
181
|
+
return
|
|
182
|
+
var held: Object = watch.node.get_ref()
|
|
183
|
+
var node: Node = held as Node
|
|
184
|
+
var callable: Callable = watch.catcher._on_fired
|
|
185
|
+
if node != null and node.is_connected(watch.signal_name, callable):
|
|
186
|
+
node.disconnect(watch.signal_name, callable)
|
|
95
187
|
|
|
96
188
|
|
|
97
189
|
## Waits until a property reads as the value given, or until something under the path says what
|
|
@@ -492,8 +584,26 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
|
|
|
492
584
|
class SignalCatcher:
|
|
493
585
|
extends RefCounted
|
|
494
586
|
var fired: bool = false
|
|
587
|
+
## How many times it fired; [member args] holds what the first carried.
|
|
588
|
+
var times: int = 0
|
|
495
589
|
var args: Array = []
|
|
496
590
|
|
|
497
591
|
func _on_fired(...carried: Array) -> void:
|
|
592
|
+
if not fired:
|
|
593
|
+
args = carried
|
|
498
594
|
fired = true
|
|
499
|
-
|
|
595
|
+
times += 1
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
## A signal recorded from when it was armed until it is collected.
|
|
599
|
+
class Watch:
|
|
600
|
+
extends RefCounted
|
|
601
|
+
var node: WeakRef
|
|
602
|
+
var node_path: String
|
|
603
|
+
var signal_name: String
|
|
604
|
+
var catcher: SignalCatcher = SignalCatcher.new()
|
|
605
|
+
|
|
606
|
+
func _init(watched: Node, path: String, named: String) -> void:
|
|
607
|
+
node = weakref(watched)
|
|
608
|
+
node_path = path
|
|
609
|
+
signal_name = named
|
|
@@ -21,6 +21,17 @@ const CATEGORY_BASES: Dictionary = {
|
|
|
21
21
|
"visual": ["VisualInstance3D"],
|
|
22
22
|
"animation": ["AnimationMixer"],
|
|
23
23
|
}
|
|
24
|
+
## The engine's MethodFlags, each by the name an answer gives it.
|
|
25
|
+
const METHOD_FLAG_NAMES: Dictionary[int, String] = {
|
|
26
|
+
METHOD_FLAG_NORMAL: "normal",
|
|
27
|
+
METHOD_FLAG_EDITOR: "editor",
|
|
28
|
+
METHOD_FLAG_CONST: "const",
|
|
29
|
+
METHOD_FLAG_VIRTUAL: "virtual",
|
|
30
|
+
METHOD_FLAG_VARARG: "vararg",
|
|
31
|
+
METHOD_FLAG_STATIC: "static",
|
|
32
|
+
METHOD_FLAG_OBJECT_CORE: "object_core",
|
|
33
|
+
METHOD_FLAG_VIRTUAL_REQUIRED: "virtual_required",
|
|
34
|
+
}
|
|
24
35
|
|
|
25
36
|
var _log: Log
|
|
26
37
|
var _values: Serialisation = Serialisation.new()
|
|
@@ -333,6 +344,7 @@ func _methods_of(class_name_str: String, include_inherited: bool) -> Array[Dicti
|
|
|
333
344
|
"class_name": return_info.get("class_name", "")
|
|
334
345
|
},
|
|
335
346
|
"flags": m.get("flags", 0),
|
|
347
|
+
"flag_names": _flag_names(Read.as_int(m.get("flags", 0))),
|
|
336
348
|
# As values, tagged the way every other answer tags one: JSON writes a Vector2i it is
|
|
337
349
|
# handed as its text, so TileMapLayer.set_cell's default atlas coords read "(-1, -1)".
|
|
338
350
|
"default_args": _values.serialize_value(m.get("default_args", []))
|
|
@@ -343,6 +355,16 @@ func _methods_of(class_name_str: String, include_inherited: bool) -> Array[Dicti
|
|
|
343
355
|
return methods
|
|
344
356
|
|
|
345
357
|
|
|
358
|
+
# The MethodFlags set in [param flags] by name, beside the number the engine reports, which reads as
|
|
359
|
+
# nothing without its enum: 1 is normal, and a const method is 5.
|
|
360
|
+
static func _flag_names(flags: int) -> Array[String]:
|
|
361
|
+
var named: Array[String] = []
|
|
362
|
+
for flag: int in METHOD_FLAG_NAMES:
|
|
363
|
+
if flags & flag:
|
|
364
|
+
named.append(METHOD_FLAG_NAMES[flag])
|
|
365
|
+
return named
|
|
366
|
+
|
|
367
|
+
|
|
346
368
|
# What [param info]'s type is called: its class for an object of one, and otherwise the engine's own
|
|
347
369
|
# name for the type, beside the number the engine reports, which reads as nothing without its enum.
|
|
348
370
|
# Nil is a value that can be anything when the usage says so, and [param nil_name] otherwise: void
|
|
@@ -23,6 +23,11 @@ const REFERENCE_PATTERNS: Array[String] = [
|
|
|
23
23
|
|
|
24
24
|
var _log: Log
|
|
25
25
|
var _files: FileWalk = FileWalk.new()
|
|
26
|
+
## The project's class names and the scripts declaring them, read the first time a script is walked
|
|
27
|
+
## and kept for the call: walking every resource starts a walk per root, and each reading every
|
|
28
|
+
## script again made the whole-project answer quadratic in the number of scripts.
|
|
29
|
+
var _classes_declared: Dictionary = {}
|
|
30
|
+
var _classes_read: bool = false
|
|
26
31
|
|
|
27
32
|
|
|
28
33
|
# Everything one dependency walk carries: the settings it was started with, and the state it
|
|
@@ -91,12 +96,14 @@ func get_dependencies(params: Dictionary) -> Dictionary:
|
|
|
91
96
|
|
|
92
97
|
total_resources = all_resources.size()
|
|
93
98
|
|
|
94
|
-
var
|
|
99
|
+
var references: int = 0
|
|
100
|
+
var distinct: Dictionary = {}
|
|
95
101
|
for key: String in dependencies:
|
|
96
102
|
# Through a typed local: what comes out of a Dictionary is Variant, and a project that
|
|
97
103
|
# errors on handing one to a typed parameter will not compile this script at all.
|
|
98
104
|
var walked: Array[Dictionary] = dependencies[key]
|
|
99
|
-
|
|
105
|
+
references += _count_recursive(walked)
|
|
106
|
+
_gather_paths(walked, key, distinct)
|
|
100
107
|
|
|
101
108
|
return {
|
|
102
109
|
"dependencies": dependencies,
|
|
@@ -104,7 +111,10 @@ func get_dependencies(params: Dictionary) -> Dictionary:
|
|
|
104
111
|
"summary":
|
|
105
112
|
{
|
|
106
113
|
"total_resources": total_resources,
|
|
107
|
-
|
|
114
|
+
# The files depended on, each once. The tree names a file again under everything that
|
|
115
|
+
# loads it, and counting its entries answered 11 for a scene with six dependencies.
|
|
116
|
+
"total_dependencies": distinct.size(),
|
|
117
|
+
"total_references": references,
|
|
108
118
|
"circular_count": circular_references.size()
|
|
109
119
|
}
|
|
110
120
|
}
|
|
@@ -332,9 +342,24 @@ func _regex_escaped(text: String) -> String:
|
|
|
332
342
|
return escaped
|
|
333
343
|
|
|
334
344
|
|
|
345
|
+
# [param root] is left out: a cycle leads back to it, and it is not one of its own dependencies.
|
|
346
|
+
func _gather_paths(deps: Array[Dictionary], root: String, into: Dictionary) -> void:
|
|
347
|
+
for dep: Dictionary in deps:
|
|
348
|
+
var path: String = str(dep.get("path", ""))
|
|
349
|
+
if path != root:
|
|
350
|
+
into[path] = true
|
|
351
|
+
if dep.has("dependencies"):
|
|
352
|
+
var deeper: Array[Dictionary] = dep["dependencies"]
|
|
353
|
+
_gather_paths(deeper, root, into)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# A circular entry is not counted: it marks that the entry holding it was reached again, which that
|
|
357
|
+
# entry already counts as one reference.
|
|
335
358
|
func _count_recursive(deps: Array[Dictionary]) -> int:
|
|
336
|
-
var count: int =
|
|
359
|
+
var count: int = 0
|
|
337
360
|
for dep: Dictionary in deps:
|
|
361
|
+
if not dep.get("circular", false):
|
|
362
|
+
count += 1
|
|
338
363
|
if dep.has("dependencies"):
|
|
339
364
|
var deeper: Array[Dictionary] = dep["dependencies"]
|
|
340
365
|
count += _count_recursive(deeper)
|
|
@@ -390,29 +415,74 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
|
|
|
390
415
|
if not walk.include_built_in and dep_path.begins_with("res://."):
|
|
391
416
|
continue
|
|
392
417
|
|
|
393
|
-
|
|
394
|
-
continue
|
|
395
|
-
|
|
396
|
-
var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
|
|
397
|
-
|
|
398
|
-
if dep_info["exists"] and current_depth + 1 < walk.max_depth:
|
|
399
|
-
var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
|
|
400
|
-
if sub_deps.size() > 0:
|
|
401
|
-
dep_info["dependencies"] = sub_deps
|
|
418
|
+
_depend(deps, path, dep_path, "", current_depth, walk)
|
|
402
419
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
420
|
+
# A script uses another by its class_name as surely as by a preload, and only the reverse walk
|
|
421
|
+
# looked for that: asked what a script depends on, the answer left out the script it typed a
|
|
422
|
+
# variable with, while asking what used that script named this one.
|
|
423
|
+
if path.get_extension() == "gd":
|
|
424
|
+
var classes: Dictionary = classes_declared()
|
|
425
|
+
for used: String in classes_used_in(content, classes):
|
|
426
|
+
_depend(deps, path, str(classes[used]), used, current_depth, walk)
|
|
410
427
|
|
|
411
428
|
walk.path_stack.pop_back()
|
|
412
429
|
walk.visited[path] = {"deps": deps, "remaining": remaining}
|
|
413
430
|
return deps
|
|
414
431
|
|
|
415
432
|
|
|
433
|
+
# Adds [param dep_path] to [param deps], what [param path] depends on, walked as far as the depth left
|
|
434
|
+
# allows, once however many times it is named. [param by_class] is the class name it was named by,
|
|
435
|
+
# or empty for a path.
|
|
436
|
+
func _depend(
|
|
437
|
+
deps: Array[Dictionary],
|
|
438
|
+
path: String,
|
|
439
|
+
dep_path: String,
|
|
440
|
+
by_class: String,
|
|
441
|
+
current_depth: int,
|
|
442
|
+
walk: DependencyWalk
|
|
443
|
+
) -> void:
|
|
444
|
+
if dep_path == path:
|
|
445
|
+
return
|
|
446
|
+
for existing: Dictionary in deps:
|
|
447
|
+
if existing.get("path", "") == dep_path:
|
|
448
|
+
return
|
|
449
|
+
var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
|
|
450
|
+
if not by_class.is_empty():
|
|
451
|
+
dep_info["by_class"] = by_class
|
|
452
|
+
if dep_info["exists"] and current_depth + 1 < walk.max_depth:
|
|
453
|
+
var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
|
|
454
|
+
if sub_deps.size() > 0:
|
|
455
|
+
dep_info["dependencies"] = sub_deps
|
|
456
|
+
deps.append(dep_info)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
## Every class_name the project's scripts declare, with the script declaring it.
|
|
460
|
+
func classes_declared() -> Dictionary:
|
|
461
|
+
if _classes_read:
|
|
462
|
+
return _classes_declared
|
|
463
|
+
_classes_read = true
|
|
464
|
+
for script: String in _files.find_files("res://", ".gd"):
|
|
465
|
+
var declared: String = _declared_class_name(script)
|
|
466
|
+
if not declared.is_empty():
|
|
467
|
+
_classes_declared[declared] = script
|
|
468
|
+
return _classes_declared
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
## The names in [param classes] that the code in [param content] uses, in the order first used: words
|
|
472
|
+
## outside strings and comments, so a class named in prose or in a message is not a use of it.
|
|
473
|
+
static func classes_used_in(content: String, classes: Dictionary) -> Array[String]:
|
|
474
|
+
var used: Array[String] = []
|
|
475
|
+
if classes.is_empty():
|
|
476
|
+
return used
|
|
477
|
+
var word: RegEx = Patterns.compiled("[A-Za-z_][A-Za-z0-9_]*")
|
|
478
|
+
for line: String in content.split("\n"):
|
|
479
|
+
for m: RegExMatch in word.search_all(_code_of(line)):
|
|
480
|
+
var name: String = m.get_string()
|
|
481
|
+
if classes.has(name) and not name in used:
|
|
482
|
+
used.append(name)
|
|
483
|
+
return used
|
|
484
|
+
|
|
485
|
+
|
|
416
486
|
# [param written] as the res:// path the engine would load from [param from]: a uid:// by what the
|
|
417
487
|
# project's uid cache says it names, and a relative path against the referring file's directory.
|
|
418
488
|
# Both were passed over as not being res:// paths, so a script's `preload("sibling.gd")` was no
|