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.
@@ -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
- return "" if holder.has_method(method) else "%s has no method %s" % [called, method]
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
- "type": "input_injected",
171
- "input_type": "action",
172
- "action": action,
173
- "pressed": held and not whole,
174
- "whole": whole,
175
- "held_frames": GdhValues.held_answer(whole, frames),
176
- "held_for": lasted,
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
- "type": "input_injected",
244
- "input_type": "key",
245
- "held_frames": GdhValues.held_answer(whole, frames),
246
- "held_for": lasted,
247
- "keycode": event.keycode,
248
- "physical_keycode": event.physical_keycode,
249
- "shift": event.shift_pressed,
250
- "ctrl": event.ctrl_pressed,
251
- "alt": event.alt_pressed,
252
- "unicode": event.unicode,
253
- "pressed": held and not whole,
254
- "whole": whole,
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
- if not by_its_path and not by_its_name and not name_in_comment:
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
- return {
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, "", current_depth, walk)
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 by_class] is the class name it was named by,
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
- by_class: String,
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
- if not by_class.is_empty():
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
- own.signals.append(_parse_signal(header, at + 1))
265
+ entry = _parse_signal(header, at + 1)
266
+ own.signals.append(entry)
226
267
  elif header.begins_with("const "):
227
- own.constants.append(_parse_constant(header, at + 1))
268
+ entry = _parse_constant(header, at + 1)
269
+ own.constants.append(entry)
228
270
  elif header.begins_with("enum "):
229
- own.enums.append(_parse_enum(header, at + 1))
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
- own.variables.append(_parse_variable(stripped, at + 1))
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
- own.functions.append(_parse_function(header, at + 1, stripped))
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
- own.inner_classes.append(inner.as_inner())
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 += ". modify only adds, so a member the script declares already is changed in the file itself"
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 it"
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: int = 0
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 += 1
56
+ missing_imports.append(res)
57
57
 
58
- if missing_imports > 0:
59
- details.append(str(missing_imports) + " resources may need reimporting")
60
- deductions += missing_imports
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.7",
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",