gd-tools-cli 0.7.0__tar.gz → 0.8.0__tar.gz

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 (88) hide show
  1. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/PKG-INFO +31 -5
  2. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/README.md +29 -4
  3. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/pyproject.toml +7 -2
  4. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-coverage/coverage.gd +185 -22
  5. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_native_coverage.gd +220 -24
  6. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_test_preflight.gd +2 -2
  7. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_test_runner.gd +12 -3
  8. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/changes.py +82 -0
  9. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/cli.py +174 -13
  10. gd_tools_cli-0.8.0/src/gd_tools/coverage/html_reporter.py +283 -0
  11. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/orchestrator.py +125 -1
  12. gd_tools_cli-0.8.0/src/gd_tools/coverage/patch.py +350 -0
  13. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/plan_generator.py +260 -44
  14. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/reporter.py +21 -8
  15. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/templates/file.html +40 -3
  16. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/templates/index.html +58 -6
  17. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/lint_runner.py +98 -3
  18. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/artifacts.py +9 -1
  19. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/command.py +47 -1
  20. gd_tools_cli-0.8.0/src/gd_tools/native_test/flaky.py +63 -0
  21. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/orchestrator.py +142 -15
  22. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/protocol.py +6 -4
  23. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/test_results.py +67 -0
  24. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/session.py +2 -0
  25. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools_cli.egg-info/PKG-INFO +31 -5
  26. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools_cli.egg-info/SOURCES.txt +2 -0
  27. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools_cli.egg-info/requires.txt +1 -0
  28. gd_tools_cli-0.7.0/src/gd_tools/coverage/html_reporter.py +0 -142
  29. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/setup.cfg +0 -0
  30. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/__init__.py +0 -0
  31. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/__main__.py +0 -0
  32. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addon_check.py +0 -0
  33. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/__init__.py +0 -0
  34. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-editor/coverage_overlay.gd +0 -0
  35. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-editor/dock.gd +0 -0
  36. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-editor/plugin.cfg +0 -0
  37. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-editor/plugin.gd +0 -0
  38. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_mock.gd +0 -0
  39. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_parameter_naming.gd +0 -0
  40. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_snapshot_serializer.gd +0 -0
  41. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_snapshot_store.gd +0 -0
  42. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_test.gd +0 -0
  43. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/addons/gd-tools-test/gd_tools_test_context.gd +0 -0
  44. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/atomic_io.py +0 -0
  45. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/clean.py +0 -0
  46. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/config.py +0 -0
  47. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/__init__.py +0 -0
  48. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/cobertura_reporter.py +0 -0
  49. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/diff_reporter.py +0 -0
  50. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/lcov_reporter.py +0 -0
  51. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/omissions.py +0 -0
  52. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/playtest.py +0 -0
  53. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/coverage/terminal_reporter.py +0 -0
  54. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/doctor.py +0 -0
  55. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/errors.py +0 -0
  56. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/file_discovery.py +0 -0
  57. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/format_runner.py +0 -0
  58. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/gh_annotations.py +0 -0
  59. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/godot.py +0 -0
  60. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/init.py +0 -0
  61. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/__init__.py +0 -0
  62. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/apply.py +0 -0
  63. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/gutconfig.py +0 -0
  64. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/reporter.py +0 -0
  65. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/rewrite.py +0 -0
  66. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/scan.py +0 -0
  67. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/migration/translate.py +0 -0
  68. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/__init__.py +0 -0
  69. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/bridge_scan.py +0 -0
  70. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/discovery.py +0 -0
  71. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/import_cache.py +0 -0
  72. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/preflight.py +0 -0
  73. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/native_test/preflight_cache.py +0 -0
  74. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/output.py +0 -0
  75. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/pre_commit.py +0 -0
  76. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/schema.py +0 -0
  77. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/update_check.py +0 -0
  78. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/verbosity.py +0 -0
  79. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/version.py +0 -0
  80. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/__init__.py +0 -0
  81. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/coalescer.py +0 -0
  82. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/loop.py +0 -0
  83. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/mapping.py +0 -0
  84. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/observer.py +0 -0
  85. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools/watch/scope.py +0 -0
  86. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools_cli.egg-info/dependency_links.txt +0 -0
  87. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools_cli.egg-info/entry_points.txt +0 -0
  88. {gd_tools_cli-0.7.0 → gd_tools_cli-0.8.0}/src/gd_tools_cli.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gd-tools-cli
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: A modern development workflow CLI for GDScript projects in Godot 4.5+
5
5
  Author-email: mansyar <m.ansyarafi@gmail.com>
6
6
  License: MIT
@@ -35,6 +35,7 @@ Requires-Dist: watchdog
35
35
  Provides-Extra: dev
36
36
  Requires-Dist: pytest; extra == "dev"
37
37
  Requires-Dist: pytest-cov; extra == "dev"
38
+ Requires-Dist: pytest-xdist; extra == "dev"
38
39
  Requires-Dist: ruff==0.15.21; extra == "dev"
39
40
  Requires-Dist: black; extra == "dev"
40
41
  Requires-Dist: commitizen; extra == "dev"
@@ -77,7 +78,7 @@ One install, one config, one mental model.
77
78
  | **Native test runtime** | Tests extend `GdToolsTest` and run inside Godot itself. Scene and resource integration tests reach the real scene tree through an explicit context object rather than a proxy. Doubles, partial doubles, and stubbing isolate collaborators; spy assertions cover calls, argument wildcards, call order, and property values. |
78
79
  | **Snapshot testing** | `assert_snapshot(value)` pins rendered values -- including object property dumps and node trees -- against versioned, diffable `.snap` files. First run writes and passes; mismatches fail with a unified diff, and `--snapshot-update` accepts the new output. |
79
80
  | **Zero-friction bootstrap** | `gd-tools init` gets a project fully set up in under a minute -- native test and coverage addons deployed, configs generated. |
80
- | **Coverage gap-filling** | Production-quality line and branch coverage for GDScript -- HTML, LCOV, Cobertura, and JSON reports, plus GitHub Actions annotations that surface failures inline on pull requests. Independent line (`--min`) and branch (`--min-branch`) gates. `# gd-tools: no cover` annotations exclude debug-only code from the numbers. `coverage run` collects coverage during manual playtest sessions, not just automated tests. |
81
+ | **Coverage gap-filling** | Production-quality line and branch coverage for GDScript -- HTML, LCOV, Cobertura, and JSON reports, plus GitHub Actions annotations that surface failures inline on pull requests. Independent line (`--min`) and branch (`--min-branch`) gates. `# gd-tools: no cover` annotations exclude debug-only code from the numbers. `coverage run` collects coverage during manual playtest sessions, not just automated tests. Patch coverage (`coverage diff --patch`) reports coverage over only the lines a pull request changes, with its own gate and inline annotations. |
81
82
  | **CI/CD friendly** | Exit codes, `--check` flags, machine-readable output (JSON, JUnit XML, LCOV, Cobertura), and GitHub Actions annotations for lint violations and coverage gates. No interactive prompts in CI mode. |
82
83
  | **Standalone compatibility** | gdlint and gdformat continue to work if invoked directly. `gd-tools` is a layer on top, not a lock-in. |
83
84
 
@@ -141,11 +142,11 @@ func test_health_starts_at_full() -> void:
141
142
  |---------|-------------|
142
143
  | `gd-tools init` | Bootstrap a Godot project -- deploy the native test and coverage addons, generate configs. |
143
144
  | `gd-tools doctor` | Diagnose the development environment -- Godot, native test addon, coverage addon, tooling; reports legacy GUT artifacts as migration advice. |
144
- | `gd-tools test` | Run tests with optional coverage, thresholds, and JUnit XML output. Suites must extend `GdToolsTest`; `GutTest` suites are rejected with exit 2 and migration guidance. Accepts optional path arguments to override configured test directories. `--parallel N` runs suites through a bounded worker pool. `--changed` runs only the suites mapped from git-changed files. `--watch` re-runs affected suites on `.gd` file changes. `--durations N` reports the N slowest tests after the run. Every run publishes a machine-readable artifact index under `.gd-tools/artifacts/<run_id>/`. |
145
+ | `gd-tools test` | Run tests with optional coverage, thresholds, and JUnit XML output. Suites must extend `GdToolsTest`; `GutTest` suites are rejected with exit 2 and migration guidance. Accepts optional path arguments to override configured test directories. `--parallel N` runs suites through a bounded worker pool. `--changed` runs only the suites mapped from git-changed files. `--watch` re-runs affected suites on `.gd` file changes. `--durations N` reports the N slowest tests after the run. `--exitfirst` (`-x`) stops dispatching new suites after the first failing suite; `--shard K/N` runs one round-robin shard for CI matrix splitting. Every run publishes a machine-readable artifact index under `.gd-tools/artifacts/<run_id>/`. |
145
146
  | `gd-tools migrate` | Guided GUT-to-native migration. Default: read-only report with unsupported-construct inventory and proposed base-class rewrites. `--apply` renames clean suites to `GdToolsTest` and translates `.gutconfig.json` into `gd-tools.toml` (merge, never clobber). `--config-only` translates config only. |
146
147
  | `gd-tools lint` | Lint GDScript files using gdlint with text, JSON, or GitHub Actions annotation output. Accepts one or more file or directory paths. |
147
148
  | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. Accepts one or more file or directory paths. |
148
- | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`, `save-baseline`, `diff` (baseline comparison for CI regression gates), and `run` (collect coverage during a manual playtest session). |
149
+ | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`, `save-baseline`, `diff` (baseline comparison for CI regression gates; with `--patch`, PR-scoped coverage over changed lines), and `run` (collect coverage during a manual playtest session). |
149
150
  | `gd-tools config` | Configuration management -- `show` (display resolved config), `validate` (check config validity), `schema` (print or write the JSON Schema for `gd-tools.toml`). |
150
151
  | `gd-tools version` | Display versions of all gd-tools components (gd-tools, Godot, gdtoolkit, Python) in a table or JSON. |
151
152
  | `gd-tools completion` | Generate shell completion scripts for bash, zsh, fish, or PowerShell. |
@@ -162,6 +163,8 @@ gd-tools test --parallel 4 # run suites with 4 concurrent worker
162
163
  gd-tools test --durations 10 # report the 10 slowest tests
163
164
  gd-tools test --changed # only suites mapped from git-changed files
164
165
  gd-tools test --changed --base main # PR/CI mode: diff from merge-base with main
166
+ gd-tools test --exitfirst # stop dispatching after the first failing suite
167
+ gd-tools test --shard 2/4 # CI sharding: run suite shard 2 of 4
165
168
  ```
166
169
 
167
170
  `--tag` is repeatable, and `--test-timeout` (per test) is separate from
@@ -190,6 +193,29 @@ pull-request CI. `--changed` composes with `--parallel`, `--coverage`, and
190
193
  the `--suite`/`--test`/`--tag` filters (the active filters bound the
191
194
  mapping domain); an empty change set exits 0 without launching Godot.
192
195
 
196
+ `--exitfirst` (short form `-x`) stops dispatching **new** suites after the
197
+ first suite whose final result is a failure — a test failure or an
198
+ infrastructure error. In-flight suites finish and are collected; suites that
199
+ were never started are reported as skipped (`not_run`) and summarized in a
200
+ `Stopped early: fail-fast after suite <id> (K of M suites skipped)` line.
201
+ Passes and skips never trigger the gate, and a suite that passes after a
202
+ retry does not either — only final suite results decide. The exit code stays
203
+ 1 for test failures. `--exitfirst` composes with `--parallel` (the pool
204
+ drains instead of killing workers), with `--coverage` (the report covers the
205
+ suites that ran, labeled partial when plan targets went unmeasured), and with
206
+ `--watch` (the gate resets for every re-run).
207
+
208
+ `--shard K/N` runs only the suites assigned to shard `K` of `N` — suite *i*
209
+ of the deterministic plan order belongs to shard `(i mod N) + 1` — for
210
+ splitting one test run across CI jobs. Selection happens after `--changed`
211
+ filtering and before `--parallel`, so each flag keeps exactly one job.
212
+ `--shard 1/1` is valid and equivalent to no sharding; malformed values
213
+ (`--shard 4/3`, `--shard 3`) exit 2 before any work. Each shard produces its
214
+ own coverage report; merge the shards with `gd-tools coverage merge`.
215
+ `--shard` and `--watch` are rejected together (exit 2). See the
216
+ [sharding recipe](./docs/USER_GUIDE.md#sharding-a-test-run-across-ci-jobs)
217
+ for a GitHub Actions matrix example.
218
+
193
219
  Repeat runs are faster: the `godot --headless --import` step is cached under
194
220
  `.gd-tools/native/import-cache/` and skipped when `project.godot`, any
195
221
  project source/resource file (scripts, scenes, import sidecars, addons), or
@@ -378,7 +404,7 @@ prefix = "test_"
378
404
  suffix = ".gd"
379
405
  runtime = "native" # native (default); "gut" is no longer a runnable runtime
380
406
  timeout_seconds = 5.0 # default per-test timeout for async native tests
381
- retries = 0 # opt-in retries per native test
407
+ retries = 0 # opt-in retries per native test; retried passes are flagged as flaky
382
408
  # parallel = 4 # opt-in parallel suite execution (1-32; unset runs sequentially)
383
409
  tags = [] # native suite tag filters
384
410
 
@@ -36,7 +36,7 @@ One install, one config, one mental model.
36
36
  | **Native test runtime** | Tests extend `GdToolsTest` and run inside Godot itself. Scene and resource integration tests reach the real scene tree through an explicit context object rather than a proxy. Doubles, partial doubles, and stubbing isolate collaborators; spy assertions cover calls, argument wildcards, call order, and property values. |
37
37
  | **Snapshot testing** | `assert_snapshot(value)` pins rendered values -- including object property dumps and node trees -- against versioned, diffable `.snap` files. First run writes and passes; mismatches fail with a unified diff, and `--snapshot-update` accepts the new output. |
38
38
  | **Zero-friction bootstrap** | `gd-tools init` gets a project fully set up in under a minute -- native test and coverage addons deployed, configs generated. |
39
- | **Coverage gap-filling** | Production-quality line and branch coverage for GDScript -- HTML, LCOV, Cobertura, and JSON reports, plus GitHub Actions annotations that surface failures inline on pull requests. Independent line (`--min`) and branch (`--min-branch`) gates. `# gd-tools: no cover` annotations exclude debug-only code from the numbers. `coverage run` collects coverage during manual playtest sessions, not just automated tests. |
39
+ | **Coverage gap-filling** | Production-quality line and branch coverage for GDScript -- HTML, LCOV, Cobertura, and JSON reports, plus GitHub Actions annotations that surface failures inline on pull requests. Independent line (`--min`) and branch (`--min-branch`) gates. `# gd-tools: no cover` annotations exclude debug-only code from the numbers. `coverage run` collects coverage during manual playtest sessions, not just automated tests. Patch coverage (`coverage diff --patch`) reports coverage over only the lines a pull request changes, with its own gate and inline annotations. |
40
40
  | **CI/CD friendly** | Exit codes, `--check` flags, machine-readable output (JSON, JUnit XML, LCOV, Cobertura), and GitHub Actions annotations for lint violations and coverage gates. No interactive prompts in CI mode. |
41
41
  | **Standalone compatibility** | gdlint and gdformat continue to work if invoked directly. `gd-tools` is a layer on top, not a lock-in. |
42
42
 
@@ -100,11 +100,11 @@ func test_health_starts_at_full() -> void:
100
100
  |---------|-------------|
101
101
  | `gd-tools init` | Bootstrap a Godot project -- deploy the native test and coverage addons, generate configs. |
102
102
  | `gd-tools doctor` | Diagnose the development environment -- Godot, native test addon, coverage addon, tooling; reports legacy GUT artifacts as migration advice. |
103
- | `gd-tools test` | Run tests with optional coverage, thresholds, and JUnit XML output. Suites must extend `GdToolsTest`; `GutTest` suites are rejected with exit 2 and migration guidance. Accepts optional path arguments to override configured test directories. `--parallel N` runs suites through a bounded worker pool. `--changed` runs only the suites mapped from git-changed files. `--watch` re-runs affected suites on `.gd` file changes. `--durations N` reports the N slowest tests after the run. Every run publishes a machine-readable artifact index under `.gd-tools/artifacts/<run_id>/`. |
103
+ | `gd-tools test` | Run tests with optional coverage, thresholds, and JUnit XML output. Suites must extend `GdToolsTest`; `GutTest` suites are rejected with exit 2 and migration guidance. Accepts optional path arguments to override configured test directories. `--parallel N` runs suites through a bounded worker pool. `--changed` runs only the suites mapped from git-changed files. `--watch` re-runs affected suites on `.gd` file changes. `--durations N` reports the N slowest tests after the run. `--exitfirst` (`-x`) stops dispatching new suites after the first failing suite; `--shard K/N` runs one round-robin shard for CI matrix splitting. Every run publishes a machine-readable artifact index under `.gd-tools/artifacts/<run_id>/`. |
104
104
  | `gd-tools migrate` | Guided GUT-to-native migration. Default: read-only report with unsupported-construct inventory and proposed base-class rewrites. `--apply` renames clean suites to `GdToolsTest` and translates `.gutconfig.json` into `gd-tools.toml` (merge, never clobber). `--config-only` translates config only. |
105
105
  | `gd-tools lint` | Lint GDScript files using gdlint with text, JSON, or GitHub Actions annotation output. Accepts one or more file or directory paths. |
106
106
  | `gd-tools format` | Format GDScript files using gdformat with check and diff modes. Accepts one or more file or directory paths. |
107
- | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`, `save-baseline`, `diff` (baseline comparison for CI regression gates), and `run` (collect coverage during a manual playtest session). |
107
+ | `gd-tools coverage` | Coverage subcommands -- `report`, `merge`, `show`, `save-baseline`, `diff` (baseline comparison for CI regression gates; with `--patch`, PR-scoped coverage over changed lines), and `run` (collect coverage during a manual playtest session). |
108
108
  | `gd-tools config` | Configuration management -- `show` (display resolved config), `validate` (check config validity), `schema` (print or write the JSON Schema for `gd-tools.toml`). |
109
109
  | `gd-tools version` | Display versions of all gd-tools components (gd-tools, Godot, gdtoolkit, Python) in a table or JSON. |
110
110
  | `gd-tools completion` | Generate shell completion scripts for bash, zsh, fish, or PowerShell. |
@@ -121,6 +121,8 @@ gd-tools test --parallel 4 # run suites with 4 concurrent worker
121
121
  gd-tools test --durations 10 # report the 10 slowest tests
122
122
  gd-tools test --changed # only suites mapped from git-changed files
123
123
  gd-tools test --changed --base main # PR/CI mode: diff from merge-base with main
124
+ gd-tools test --exitfirst # stop dispatching after the first failing suite
125
+ gd-tools test --shard 2/4 # CI sharding: run suite shard 2 of 4
124
126
  ```
125
127
 
126
128
  `--tag` is repeatable, and `--test-timeout` (per test) is separate from
@@ -149,6 +151,29 @@ pull-request CI. `--changed` composes with `--parallel`, `--coverage`, and
149
151
  the `--suite`/`--test`/`--tag` filters (the active filters bound the
150
152
  mapping domain); an empty change set exits 0 without launching Godot.
151
153
 
154
+ `--exitfirst` (short form `-x`) stops dispatching **new** suites after the
155
+ first suite whose final result is a failure — a test failure or an
156
+ infrastructure error. In-flight suites finish and are collected; suites that
157
+ were never started are reported as skipped (`not_run`) and summarized in a
158
+ `Stopped early: fail-fast after suite <id> (K of M suites skipped)` line.
159
+ Passes and skips never trigger the gate, and a suite that passes after a
160
+ retry does not either — only final suite results decide. The exit code stays
161
+ 1 for test failures. `--exitfirst` composes with `--parallel` (the pool
162
+ drains instead of killing workers), with `--coverage` (the report covers the
163
+ suites that ran, labeled partial when plan targets went unmeasured), and with
164
+ `--watch` (the gate resets for every re-run).
165
+
166
+ `--shard K/N` runs only the suites assigned to shard `K` of `N` — suite *i*
167
+ of the deterministic plan order belongs to shard `(i mod N) + 1` — for
168
+ splitting one test run across CI jobs. Selection happens after `--changed`
169
+ filtering and before `--parallel`, so each flag keeps exactly one job.
170
+ `--shard 1/1` is valid and equivalent to no sharding; malformed values
171
+ (`--shard 4/3`, `--shard 3`) exit 2 before any work. Each shard produces its
172
+ own coverage report; merge the shards with `gd-tools coverage merge`.
173
+ `--shard` and `--watch` are rejected together (exit 2). See the
174
+ [sharding recipe](./docs/USER_GUIDE.md#sharding-a-test-run-across-ci-jobs)
175
+ for a GitHub Actions matrix example.
176
+
152
177
  Repeat runs are faster: the `godot --headless --import` step is cached under
153
178
  `.gd-tools/native/import-cache/` and skipped when `project.godot`, any
154
179
  project source/resource file (scripts, scenes, import sidecars, addons), or
@@ -337,7 +362,7 @@ prefix = "test_"
337
362
  suffix = ".gd"
338
363
  runtime = "native" # native (default); "gut" is no longer a runnable runtime
339
364
  timeout_seconds = 5.0 # default per-test timeout for async native tests
340
- retries = 0 # opt-in retries per native test
365
+ retries = 0 # opt-in retries per native test; retried passes are flagged as flaky
341
366
  # parallel = 4 # opt-in parallel suite execution (1-32; unset runs sequentially)
342
367
  tags = [] # native suite tag filters
343
368
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gd-tools-cli"
7
- version = "0.7.0"
7
+ version = "0.8.0"
8
8
  description = "A modern development workflow CLI for GDScript projects in Godot 4.5+"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -49,6 +49,7 @@ Documentation = "https://github.com/mansyar/gd-tools#readme"
49
49
  dev = [
50
50
  "pytest",
51
51
  "pytest-cov",
52
+ "pytest-xdist",
52
53
  "ruff==0.15.21",
53
54
  "black",
54
55
  "commitizen",
@@ -71,6 +72,10 @@ where = ["src"]
71
72
 
72
73
  [tool.pytest.ini_options]
73
74
  testpaths = ["tests"]
75
+ # Import gd_tools from this checkout's src/ rather than any globally
76
+ # installed (editable) copy, so worktrees and CI checkouts always test
77
+ # their own tree.
78
+ pythonpath = ["src"]
74
79
  markers = [
75
80
  "unit: Unit tests for individual modules.",
76
81
  "integration: Integration tests for command flows.",
@@ -105,7 +110,7 @@ target-version = ["py310"]
105
110
 
106
111
  [tool.commitizen]
107
112
  name = "cz_conventional_commits"
108
- version = "0.7.0"
113
+ version = "0.8.0"
109
114
  version_files = ["pyproject.toml:version"]
110
115
  changelog_file = "CHANGELOG.md"
111
116
  tag_format = "v$version"
@@ -17,6 +17,28 @@ extends Node
17
17
 
18
18
  const TRACKER_NAME = "_GDTCoverage"
19
19
 
20
+ ## Every branch_type this collector knows how to measure. A plan that
21
+ ## carries anything else was produced by a newer plan generator than
22
+ ## this collector implements; instrumenting it would silently
23
+ ## mis-measure the unknown arms, so the plan is rejected instead.
24
+ const KNOWN_BRANCH_TYPES: Array[String] = [
25
+ "if_true",
26
+ "elif_true",
27
+ "if_false",
28
+ "loop_body",
29
+ "match_case",
30
+ "ternary_true",
31
+ "ternary_false",
32
+ "and_site",
33
+ "and_right",
34
+ "and_short",
35
+ "or_site",
36
+ "or_right",
37
+ "or_short",
38
+ "assert_true",
39
+ "assert_false",
40
+ ]
41
+
20
42
  const PLAYTEST_ENV = "GD_TOOLS_COVERAGE_PLAYTEST"
21
43
 
22
44
  const PLAYTEST_INTERVAL_ENV = "GD_TOOLS_COVERAGE_PLAYTEST_INTERVAL"
@@ -24,6 +46,7 @@ const PLAYTEST_INTERVAL_ENV = "GD_TOOLS_COVERAGE_PLAYTEST_INTERVAL"
24
46
  const DEFAULT_PLAYTEST_INTERVAL: float = 5.0
25
47
 
26
48
  var _hits: Dictionary = {}
49
+ var _derived: Dictionary = {}
27
50
 
28
51
  var _active: bool = false
29
52
 
@@ -83,12 +106,24 @@ func hit(file_id: int, line_id: int) -> void:
83
106
  func hit_ret(file_id: int, line_id: int, value: Variant) -> Variant:
84
107
  ## Record one instrumented hit and pass the tracked value through.
85
108
  ##
86
- ## Injected around ternary operands so each arm is measured exactly
87
- ## when it evaluates, without re-evaluating the condition.
109
+ ## Injected around ternary operands and boolean-operator sites/right
110
+ ## operands so each arm is measured exactly when it evaluates, without
111
+ ## re-evaluating the condition.
88
112
  hit(file_id, line_id)
89
113
  return value
90
114
 
91
115
 
116
+ func hit_bool(file_id: int, true_id: int, false_id: int, value: Variant) -> Variant:
117
+ ## Record exactly one boolean-arm hit and pass the value through.
118
+ ##
119
+ ## Injected around assert conditions: the condition's truth decides
120
+ ## which of the two arms is recorded, and the value continues into
121
+ ## the assert itself. One wrapper call therefore records exactly one
122
+ ## of assert_true / assert_false per evaluation.
123
+ hit(file_id, true_id if value else false_id)
124
+ return value
125
+
126
+
92
127
  func get_hits() -> Dictionary:
93
128
  return _hits
94
129
 
@@ -100,6 +135,7 @@ func get_omitted() -> Array:
100
135
 
101
136
  func reset() -> void:
102
137
  _hits.clear()
138
+ _derived.clear()
103
139
  _omitted.clear()
104
140
 
105
141
 
@@ -194,6 +230,7 @@ func _build_coverage_json(hits: Dictionary, omitted: Array = []) -> Dictionary:
194
230
  # Matches the post-run hook output contract exactly so every
195
231
  # reporter consumes playtest sessions unchanged.
196
232
  var files: Array = []
233
+ _derive_short_hits(hits)
197
234
  for file_id in hits:
198
235
  var file_hits: Dictionary = hits[file_id]
199
236
  var hits_dict: Dictionary = {}
@@ -318,9 +355,57 @@ func _instrument_file(file_entry: Dictionary) -> bool:
318
355
  # change to any percentage.
319
356
  if not _hits.has(file_id):
320
357
  _hits[file_id] = {}
358
+ _derive_short_arms(file_id, lines)
321
359
  return true
322
360
 
323
361
 
362
+ func _derive_short_arms(file_id: int, lines: Array) -> void:
363
+ ## Pair each ``<op>_short`` point with its operator's site and right
364
+ ## point ids so coverage aggregation can derive short-circuit hits.
365
+ ##
366
+ ## Short-circuit arms have no span of their own: the collector records
367
+ ## the site whenever the whole expression evaluates and the right arm
368
+ ## whenever the right operand evaluates, so ``short = site - right``
369
+ ## counts the evaluations the operator skipped. The plan emits each
370
+ ## right operand immediately followed by its short arm, so pairing by
371
+ ## most recent same-operator right is exact, including for chains.
372
+ var site_by_operator: Dictionary = {}
373
+ var last_right: Dictionary = {}
374
+ var pairs: Dictionary = {}
375
+ for entry in lines:
376
+ var branch_type := str(entry.get("branch_type", ""))
377
+ if branch_type.ends_with("_site"):
378
+ site_by_operator[branch_type.trim_suffix("_site")] = int(entry["id"])
379
+ elif branch_type.ends_with("_right"):
380
+ last_right[branch_type.trim_suffix("_right")] = int(entry["id"])
381
+ elif branch_type.ends_with("_short"):
382
+ var operator := branch_type.trim_suffix("_short")
383
+ if site_by_operator.has(operator) and last_right.has(operator):
384
+ pairs[int(entry["id"])] = [
385
+ site_by_operator[operator],
386
+ last_right[operator],
387
+ ]
388
+ if not pairs.is_empty():
389
+ _derived[file_id] = pairs
390
+
391
+
392
+ func _derive_short_hits(hits: Dictionary) -> void:
393
+ ## Fold derived short-circuit arm hits into the recorded hit counts.
394
+ ##
395
+ ## ``short = site - right`` clamped at zero: a negative result would
396
+ ## mean the wrap double-fired, which is a defect, not a measurement.
397
+ for file_id in _derived:
398
+ if not hits.has(file_id):
399
+ continue
400
+ var pairs: Dictionary = _derived[file_id]
401
+ for short_id in pairs:
402
+ var site_hits := int(hits[file_id].get(pairs[short_id][0], 0))
403
+ var right_hits := int(hits[file_id].get(pairs[short_id][1], 0))
404
+ var short_hits := maxi(0, site_hits - right_hits)
405
+ if short_hits > 0:
406
+ hits[file_id][short_id] = short_hits
407
+
408
+
324
409
  static func _extract_indent(line: String) -> String:
325
410
  var indent: String = ""
326
411
  for c in line:
@@ -345,17 +430,22 @@ static func _detect_body_indent(source_lines: PackedStringArray, pattern_index:
345
430
 
346
431
 
347
432
  static func _inject_trackers(source: String, file_id: int, lines: Array) -> String:
348
- var wrapped := _wrap_ternary_operands(source, file_id, lines)
433
+ var wrapped := _wrap_expression_operands(source, file_id, lines)
349
434
  var source_lines: PackedStringArray = wrapped.split("\n")
350
435
  var sorted_lines: Array = lines.duplicate(true)
351
436
  sorted_lines.sort_custom(func(a, b): return int(a["line"]) > int(b["line"]))
352
437
  for entry in sorted_lines:
353
- # Ternary arms are instrumented by wrapping their operand text
354
- # (see _wrap_ternary_operands); a line-inserted hit() on the
355
- # shared anchor line fires both arms in lockstep and can never
356
- # report an uncovered arm.
438
+ # Ternary and boolean-operator arms are instrumented by wrapping
439
+ # their operand text (see _wrap_expression_operands); a
440
+ # line-inserted hit() on the shared anchor line fires both arms
441
+ # in lockstep and can never report an uncovered arm.
357
442
  if entry.get("operand_span") != null:
358
443
  continue
444
+ # Short-circuit arms are derived at aggregation as site - right; a
445
+ # line-inserted hit() would fire whenever the statement runs
446
+ # rather than when the operator skipped its right operand.
447
+ if str(entry.get("branch_type", "")).ends_with("_short"):
448
+ continue
359
449
  var target_line_num: int = int(entry["line"])
360
450
  var line_id: int = int(entry["id"])
361
451
  var target_index: int = target_line_num - 1
@@ -398,26 +488,60 @@ static func _offset_of(source: String, line: int, col: int) -> int:
398
488
  return target
399
489
 
400
490
 
401
- static func _wrap_ternary_operands(source: String, file_id: int, lines: Array) -> String:
402
- ## Replace each ternary operand with a value-preserving tracker call.
491
+ static func _wrap_expression_operands(source: String, file_id: int, lines: Array) -> String:
492
+ ## Replace each tracked operand with a value-preserving tracker call.
493
+ ##
494
+ ## The plan records the source span of every ternary arm operand and
495
+ ## boolean-operator site/right operand. Wrapping the operand records
496
+ ## the arm's hit exactly when that operand evaluates, keeping values,
497
+ ## evaluation order, and single evaluation intact. Line numbers are
498
+ ## unchanged: a wrapper never adds or removes a newline, so the
499
+ ## line-based insertion that follows stays valid.
403
500
  ##
404
- ## The plan records the source span of every ternary arm operand.
405
- ## Wrapping the operand records the arm's hit exactly when that arm
406
- ## evaluates, keeping values, evaluation order, and single evaluation
407
- ## intact. Line numbers are unchanged: a wrapper never adds or removes
408
- ## a newline, so the line-based insertion that follows stays valid.
501
+ ## Assert conditions carry two arm ids (assert_true/assert_false)
502
+ ## over one shared span: the pair becomes a single value-aware
503
+ ## hit_bool wrap that records exactly one arm per evaluation.
409
504
  var spans: Array = []
505
+ var assert_pairs: Dictionary = {}
410
506
  for entry in lines:
507
+ var branch_type := str(entry.get("branch_type", ""))
411
508
  var span: Variant = entry.get("operand_span")
412
509
  if span == null:
413
510
  continue
511
+ if branch_type == "assert_true" or branch_type == "assert_false":
512
+ var key := str(span)
513
+ if not assert_pairs.has(key):
514
+ assert_pairs[key] = {
515
+ "span": span,
516
+ "assert_true": -1,
517
+ "assert_false": -1,
518
+ }
519
+ assert_pairs[key][branch_type] = int(entry["id"])
520
+ continue
414
521
  var start := _offset_of(source, int(span[0]), int(span[1]))
415
522
  var end := _offset_of(source, int(span[2]), int(span[3]))
416
523
  if start < 0 or end < 0 or start >= end or end > source.length():
417
524
  # Fail open: an unusable span leaves the arm uninstrumented
418
525
  # rather than corrupting the source.
419
526
  continue
420
- spans.append({"start": start, "end": end, "id": int(entry["id"])})
527
+ spans.append({"start": start, "end": end, "kind": "ret", "id": int(entry["id"])})
528
+ for key in assert_pairs:
529
+ var pair: Dictionary = assert_pairs[key]
530
+ if int(pair["assert_true"]) < 0 or int(pair["assert_false"]) < 0:
531
+ # Fail open: an incomplete pair leaves the assert uninstrumented.
532
+ continue
533
+ var span: Array = pair["span"]
534
+ var start := _offset_of(source, int(span[0]), int(span[1]))
535
+ var end := _offset_of(source, int(span[2]), int(span[3]))
536
+ if start < 0 or end < 0 or start >= end or end > source.length():
537
+ continue
538
+ spans.append({
539
+ "start": start,
540
+ "end": end,
541
+ "kind": "bool",
542
+ "true_id": int(pair["assert_true"]),
543
+ "false_id": int(pair["assert_false"]),
544
+ })
421
545
  if spans.is_empty():
422
546
  return source
423
547
  spans.sort_custom(func(a, b):
@@ -460,12 +584,21 @@ static func _wrap_spans(
460
584
  j += 1
461
585
  result += source.substr(cursor, start - cursor)
462
586
  var operand := _wrap_spans(source, file_id, children, 0, start, end)
463
- result += "%s.hit_ret(%d, %d, %s)" % [
464
- TRACKER_NAME,
465
- file_id,
466
- int(span["id"]),
467
- operand,
468
- ]
587
+ if str(span.get("kind", "ret")) == "bool":
588
+ result += "%s.hit_bool(%d, %d, %d, %s)" % [
589
+ TRACKER_NAME,
590
+ file_id,
591
+ int(span["true_id"]),
592
+ int(span["false_id"]),
593
+ operand,
594
+ ]
595
+ else:
596
+ result += "%s.hit_ret(%d, %d, %s)" % [
597
+ TRACKER_NAME,
598
+ file_id,
599
+ int(span["id"]),
600
+ operand,
601
+ ]
469
602
  cursor = end
470
603
  i = j
471
604
  result += source.substr(cursor, to - cursor)
@@ -562,7 +695,11 @@ func _validate_file_entry(file_entry: Variant) -> bool:
562
695
  )
563
696
  return false
564
697
 
565
- for line_entry in file_entry["lines"]:
698
+ return _validate_line_entries(file_entry["lines"])
699
+
700
+
701
+ func _validate_line_entries(lines: Array) -> bool:
702
+ for line_entry in lines:
566
703
  if not (line_entry is Dictionary) or not line_entry.has("line") or not line_entry.has("id"):
567
704
  _log_error(
568
705
  "Invalid coverage plan structure.",
@@ -570,10 +707,36 @@ func _validate_file_entry(file_entry: Variant) -> bool:
570
707
  "Ensure each line entry has 'line' and 'id' fields."
571
708
  )
572
709
  return false
710
+ if not _validate_branch_type(line_entry):
711
+ return false
573
712
 
574
713
  return true
575
714
 
576
715
 
716
+ func _validate_branch_type(line_entry: Dictionary) -> bool:
717
+ ## Reject plans carrying branch types this collector cannot measure.
718
+ ##
719
+ ## Loud-failure handshake: instrumenting an unknown branch type would
720
+ ## silently mis-measure the unknown arms, so the plan is rejected
721
+ ## instead of reporting numbers that look trustworthy but are not.
722
+ var branch_type: Variant = line_entry.get("branch_type")
723
+ if branch_type == null:
724
+ return true
725
+ var type_name := str(branch_type)
726
+ if type_name.is_empty() or type_name in KNOWN_BRANCH_TYPES:
727
+ return true
728
+ _log_error(
729
+ "Unsupported coverage plan.",
730
+ (
731
+ "Branch type '%s' (point id %s) is not implemented by this "
732
+ + "collector."
733
+ ) % [type_name, str(line_entry.get("id", "?"))],
734
+ "Update the gd-tools addons so the collector matches the plan "
735
+ + "version."
736
+ )
737
+ return false
738
+
739
+
577
740
  func _log_error(what: String, cause: String, fix: String) -> void:
578
741
  push_error(
579
742
  "[gd-tools] [Error] " + what + "\n\n" + " Cause: " + cause + "\n" + " Fix: " + fix