gdharness 1.3.1 → 1.3.2

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.
Files changed (34) hide show
  1. package/build/cli.js +311 -86
  2. package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
  3. package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +5 -0
  5. package/build/godot/addons/gdharness_editor/property_values.gd +106 -10
  6. package/build/godot/addons/gdharness_editor/reading.gd +37 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +4 -0
  8. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +133 -39
  9. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +9 -9
  10. package/build/godot/addons/gdharness_editor/value_shapes.gd +7 -0
  11. package/build/godot/addons/gdharness_runtime/reading.gd +37 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_values.gd +27 -1
  13. package/build/godot/addons/gdharness_runtime/serialisation.gd +4 -0
  14. package/build/godot/addons/gdharness_runtime/value_shapes.gd +7 -0
  15. package/build/godot/operations/classdb_queries.gd +13 -3
  16. package/build/godot/operations/declared_values.gd +116 -0
  17. package/build/godot/operations/dependencies.gd +109 -5
  18. package/build/godot/operations/export_presets.gd +4 -6
  19. package/build/godot/operations/file_reads.gd +5 -0
  20. package/build/godot/operations/file_walk.gd +34 -0
  21. package/build/godot/operations/gdscript_analysis.gd +7 -2
  22. package/build/godot/operations/gdscript_authoring.gd +105 -2
  23. package/build/godot/operations/godot_operations.gd +2 -0
  24. package/build/godot/operations/import_pipeline.gd +39 -0
  25. package/build/godot/operations/input_actions.gd +73 -1
  26. package/build/godot/operations/project_config.gd +59 -3
  27. package/build/godot/operations/project_diagnostics.gd +27 -2
  28. package/build/godot/operations/property_values.gd +106 -10
  29. package/build/godot/operations/reading.gd +37 -0
  30. package/build/godot/operations/resource_files.gd +4 -1
  31. package/build/godot/operations/script_check.gd +16 -0
  32. package/build/godot/operations/serialisation.gd +4 -0
  33. package/build/godot/operations/value_shapes.gd +7 -0
  34. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  extends RefCounted
2
2
 
3
+ const GdhFileWalk = preload("file_walk.gd")
3
4
  const GdhRead = preload("reading.gd")
4
5
  const GdhLog = preload("logger.gd")
5
6
  const GdhProjectFile = preload("project_file.gd")
@@ -125,6 +126,12 @@ func set_project_setting(params: Dictionary) -> Dictionary:
125
126
  if not _converts_faithfully(final_value, wanted):
126
127
  return _log.failure(_cannot_hold(setting_path, wanted, final_value))
127
128
  final_value = type_convert(final_value, wanted)
129
+ # JSON carries every number as a float and a new setting has no declared type to fit one to, so a
130
+ # whole number was saved as 800.0 beside an answer saying 800.
131
+ if not had_value and wanted == TYPE_NIL and final_value is float:
132
+ var number: float = final_value
133
+ if number == floorf(number) and absf(number) < 9.0e15:
134
+ final_value = int(number)
128
135
 
129
136
  var answer: Dictionary = {
130
137
  "setting_path": setting_path,
@@ -141,6 +148,17 @@ func set_project_setting(params: Dictionary) -> Dictionary:
141
148
  answer["unchanged"] = true
142
149
  return answer
143
150
 
151
+ var near: PackedStringArray = []
152
+ if not had_value:
153
+ near = _settings_near(setting_path)
154
+ if not near.is_empty():
155
+ answer["note"] = (
156
+ (
157
+ "No setting %s existed, so this wrote a new one, which the engine reads only where"
158
+ + " something asks for it by that name. The nearest %s %s."
159
+ )
160
+ % [setting_path, "setting is" if near.size() == 1 else "settings are", ", ".join(near)]
161
+ )
144
162
  ProjectSettings.set_setting(setting_path, final_value)
145
163
  var err: Error = GdhProjectFile.save()
146
164
  if err != OK:
@@ -148,6 +166,18 @@ func set_project_setting(params: Dictionary) -> Dictionary:
148
166
  return answer
149
167
 
150
168
 
169
+ # The settings in the same section as [param setting_path] named nearest it, for a new one that is
170
+ # likely a misspelt one: a setting nothing declares is written all the same, and is never read.
171
+ static func _settings_near(setting_path: String) -> PackedStringArray:
172
+ var section: String = setting_path.get_slice("/", 0) + "/"
173
+ var names: PackedStringArray = []
174
+ for info: Dictionary in ProjectSettings.get_property_list():
175
+ var name: String = str(info.get("name", ""))
176
+ if name.begins_with(section) and name != setting_path:
177
+ var _added: bool = names.append(name)
178
+ return GdhRead.nearest(setting_path, names)
179
+
180
+
151
181
  # The type a setting wants, or TYPE_NIL where the engine names none and there is nothing to go on.
152
182
  #
153
183
  # JSON carries one number type, so every number arrives as a float and an int setting was written
@@ -215,7 +245,9 @@ func add_autoload(params: Dictionary) -> Dictionary:
215
245
  _log.info("Adding autoload: " + name + " -> " + path)
216
246
 
217
247
  if not FileAccess.file_exists(path):
218
- return _log.failure("Autoload file does not exist: " + path)
248
+ return _log.failure(
249
+ GdhFileWalk.new().with_nearest_files("Autoload file does not exist: " + path, path)
250
+ )
219
251
 
220
252
  # Through ProjectSettings so the file is saved the way the editor saves it, which includes
221
253
  # putting Godot's header back and dropping any comment somebody wrote into the file by hand.
@@ -225,7 +257,17 @@ func add_autoload(params: Dictionary) -> Dictionary:
225
257
  # An asterisk in front of the path makes the autoload's name a global variable, the editor's
226
258
  # "Global Variable" column. Without it the node still comes up under the root; only the name
227
259
  # stops resolving.
228
- ProjectSettings.set_setting(setting, ("*" if global else "") + path)
260
+ var entry: String = ("*" if global else "") + path
261
+ if was_updated and str(ProjectSettings.get_setting(setting)) == entry:
262
+ return {
263
+ "name": name,
264
+ "path": path,
265
+ "enabled": true,
266
+ "global": global,
267
+ "action": "unchanged",
268
+ "saved": false
269
+ }
270
+ ProjectSettings.set_setting(setting, entry)
229
271
  var err: Error = GdhProjectFile.save()
230
272
  if err != OK:
231
273
  return _log.failure("Failed to save project.godot: " + error_string(err))
@@ -236,6 +278,7 @@ func add_autoload(params: Dictionary) -> Dictionary:
236
278
  "enabled": true,
237
279
  "global": global,
238
280
  "action": "updated" if was_updated else "added",
281
+ "saved": true,
239
282
  }
240
283
 
241
284
 
@@ -295,7 +338,20 @@ func set_main_scene(params: Dictionary) -> Dictionary:
295
338
  _log.info("Setting main scene: " + scene_path)
296
339
 
297
340
  if not FileAccess.file_exists(scene_path):
298
- return _log.failure("Scene file does not exist: " + scene_path)
341
+ return _log.failure(
342
+ GdhFileWalk.new().with_nearest_files("Scene file does not exist: " + scene_path, scene_path)
343
+ )
344
+
345
+ # The engine starts the game by instancing the main scene, so a script or any other resource set
346
+ # there was saved and answered as set, and the game then would not start.
347
+ if not ResourceLoader.exists(scene_path, "PackedScene"):
348
+ var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
349
+ var kind: String = loaded.get_class() if loaded != null else ""
350
+ var said: String = (
351
+ "%s is %s, not a scene, and the main scene has to be one"
352
+ % [scene_path, "a " + kind if not kind.is_empty() else "no resource the engine loads"]
353
+ )
354
+ return _log.failure(GdhFileWalk.new().with_nearest_files(said, scene_path.get_basename() + ".tscn"))
299
355
 
300
356
  var old_main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
301
357
 
@@ -1,8 +1,11 @@
1
1
  extends RefCounted
2
2
 
3
+ const GdhDependencies = preload("dependencies.gd")
3
4
  const GdhFileWalk = preload("file_walk.gd")
4
5
  const GdhImportPipeline = preload("import_pipeline.gd")
5
6
  const GdhLog = preload("logger.gd")
7
+ const GdhRead = preload("reading.gd")
8
+ const GdhScriptCheck = preload("script_check.gd")
6
9
 
7
10
  var _log: GdhLog
8
11
  var _files: GdhFileWalk = GdhFileWalk.new()
@@ -15,7 +18,7 @@ func _init(p_log: GdhLog) -> void:
15
18
  # Get comprehensive project health check with scoring
16
19
  func get_project_health(params: Dictionary) -> Dictionary:
17
20
  var check_categories: Array = params.get(
18
- "categories", ["structure", "resources", "scripts", "scenes", "config"]
21
+ "categories", ["structure", "resources", "scripts", "references", "scenes", "config"]
19
22
  )
20
23
 
21
24
  _log.info("Performing project health check")
@@ -87,12 +90,34 @@ func get_project_health(params: Dictionary) -> Dictionary:
87
90
  if todo_count > 0:
88
91
  recommendations.append("%d scripts have TODO or FIXME comments to look at" % todo_count)
89
92
 
90
- checks["scripts"] = {
93
+ # Compiled by the server before this runs, since an engine stops at the first script that
94
+ # fails, and handed in. Without it the check read only comments and said "passed" beside
95
+ # scripts the engine refused.
96
+ var compiled: Dictionary = params.get("compiled", {})
97
+ var failures: Array = compiled.get("failures", [])
98
+ for failure: Dictionary in failures:
99
+ details.append(GdhScriptCheck.failure_line(failure))
100
+ deductions += 10
101
+
102
+ var scripts: Dictionary = {
91
103
  "name": "Scripts",
92
104
  "passed": details.is_empty(),
93
105
  "details": details,
94
106
  "total_scripts": script_files.size()
95
107
  }
108
+ if compiled.has("checked"):
109
+ scripts["compiled"] = GdhRead.as_int(compiled["checked"])
110
+ else:
111
+ scripts["not_compiled"] = str(compiled.get("not_checked", "no compile was asked for"))
112
+ checks["scripts"] = scripts
113
+
114
+ # 3b. References
115
+ if "references" in check_categories:
116
+ var details: Array[String] = []
117
+ for entry: Dictionary in GdhDependencies.new(_log).missing_references():
118
+ details.append(GdhDependencies.missing_line(entry))
119
+ deductions += 5
120
+ checks["references"] = {"name": "References", "passed": details.is_empty(), "details": details}
96
121
 
97
122
  # 4. Scenes Check
98
123
  if "scenes" in check_categories:
@@ -14,6 +14,7 @@ extends RefCounted
14
14
  ## write accepts. What is here on top of it is the part only a property knows: the type wanted, so a
15
15
  ## dictionary shaped like a vector or a bare pair of numbers can be read as one.
16
16
 
17
+ const GdhDeclared = preload("declared_values.gd")
17
18
  const GdhRead = preload("reading.gd")
18
19
  const GdhSerialisation = preload("serialisation.gd")
19
20
  const GdhShapes = preload("value_shapes.gd")
@@ -78,6 +79,11 @@ var _refused: String = ""
78
79
  # offsets. An answer naming only what was given leaves the caller to guess the rest.
79
80
  var _also_changed: Dictionary = {}
80
81
 
82
+ # The values the last write_all wrote outside what their property declares, by name, as a sentence
83
+ # saying so: a number past an export range, or one no enum value has. The engine stores either, and
84
+ # the inspector would not have let it be entered, so a write answered as made said nothing of it.
85
+ var _outside_declared: Dictionary = {}
86
+
81
87
 
82
88
  ## Turns what arrived over the wire into the Godot value a property wants.
83
89
  ##
@@ -177,23 +183,34 @@ func cannot_hold(
177
183
  ## caller returns the refusal instead of saving.
178
184
  func write_all(target: Object, properties: Dictionary) -> String:
179
185
  _also_changed = {}
186
+ _outside_declared = {}
180
187
  var named: Dictionary = {}
181
188
  for key: Variant in properties:
182
189
  named[str(key).get_slice(":", 0)] = true
183
190
  var before: Dictionary = _stored(target, named)
184
191
  var given: Dictionary = {}
192
+ # A refusal of one of several leaves the others unwritten too, which a refusal naming only the
193
+ # one it refused left a caller to guess.
194
+ var unwritten: String = (
195
+ "" if properties.size() < 2 else " None of the %d properties given was written." % properties.size()
196
+ )
185
197
  for key: Variant in properties:
186
198
  var property: String = str(key)
187
199
  var declared: Dictionary = declaration(target, property)
188
200
  if declared.is_empty() and ":" in property:
189
201
  declared = _component_slot(target, property)
190
202
  if declared.has("refused"):
191
- return str(declared["refused"])
203
+ return str(declared["refused"]) + unwritten
204
+ if declared.is_empty() and target is ShaderMaterial and property.begins_with("shader_parameter/"):
205
+ return _no_uniform(target as ShaderMaterial, property) + unwritten
192
206
  if declared.is_empty():
193
- return "%s has no property %s" % [target.get_class(), property]
207
+ return "%s has no property %s." % [class_called(target), property] + unwritten
194
208
  var value: Array = value_for(target, declared, properties[key])
195
209
  if not value[0]:
196
- return str(value[1])
210
+ return str(value[1]) + unwritten
211
+ var outside: String = GdhDeclared.outside(target, declared, value[1], shown(value[1]))
212
+ if not outside.is_empty():
213
+ _outside_declared[property] = outside
197
214
  _write(target, property, value[1])
198
215
  given[property] = value[1]
199
216
 
@@ -217,11 +234,32 @@ func write_all(target: Object, properties: Dictionary) -> String:
217
234
  return ""
218
235
 
219
236
 
237
+ ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
238
+ ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
239
+ ## material.
240
+ static func _no_uniform(material: ShaderMaterial, property: String) -> String:
241
+ var uniform: String = property.trim_prefix("shader_parameter/")
242
+ if material.shader == null:
243
+ return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
244
+ var shader: String = material.shader.resource_path
245
+ var named: String = shader if not shader.is_empty() else "Its shader"
246
+ var uniforms: Array = []
247
+ for entry: Dictionary in material.shader.get_shader_uniform_list():
248
+ uniforms.append(str(entry["name"]))
249
+ var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
250
+ if uniforms.is_empty():
251
+ return said + "; it declares no uniforms."
252
+ return GdhRead.offered(said, uniform, uniforms) + "."
253
+
254
+
220
255
  ## Puts under alsoChanged in [param answer] each property the last [method write_all] changed
221
- ## without being given it, as {was, now}, when there are any. A node is named by its path from
222
- ## [param relative_to], as a scene answer names one. Called before the scene is written, since the
223
- ## write frees the nodes these values can hold.
224
- func note_also_changed(answer: Dictionary, relative_to: Node = null) -> void:
256
+ ## without being given it, as {was, now}, and under outsideDeclared each value it wrote outside what
257
+ ## its property declares, when there are any. A node is named by its path from [param relative_to],
258
+ ## as a scene answer names one. Called before the scene is written, since the write frees the nodes
259
+ ## these values can hold.
260
+ func note_write(answer: Dictionary, relative_to: Node = null) -> void:
261
+ if not _outside_declared.is_empty():
262
+ answer["outsideDeclared"] = _outside_declared.duplicate()
225
263
  if _also_changed.is_empty():
226
264
  return
227
265
  var noted: Dictionary = {}
@@ -324,6 +362,18 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
324
362
  var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
325
363
  if expected_type == TYPE_OBJECT and hint == PROPERTY_HINT_NODE_TYPE:
326
364
  return _node_reference(target, property, raw)
365
+ # An enum is written by the name of a value as well as its number, since the name is what the
366
+ # script and the inspector show and the number is only where the value happens to sit.
367
+ if expected_type == TYPE_INT and hint == PROPERTY_HINT_ENUM and typeof(raw) == TYPE_STRING:
368
+ var choices: Dictionary = GdhDeclared.enum_of(target, declared)
369
+ var chosen: Variant = GdhDeclared.enum_named(choices, str(raw))
370
+ if chosen == null:
371
+ var hinted: Dictionary = GdhDeclared.enum_values(str(declared.get("hint_string", "")))
372
+ chosen = GdhDeclared.enum_named(hinted, str(raw))
373
+ if chosen != null:
374
+ return [true, chosen]
375
+ if not str(raw).strip_edges().is_valid_int():
376
+ return [false, GdhDeclared.enum_refusal(class_called(target), property, str(raw), choices)]
327
377
 
328
378
  # A resource-valued property takes the path of one, which is how a caller names a TileSet,
329
379
  # a material or a theme: there is no other way to hand a tool a Resource.
@@ -339,12 +389,12 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
339
389
  return [false, _fraction_refusal(target, property, raw)]
340
390
  var rounded: String = GdhSerialisation.inexact(raw, expected_type)
341
391
  if not rounded.is_empty():
342
- return [false, "%s.%s holds int: %s." % [target.get_class(), property, rounded]]
392
+ return [false, "%s.%s holds int: %s." % [class_called(target), property, rounded]]
343
393
  _refused = ""
344
394
  value = parse(raw, expected_type, elements)
345
395
  if not _refused.is_empty():
346
396
  return [false, _refused]
347
- var refusal: String = cannot_hold(target.get_class(), property, value, expected_type, elements)
397
+ var refusal: String = cannot_hold(class_called(target), property, value, expected_type, elements)
348
398
  if not refusal.is_empty():
349
399
  return [false, refusal]
350
400
  if value is Object:
@@ -489,10 +539,43 @@ func _fraction_refusal(target: Object, property: String, raw: Variant) -> String
489
539
  "%s.%s holds whole numbers, and %s would be cut to fit. Setting it would save a different "
490
540
  + "value than the one asked for."
491
541
  )
492
- % [target.get_class(), property, shown(raw)]
542
+ % [class_called(target), property, shown(raw)]
493
543
  )
494
544
 
495
545
 
546
+ ## [param raw] as a colour, read as a property write reads one, as [true, Color] or [false, why]:
547
+ ## fields from 0 to 1 with a optional, or an html code. [param named] is what the refusal calls it.
548
+ func colour_from(raw: Variant, named: String) -> Array:
549
+ _refused = ""
550
+ var value: Variant = parse(raw, TYPE_COLOR)
551
+ if not _refused.is_empty():
552
+ return [false, _refused]
553
+ if value is Color:
554
+ return [true, value]
555
+ return [
556
+ false,
557
+ (
558
+ (
559
+ '%s is {"r": .., "g": .., "b": .., "a": ..} from 0 to 1, a optional, or an html code such as '
560
+ + '"#ff8000" or "#ff800080"; %s is neither.'
561
+ )
562
+ % [named, JSON.stringify(raw)]
563
+ )
564
+ ]
565
+
566
+
567
+ ## What [param target] is called in a refusal: the class_name its script declares, or else its
568
+ ## engine class. A resource made as a project class is a Resource to the engine, and "Resource.kind"
569
+ ## named a property no Resource has.
570
+ static func class_called(target: Object) -> String:
571
+ var attached: Variant = target.get_script()
572
+ if attached is Script:
573
+ var script: Script = attached
574
+ if not str(script.get_global_name()).is_empty():
575
+ return str(script.get_global_name())
576
+ return target.get_class()
577
+
578
+
496
579
  ## The resource [param path] names, as [found, resource or why not].
497
580
  ##
498
581
  ## The project boundary is enforced here as well as on the server, because only the engine knows
@@ -516,6 +599,19 @@ func _resource_at(path: String, property: String) -> Array:
516
599
  )
517
600
  ]
518
601
  if not ResourceLoader.exists(path):
602
+ # An image or a sound nobody has imported is a file the engine has no loader for, and "No
603
+ # resource at" sent a caller looking for a file that was there.
604
+ if path.begins_with("res://") and FileAccess.file_exists(path):
605
+ return [
606
+ false,
607
+ (
608
+ (
609
+ "%s is there, but it has never been imported, so the engine has no resource to load"
610
+ + " from it for %s. project_import reimport with resourcePath %s imports it."
611
+ )
612
+ % [path, property, path]
613
+ )
614
+ ]
519
615
  return [false, "No resource at %s for %s" % [path, property]]
520
616
  return [true, load(path)]
521
617
 
@@ -164,6 +164,43 @@ static func not_of_kind(
164
164
  return told + " The scene's %ss are at %s." % [kind, shown]
165
165
 
166
166
 
167
+ ## Up to three of [param names] closest to [param word], closest first, ignoring case: the names a
168
+ ## refusal offers for one that is not there, as a typo usually is one of them.
169
+ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray:
170
+ var wanted: String = word.to_lower()
171
+ var close: Array[String] = []
172
+ for name: String in names:
173
+ if name.to_lower().similarity(wanted) >= 0.5:
174
+ close.append(name)
175
+ close.sort_custom(
176
+ func(a: String, b: String) -> bool:
177
+ return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
178
+ )
179
+ return PackedStringArray(close.slice(0, 3))
180
+
181
+
182
+ ## [param said], the refusal for [param word], which is not among [param names], with those nearest
183
+ ## it when any are, or else every one of them, so the caller has a name to send instead.
184
+ static func offered(said: String, word: String, names: Array) -> String:
185
+ var every: PackedStringArray = PackedStringArray(names)
186
+ var close: PackedStringArray = nearest(word, every)
187
+ if close.is_empty():
188
+ return "%s; it has %s" % [said, ", ".join(every)]
189
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
190
+
191
+
192
+ ## The signals [param object] has that a caller can connect, its own class's first: the engine
193
+ ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
194
+ ## starting with an underscore are the engine's own.
195
+ static func signals_of(object: Object) -> Array:
196
+ var names: Array = []
197
+ for entry: Dictionary in object.get_signal_list():
198
+ var name: String = str(entry["name"])
199
+ if not name.begins_with("_"):
200
+ names.push_front(name)
201
+ return names
202
+
203
+
167
204
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
168
205
  static func with_article(word: String) -> String:
169
206
  return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
@@ -1,5 +1,6 @@
1
1
  extends RefCounted
2
2
 
3
+ const GdhFileWalk = preload("file_walk.gd")
3
4
  const GdhLog = preload("logger.gd")
4
5
  const GdhPatterns = preload("patterns.gd")
5
6
 
@@ -29,7 +30,9 @@ func get_uid(params: Dictionary) -> Dictionary:
29
30
 
30
31
  var absolute_path: String = ProjectSettings.globalize_path(file_path)
31
32
  if not FileAccess.file_exists(file_path):
32
- return _log.failure("File does not exist: " + file_path)
33
+ return _log.failure(
34
+ GdhFileWalk.new().with_nearest_files("File does not exist: " + file_path, file_path)
35
+ )
33
36
 
34
37
  var answer: Dictionary = {"file": file_path, "absolute_path": absolute_path}
35
38
  var found: Dictionary = uid_of(file_path)
@@ -54,6 +54,22 @@ func _init(p_log: GdhLog) -> void:
54
54
  _log = p_log
55
55
 
56
56
 
57
+ ## One line for [param failure], a script [method check_scripts] answered as failing, with its
58
+ ## first error and the note the server put on it.
59
+ static func failure_line(failure: Dictionary) -> String:
60
+ var errors: Array = failure.get("errors", [])
61
+ var said: String = "%s does not compile" % str(failure.get("path", ""))
62
+ if not errors.is_empty():
63
+ var first: Dictionary = errors[0]
64
+ var line: int = GdhRead.as_int(first.get("line", 0))
65
+ var message: String = str(first.get("message", ""))
66
+ said += ": line %d, %s" % [line, message] if line > 0 else ": " + message
67
+ if errors.size() > 1:
68
+ said += " (and %d more)" % (errors.size() - 1)
69
+ var note: String = str(failure.get("note", ""))
70
+ return said if note.is_empty() else "%s %s" % [said, note]
71
+
72
+
57
73
  ## Whether the scripts in [code]paths[/code] compile in a fresh engine, which reads the class cache
58
74
  ## as it starts and so resolves every class the cache lists, up to and including the first that
59
75
  ## raised an error. [code]checked[/code] names the scripts read, in order, and [code]failed[/code]
@@ -410,6 +410,10 @@ func fitted(value: Variant, type: int) -> Variant:
410
410
  var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
411
411
  if not simple.has(type) or not simple.has(typeof(rebuilt)):
412
412
  return rebuilt
413
+ # A number or a truth value stays as sent where text is wanted, for the refusal to name. Converted,
414
+ # 1 arrived as the double it is in JSON and was saved as "1.0", which is not the text anyone sends.
415
+ if type == TYPE_STRING and not rebuilt is String:
416
+ return rebuilt
413
417
 
414
418
  # Digits are read as digits, since read as JSON they become a double and one past 2^53 a
415
419
  # neighbour of the number written.
@@ -82,6 +82,13 @@ static func spare(fields: Dictionary, tag: String) -> Array:
82
82
  ##
83
83
  ## The refusal names only the two types, and the key is what the caller has to take out.
84
84
  static func remedy(value: Variant, type: int) -> String:
85
+ if type == TYPE_STRING and typeof(value) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]:
86
+ var number: Variant = value
87
+ if value is float:
88
+ var whole: float = value
89
+ if is_finite(whole) and whole == floorf(whole):
90
+ number = int(whole)
91
+ return ' Text is sent in quotes, as "%s".' % str(number)
85
92
  var shape: String = written_as(type)
86
93
  if shape.is_empty():
87
94
  return ""
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gdharness",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
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",