roschema 0.2.1__tar.gz → 0.3.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 (122) hide show
  1. {roschema-0.2.1 → roschema-0.3.0}/.github/workflows/ci.yml +3 -3
  2. {roschema-0.2.1 → roschema-0.3.0}/.github/workflows/release.yml +1 -1
  3. {roschema-0.2.1 → roschema-0.3.0}/CHANGELOG.md +23 -0
  4. {roschema-0.2.1 → roschema-0.3.0}/PKG-INFO +37 -16
  5. {roschema-0.2.1 → roschema-0.3.0}/README.md +35 -15
  6. {roschema-0.2.1 → roschema-0.3.0}/action/action.yml +22 -2
  7. {roschema-0.2.1 → roschema-0.3.0}/pyproject.toml +2 -1
  8. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/cli.py +66 -19
  9. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/report/__init__.py +3 -1
  10. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/report/github.py +14 -3
  11. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/report/json_report.py +16 -5
  12. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/report/summary.py +37 -0
  13. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/report/text.py +8 -3
  14. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/golden_check.json +6 -0
  15. {roschema-0.2.1 → roschema-0.3.0}/tests/test_action.py +21 -5
  16. {roschema-0.2.1 → roschema-0.3.0}/tests/test_check_cli.py +61 -0
  17. {roschema-0.2.1 → roschema-0.3.0}/tests/test_golden.py +4 -5
  18. {roschema-0.2.1 → roschema-0.3.0}/tests/test_lint.py +8 -0
  19. {roschema-0.2.1 → roschema-0.3.0}/tests/test_mcap.py +28 -0
  20. {roschema-0.2.1 → roschema-0.3.0}/.gitignore +0 -0
  21. {roschema-0.2.1 → roschema-0.3.0}/CONTRIBUTING.md +0 -0
  22. {roschema-0.2.1 → roschema-0.3.0}/LICENSE +0 -0
  23. {roschema-0.2.1 → roschema-0.3.0}/docs/rules.md +0 -0
  24. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/__init__.py +0 -0
  25. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/config.py +0 -0
  26. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/deep.py +0 -0
  27. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/differ.py +0 -0
  28. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/discovery/__init__.py +0 -0
  29. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/discovery/discover.py +0 -0
  30. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/discovery/model.py +0 -0
  31. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/discovery/resolve.py +0 -0
  32. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/formatter.py +0 -0
  33. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/freeze.py +0 -0
  34. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/ir.py +0 -0
  35. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/loader.py +0 -0
  36. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/mcap_fs.py +0 -0
  37. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/parser/__init__.py +0 -0
  38. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/parser/core.py +0 -0
  39. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/parser/errors.py +0 -0
  40. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/py.typed +0 -0
  41. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/__init__.py +0 -0
  42. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/__init__.py +0 -0
  43. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs001_field_removed.py +0 -0
  44. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs002_field_added.py +0 -0
  45. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs003_field_type_changed.py +0 -0
  46. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs004_fields_reordered.py +0 -0
  47. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs005_field_renamed.py +0 -0
  48. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs006_array_kind_changed.py +0 -0
  49. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs007_bound_tightened.py +0 -0
  50. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs008_bound_loosened.py +0 -0
  51. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs020_constant_removed.py +0 -0
  52. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs021_constant_value_changed.py +0 -0
  53. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs022_constant_type_changed.py +0 -0
  54. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs030_default_changed.py +0 -0
  55. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs040_interface_removed.py +0 -0
  56. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs060_message_version.py +0 -0
  57. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/breaking/rs110_transitive.py +0 -0
  58. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/gendocs.py +0 -0
  59. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/__init__.py +0 -0
  60. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/_common.py +0 -0
  61. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl001_field_snake_case.py +0 -0
  62. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl002_constant_upper_snake.py +0 -0
  63. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl003_name_pascal_case.py +0 -0
  64. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl004_missing_doc.py +0 -0
  65. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl005_discouraged_types.py +0 -0
  66. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl006_unbounded.py +0 -0
  67. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/lint/rsl010_srv_shadow.py +0 -0
  68. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/model.py +0 -0
  69. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/rules/registry.py +0 -0
  70. {roschema-0.2.1 → roschema-0.3.0}/src/roschema/vfs.py +0 -0
  71. {roschema-0.2.1 → roschema-0.3.0}/tests/_helpers.py +0 -0
  72. {roschema-0.2.1 → roschema-0.3.0}/tests/conftest.py +0 -0
  73. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/AMENT_IGNORE +0 -0
  74. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/COLCON_IGNORE +0 -0
  75. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/action/Move.action +0 -0
  76. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/ArrayKind.msg +0 -0
  77. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundLoose.msg +0 -0
  78. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundTight.msg +0 -0
  79. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundedNest.msg +0 -0
  80. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Constants.msg +0 -0
  81. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Empty.msg +0 -0
  82. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldAdded.msg +0 -0
  83. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldRemoved.msg +0 -0
  84. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Renamed.msg +0 -0
  85. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Reordered.msg +0 -0
  86. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Suppressed.msg +0 -0
  87. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/TypeChanged.msg +0 -0
  88. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Unchanged.msg +0 -0
  89. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/WeirdSpacing.msg +0 -0
  90. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/package.xml +0 -0
  91. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/srv/DoThing.srv +0 -0
  92. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/action/Move.action +0 -0
  93. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/ArrayKind.msg +0 -0
  94. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundLoose.msg +0 -0
  95. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundTight.msg +0 -0
  96. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundedNest.msg +0 -0
  97. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Constants.msg +0 -0
  98. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Empty.msg +0 -0
  99. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldAdded.msg +0 -0
  100. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldRemoved.msg +0 -0
  101. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Removed.msg +0 -0
  102. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Renamed.msg +0 -0
  103. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Reordered.msg +0 -0
  104. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Suppressed.msg +0 -0
  105. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/TypeChanged.msg +0 -0
  106. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Unchanged.msg +0 -0
  107. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/WeirdSpacing.msg +0 -0
  108. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/package.xml +0 -0
  109. {roschema-0.2.1 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/srv/DoThing.srv +0 -0
  110. {roschema-0.2.1 → roschema-0.3.0}/tests/test_cli_ls.py +0 -0
  111. {roschema-0.2.1 → roschema-0.3.0}/tests/test_cli_version.py +0 -0
  112. {roschema-0.2.1 → roschema-0.3.0}/tests/test_config.py +0 -0
  113. {roschema-0.2.1 → roschema-0.3.0}/tests/test_deep.py +0 -0
  114. {roschema-0.2.1 → roschema-0.3.0}/tests/test_differ.py +0 -0
  115. {roschema-0.2.1 → roschema-0.3.0}/tests/test_discovery.py +0 -0
  116. {roschema-0.2.1 → roschema-0.3.0}/tests/test_fmt.py +0 -0
  117. {roschema-0.2.1 → roschema-0.3.0}/tests/test_freeze.py +0 -0
  118. {roschema-0.2.1 → roschema-0.3.0}/tests/test_gendocs.py +0 -0
  119. {roschema-0.2.1 → roschema-0.3.0}/tests/test_parser.py +0 -0
  120. {roschema-0.2.1 → roschema-0.3.0}/tests/test_report_formats.py +0 -0
  121. {roschema-0.2.1 → roschema-0.3.0}/tests/test_rules.py +0 -0
  122. {roschema-0.2.1 → roschema-0.3.0}/tests/test_vfs.py +0 -0
@@ -15,7 +15,7 @@ jobs:
15
15
  runs-on: ubuntu-latest
16
16
  steps:
17
17
  - uses: actions/checkout@v4
18
- - uses: astral-sh/setup-uv@v5
18
+ - uses: astral-sh/setup-uv@v10.3.3
19
19
  with:
20
20
  python-version: "3.12"
21
21
  enable-cache: true
@@ -36,10 +36,10 @@ jobs:
36
36
  strategy:
37
37
  fail-fast: false
38
38
  matrix:
39
- python-version: ["3.10", "3.11", "3.12", "3.13"]
39
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
40
40
  steps:
41
41
  - uses: actions/checkout@v4
42
- - uses: astral-sh/setup-uv@v5
42
+ - uses: astral-sh/setup-uv@v10.3.3
43
43
  with:
44
44
  python-version: ${{ matrix.python-version }}
45
45
  enable-cache: true
@@ -10,7 +10,7 @@ jobs:
10
10
  runs-on: ubuntu-latest
11
11
  steps:
12
12
  - uses: actions/checkout@v4
13
- - uses: astral-sh/setup-uv@v5
13
+ - uses: astral-sh/setup-uv@v10.3.3
14
14
  with:
15
15
  python-version: "3.12"
16
16
  enable-cache: true
@@ -3,6 +3,29 @@
3
3
  The format follows [Keep a Changelog](https://keepachangelog.com/), and the
4
4
  project adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [0.3.0] - 2026-11-03
7
+
8
+ ### Added
9
+
10
+ - Reports say how many interfaces were compared, and on which side the rest
11
+ exist only (JSON: `coverage`).
12
+ - `--warnings-as-errors` for `check`, `bag-check` and `lint`, and the Action's
13
+ `warnings-as-errors` input.
14
+
15
+ ### Changed
16
+
17
+ - Python 3.14 is supported and tested.
18
+ - An empty baseline is an error (exit `2`) instead of a silent pass; pass
19
+ `--allow-empty` (Action: `allow-empty`) when that is expected.
20
+
21
+ ### Fixed
22
+
23
+ - `--format github` attaches findings with no new-side location, such as a
24
+ removed field or a reorder, to the interface's file instead of leaving GitHub
25
+ to show them against the workflow file.
26
+ - The Action uses `astral-sh/setup-uv@v10.3.3` (Node 24) with its cache off,
27
+ removing the Node 20 deprecation and missing `uv.lock` warnings.
28
+
6
29
  ## [0.2.1] - 2026-11-03
7
30
 
8
31
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: roschema
3
- Version: 0.2.1
3
+ Version: 0.3.0
4
4
  Summary: Breaking-change detection, linting and formatting for ROS 2 interface definitions
5
5
  Project-URL: Homepage, https://github.com/justagist/roschema
6
6
  Project-URL: Documentation, https://github.com/justagist/roschema#readme
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.10
17
17
  Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
20
21
  Classifier: Topic :: Software Development :: Quality Assurance
21
22
  Classifier: Typing :: Typed
22
23
  Requires-Python: >=3.10
@@ -153,7 +154,8 @@ roschema ls
153
154
 
154
155
  ```
155
156
  roschema check [TARGET] --against BASELINE [--level LEVEL] [--format text|json|github]
156
- [--deep | --no-deep] [--exit-zero] [--config PATH] [--exclude GLOB]
157
+ [--deep | --no-deep] [--exit-zero] [--warnings-as-errors] [--allow-empty]
158
+ [--config PATH] [--exclude GLOB]
157
159
  ```
158
160
 
159
161
  `TARGET` selects what to check; omit it to check every interface package below
@@ -176,6 +178,13 @@ MCAP bag (`.mcap`) or a lockfile (`roschema.lock`); the type is detected
176
178
  automatically. Initialised git submodules are compared at the commit the
177
179
  baseline records for them.
178
180
 
181
+ Every report says how much was compared, for example `compared 12 interface(s)
182
+ in 3 package(s); 1 only in the target`. A baseline with no interfaces at all is
183
+ an error (exit `2`): every current interface would look newly added, so the
184
+ check would pass without comparing anything. That usually means the wrong
185
+ baseline or target; on the pull request that adds the first interfaces, pass
186
+ `--allow-empty`.
187
+
179
188
  Limitations:
180
189
 
181
190
  - When a git ref and a directory share a name, the git ref wins; prefix the
@@ -199,7 +208,7 @@ followed.
199
208
 
200
209
  ```
201
210
  roschema lint [TARGET] [--format text|json|github] [--rules RULE=SEV] [--exit-zero]
202
- [--config PATH] [--exclude GLOB]
211
+ [--warnings-as-errors] [--config PATH] [--exclude GLOB]
203
212
  roschema fmt [TARGET] [--check] [--exclude GLOB]
204
213
  ```
205
214
 
@@ -238,7 +247,8 @@ same. Comparison ignores it.
238
247
 
239
248
  ```
240
249
  roschema bag-check BAG.mcap [TARGETS...] [--level LEVEL] [--config PATH]
241
- [--format text|json|github] [--exit-zero] [--exclude GLOB]
250
+ [--format text|json|github] [--exit-zero] [--warnings-as-errors]
251
+ [--exclude GLOB]
242
252
  ```
243
253
 
244
254
  Needs the `bag` extra (`uv tool install 'roschema[bag]'`).
@@ -327,8 +337,8 @@ allow every rule for that target.
327
337
 
328
338
  ## Output
329
339
 
330
- **Text** (the default) groups findings by package and ends with a summary and a
331
- semver advice per package:
340
+ **Text** (the default) starts with what was compared, groups findings by
341
+ package and ends with a summary and a semver advice per package:
332
342
 
333
343
  - **MAJOR** when the package has a wire- or source-level error;
334
344
  - **MINOR** when its only notable change is an added field (RS002) whose
@@ -338,8 +348,8 @@ semver advice per package:
338
348
  The advice only covers reported findings. Additions such as a new interface or
339
349
  constant are not findings, so they never raise the advice to MINOR on their own.
340
350
 
341
- **`--format github`** emits `::error`/`::warning` workflow commands, which GitHub
342
- shows as annotations on the PR diff.
351
+ **`--format github`** prints what was compared, then emits `::error`/`::warning`
352
+ workflow commands, which GitHub shows as annotations on the PR diff.
343
353
 
344
354
  **`--format json`** emits a stable document (`version: 1`):
345
355
 
@@ -367,12 +377,19 @@ shows as annotations on the PR diff.
367
377
  "warnings": 0,
368
378
  "suppressed": 0,
369
379
  "advisories": { "my_msgs": "MAJOR" }
380
+ },
381
+ "coverage": {
382
+ "packages": 1,
383
+ "compared": 1,
384
+ "target_only": 0,
385
+ "baseline_only": 0
370
386
  }
371
387
  }
372
388
  ```
373
389
 
374
390
  `old`/`new` are `null` when there is no corresponding side (a removed interface
375
- has no `new`). `lint` output has no `advisories`.
391
+ has no `new`). `coverage` counts interfaces present on both sides (`compared`)
392
+ and on one side only. `lint` output has no `advisories` or `coverage`.
376
393
 
377
394
  ## Exit codes
378
395
 
@@ -380,10 +397,11 @@ has no `new`). `lint` output has no `advisories`.
380
397
  |---|---|---|---|
381
398
  | `0` | no unsuppressed errors (warnings allowed) | every file formatted | success |
382
399
  | `1` | at least one unsuppressed error | a file would change | n/a |
383
- | `2` | tool or usage error (bad target, baseline, configuration or file) | tool or usage error | tool or usage error |
400
+ | `2` | tool or usage error (bad target, baseline, configuration or file, or an empty baseline) | tool or usage error | tool or usage error |
384
401
 
385
- `--exit-zero` turns `1` into `0` for `check`, `bag-check` and `lint`; tool
386
- errors still exit `2`.
402
+ `--warnings-as-errors` makes unsuppressed warnings exit `1` too. `--exit-zero`
403
+ turns `1` into `0` for `check`, `bag-check` and `lint`; tool errors still exit
404
+ `2`.
387
405
 
388
406
  ## GitHub Action
389
407
 
@@ -403,9 +421,10 @@ jobs:
403
421
  - uses: justagist/roschema/action@main
404
422
  with:
405
423
  against: origin/${{ github.base_ref }}
406
- # version: "roschema==0.2.1" # pin a release for reproducible results
424
+ # version: "roschema==0.3.0" # pin a release for reproducible results
407
425
  # level: source
408
426
  # deep: "true"
427
+ # warnings-as-errors: "true"
409
428
  # exclude: "vendor_msgs* legacy_msgs/msg/Old"
410
429
  ```
411
430
 
@@ -416,6 +435,8 @@ jobs:
416
435
  | `level` | (empty) | reporting level; empty uses the configured level, else `source` |
417
436
  | `format` | `github` | `text`, `json` or `github` |
418
437
  | `deep` | `"false"` | `"true"` adds `--deep` |
438
+ | `allow-empty` | `"false"` | `"true"` adds `--allow-empty` |
439
+ | `warnings-as-errors` | `"false"` | `"true"` adds `--warnings-as-errors` |
419
440
  | `exclude` | (empty) | whitespace-separated globs passed as `--exclude` |
420
441
  | `version` | `roschema` | the roschema requirement installed with `uvx` |
421
442
 
@@ -426,9 +447,9 @@ because it is part of the checkout.
426
447
 
427
448
  ## Troubleshooting
428
449
 
429
- **"no breaking changes found", but you expected some.** Run `roschema ls` with
430
- the same target to see which packages and interfaces are discovered, then check
431
- that:
450
+ **"no breaking changes found", but you expected some.** The line under the
451
+ header says how many interfaces were compared. Run `roschema ls` with the same
452
+ target to see which packages and interfaces are discovered, then check that:
432
453
 
433
454
  - the packages are not below a `COLCON_IGNORE` or `AMENT_IGNORE` marker;
434
455
  - a package-name target did not fall back to the installed copy (the warning
@@ -119,7 +119,8 @@ roschema ls
119
119
 
120
120
  ```
121
121
  roschema check [TARGET] --against BASELINE [--level LEVEL] [--format text|json|github]
122
- [--deep | --no-deep] [--exit-zero] [--config PATH] [--exclude GLOB]
122
+ [--deep | --no-deep] [--exit-zero] [--warnings-as-errors] [--allow-empty]
123
+ [--config PATH] [--exclude GLOB]
123
124
  ```
124
125
 
125
126
  `TARGET` selects what to check; omit it to check every interface package below
@@ -142,6 +143,13 @@ MCAP bag (`.mcap`) or a lockfile (`roschema.lock`); the type is detected
142
143
  automatically. Initialised git submodules are compared at the commit the
143
144
  baseline records for them.
144
145
 
146
+ Every report says how much was compared, for example `compared 12 interface(s)
147
+ in 3 package(s); 1 only in the target`. A baseline with no interfaces at all is
148
+ an error (exit `2`): every current interface would look newly added, so the
149
+ check would pass without comparing anything. That usually means the wrong
150
+ baseline or target; on the pull request that adds the first interfaces, pass
151
+ `--allow-empty`.
152
+
145
153
  Limitations:
146
154
 
147
155
  - When a git ref and a directory share a name, the git ref wins; prefix the
@@ -165,7 +173,7 @@ followed.
165
173
 
166
174
  ```
167
175
  roschema lint [TARGET] [--format text|json|github] [--rules RULE=SEV] [--exit-zero]
168
- [--config PATH] [--exclude GLOB]
176
+ [--warnings-as-errors] [--config PATH] [--exclude GLOB]
169
177
  roschema fmt [TARGET] [--check] [--exclude GLOB]
170
178
  ```
171
179
 
@@ -204,7 +212,8 @@ same. Comparison ignores it.
204
212
 
205
213
  ```
206
214
  roschema bag-check BAG.mcap [TARGETS...] [--level LEVEL] [--config PATH]
207
- [--format text|json|github] [--exit-zero] [--exclude GLOB]
215
+ [--format text|json|github] [--exit-zero] [--warnings-as-errors]
216
+ [--exclude GLOB]
208
217
  ```
209
218
 
210
219
  Needs the `bag` extra (`uv tool install 'roschema[bag]'`).
@@ -293,8 +302,8 @@ allow every rule for that target.
293
302
 
294
303
  ## Output
295
304
 
296
- **Text** (the default) groups findings by package and ends with a summary and a
297
- semver advice per package:
305
+ **Text** (the default) starts with what was compared, groups findings by
306
+ package and ends with a summary and a semver advice per package:
298
307
 
299
308
  - **MAJOR** when the package has a wire- or source-level error;
300
309
  - **MINOR** when its only notable change is an added field (RS002) whose
@@ -304,8 +313,8 @@ semver advice per package:
304
313
  The advice only covers reported findings. Additions such as a new interface or
305
314
  constant are not findings, so they never raise the advice to MINOR on their own.
306
315
 
307
- **`--format github`** emits `::error`/`::warning` workflow commands, which GitHub
308
- shows as annotations on the PR diff.
316
+ **`--format github`** prints what was compared, then emits `::error`/`::warning`
317
+ workflow commands, which GitHub shows as annotations on the PR diff.
309
318
 
310
319
  **`--format json`** emits a stable document (`version: 1`):
311
320
 
@@ -333,12 +342,19 @@ shows as annotations on the PR diff.
333
342
  "warnings": 0,
334
343
  "suppressed": 0,
335
344
  "advisories": { "my_msgs": "MAJOR" }
345
+ },
346
+ "coverage": {
347
+ "packages": 1,
348
+ "compared": 1,
349
+ "target_only": 0,
350
+ "baseline_only": 0
336
351
  }
337
352
  }
338
353
  ```
339
354
 
340
355
  `old`/`new` are `null` when there is no corresponding side (a removed interface
341
- has no `new`). `lint` output has no `advisories`.
356
+ has no `new`). `coverage` counts interfaces present on both sides (`compared`)
357
+ and on one side only. `lint` output has no `advisories` or `coverage`.
342
358
 
343
359
  ## Exit codes
344
360
 
@@ -346,10 +362,11 @@ has no `new`). `lint` output has no `advisories`.
346
362
  |---|---|---|---|
347
363
  | `0` | no unsuppressed errors (warnings allowed) | every file formatted | success |
348
364
  | `1` | at least one unsuppressed error | a file would change | n/a |
349
- | `2` | tool or usage error (bad target, baseline, configuration or file) | tool or usage error | tool or usage error |
365
+ | `2` | tool or usage error (bad target, baseline, configuration or file, or an empty baseline) | tool or usage error | tool or usage error |
350
366
 
351
- `--exit-zero` turns `1` into `0` for `check`, `bag-check` and `lint`; tool
352
- errors still exit `2`.
367
+ `--warnings-as-errors` makes unsuppressed warnings exit `1` too. `--exit-zero`
368
+ turns `1` into `0` for `check`, `bag-check` and `lint`; tool errors still exit
369
+ `2`.
353
370
 
354
371
  ## GitHub Action
355
372
 
@@ -369,9 +386,10 @@ jobs:
369
386
  - uses: justagist/roschema/action@main
370
387
  with:
371
388
  against: origin/${{ github.base_ref }}
372
- # version: "roschema==0.2.1" # pin a release for reproducible results
389
+ # version: "roschema==0.3.0" # pin a release for reproducible results
373
390
  # level: source
374
391
  # deep: "true"
392
+ # warnings-as-errors: "true"
375
393
  # exclude: "vendor_msgs* legacy_msgs/msg/Old"
376
394
  ```
377
395
 
@@ -382,6 +400,8 @@ jobs:
382
400
  | `level` | (empty) | reporting level; empty uses the configured level, else `source` |
383
401
  | `format` | `github` | `text`, `json` or `github` |
384
402
  | `deep` | `"false"` | `"true"` adds `--deep` |
403
+ | `allow-empty` | `"false"` | `"true"` adds `--allow-empty` |
404
+ | `warnings-as-errors` | `"false"` | `"true"` adds `--warnings-as-errors` |
385
405
  | `exclude` | (empty) | whitespace-separated globs passed as `--exclude` |
386
406
  | `version` | `roschema` | the roschema requirement installed with `uvx` |
387
407
 
@@ -392,9 +412,9 @@ because it is part of the checkout.
392
412
 
393
413
  ## Troubleshooting
394
414
 
395
- **"no breaking changes found", but you expected some.** Run `roschema ls` with
396
- the same target to see which packages and interfaces are discovered, then check
397
- that:
415
+ **"no breaking changes found", but you expected some.** The line under the
416
+ header says how many interfaces were compared. Run `roschema ls` with the same
417
+ target to see which packages and interfaces are discovered, then check that:
398
418
 
399
419
  - the packages are not below a `COLCON_IGNORE` or `AMENT_IGNORE` marker;
400
420
  - a package-name target did not fall back to the installed copy (the warning
@@ -22,6 +22,14 @@ inputs:
22
22
  deep:
23
23
  description: Resolve nested types and report transitive breakage (RS110).
24
24
  default: "false"
25
+ allow-empty:
26
+ description: >-
27
+ Pass when the baseline has no interfaces, e.g. on the pull request that
28
+ adds the first ones. Otherwise an empty baseline fails the step.
29
+ default: "false"
30
+ warnings-as-errors:
31
+ description: Fail the step on warnings as well as errors.
32
+ default: "false"
25
33
  exclude:
26
34
  description: >-
27
35
  Whitespace-separated globs of packages/interfaces to skip. Split on
@@ -30,14 +38,18 @@ inputs:
30
38
  version:
31
39
  description: >-
32
40
  roschema requirement for uvx. Unpinned runs the latest release (refreshed
33
- on every run, even with a uv cache); pin it (e.g. "roschema==0.2.1") for
41
+ on every run, even with a uv cache); pin it (e.g. "roschema==0.3.0") for
34
42
  reproducible results.
35
43
  default: roschema
36
44
 
37
45
  runs:
38
46
  using: composite
39
47
  steps:
40
- - uses: astral-sh/setup-uv@v5
48
+ - uses: astral-sh/setup-uv@v10.3.3
49
+ with:
50
+ # Nothing to key a cache on in the caller's repo, and uvx refreshes
51
+ # roschema on every run anyway.
52
+ enable-cache: false
41
53
  - shell: bash
42
54
  env:
43
55
  ROSCHEMA_TARGET: ${{ inputs.target }}
@@ -45,6 +57,8 @@ runs:
45
57
  ROSCHEMA_LEVEL: ${{ inputs.level }}
46
58
  ROSCHEMA_FORMAT: ${{ inputs.format }}
47
59
  ROSCHEMA_DEEP: ${{ inputs.deep }}
60
+ ROSCHEMA_ALLOW_EMPTY: ${{ inputs.allow-empty }}
61
+ ROSCHEMA_WARNINGS_AS_ERRORS: ${{ inputs.warnings-as-errors }}
48
62
  ROSCHEMA_EXCLUDE: ${{ inputs.exclude }}
49
63
  ROSCHEMA_VERSION: ${{ inputs.version }}
50
64
  run: |
@@ -58,6 +72,12 @@ runs:
58
72
  if [ "$ROSCHEMA_DEEP" = "true" ]; then
59
73
  args+=(--deep)
60
74
  fi
75
+ if [ "$ROSCHEMA_ALLOW_EMPTY" = "true" ]; then
76
+ args+=(--allow-empty)
77
+ fi
78
+ if [ "$ROSCHEMA_WARNINGS_AS_ERRORS" = "true" ]; then
79
+ args+=(--warnings-as-errors)
80
+ fi
61
81
  read -r -a excludes <<< "${ROSCHEMA_EXCLUDE//$'\n'/ }"
62
82
  for glob in "${excludes[@]}"; do
63
83
  args+=(--exclude "$glob")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roschema"
7
- version = "0.2.1"
7
+ version = "0.3.0"
8
8
  description = "Breaking-change detection, linting and formatting for ROS 2 interface definitions"
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -19,6 +19,7 @@ classifiers = [
19
19
  "Programming Language :: Python :: 3.11",
20
20
  "Programming Language :: Python :: 3.12",
21
21
  "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
22
23
  "Topic :: Software Development :: Quality Assurance",
23
24
  "Typing :: Typed",
24
25
  ]
@@ -38,7 +38,7 @@ from .ir import InterfaceFile
38
38
  from .loader import load_interfaces
39
39
  from .mcap_fs import McapTypeFS
40
40
  from .parser import ParseError
41
- from .report import build_github, build_json, render_lint, render_text
41
+ from .report import Coverage, build_github, build_json, coverage, render_lint, render_text
42
42
  from .rules import Finding, all_lint_rules, evaluate, lint_interfaces
43
43
  from .vfs import GitTreeFS, RealFS, VfsError, git_toplevel
44
44
 
@@ -113,6 +113,17 @@ def check(
113
113
  exit_zero: Annotated[
114
114
  bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
115
115
  ] = False,
116
+ warnings_as_errors: Annotated[
117
+ bool,
118
+ typer.Option("--warnings-as-errors", help="Exit 1 on warnings as well as errors."),
119
+ ] = False,
120
+ allow_empty: Annotated[
121
+ bool,
122
+ typer.Option(
123
+ "--allow-empty",
124
+ help="Pass when the baseline has no interfaces (e.g. before any exist).",
125
+ ),
126
+ ] = False,
116
127
  exclude: Annotated[
117
128
  list[str] | None,
118
129
  typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
@@ -127,7 +138,7 @@ def check(
127
138
  raise typer.Exit(code=2)
128
139
 
129
140
  try:
130
- findings, new_source, old_source, prefix = _compute(
141
+ findings, new_source, old_source, prefix, files, cov = _compute(
131
142
  target, lambda root: resolve_baseline(against, root), level, config_path, exclude, deep
132
143
  )
133
144
  except (VfsError, ParseError, ConfigError) as exc:
@@ -139,8 +150,12 @@ def check(
139
150
  f"[dim]note: {against!r} matched a git ref; "
140
151
  f"use './{against}' to compare against the directory instead.[/dim]"
141
152
  )
142
- _emit(findings, fmt, new_source, old_source, prefix)
143
- _exit_for(findings, exit_zero)
153
+ if not allow_empty:
154
+ _require_baseline(
155
+ cov, f"the baseline ({old_source})", "pass --allow-empty if that is expected"
156
+ )
157
+ _emit(findings, fmt, new_source, old_source, cov, prefix, files)
158
+ _exit_for(findings, exit_zero, warnings_as_errors)
144
159
 
145
160
 
146
161
  @app.command(name="bag-check")
@@ -170,6 +185,10 @@ def bag_check(
170
185
  exit_zero: Annotated[
171
186
  bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
172
187
  ] = False,
188
+ warnings_as_errors: Annotated[
189
+ bool,
190
+ typer.Option("--warnings-as-errors", help="Exit 1 on warnings as well as errors."),
191
+ ] = False,
173
192
  exclude: Annotated[
174
193
  list[str] | None,
175
194
  typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
@@ -208,8 +227,11 @@ def bag_check(
208
227
  findings = finalize_findings(
209
228
  evaluate(diff_interfaces(old_ifaces, new_ifaces)), config, level, BAG_DEFAULT_LEVEL
210
229
  )
211
- _emit(findings, fmt, new_source, old_res.source)
212
- _exit_for(findings, exit_zero)
230
+ cov = coverage(old_ifaces, new_ifaces)
231
+ _require_baseline(cov, "the bag", "check the --exclude patterns")
232
+ files = {key: iface.source_file for key, iface in new_ifaces.items()}
233
+ _emit(findings, fmt, new_source, old_res.source, cov, interface_files=files)
234
+ _exit_for(findings, exit_zero, warnings_as_errors)
213
235
 
214
236
 
215
237
  def _resolve_current(
@@ -263,8 +285,12 @@ def _compute(
263
285
  config_path: Path | None,
264
286
  exclude: list[str] | None,
265
287
  deep: bool | None = None,
266
- ) -> tuple[list[Finding], str, str, str]:
267
- """Resolve both sides, diff and finalize; the shared check pipeline."""
288
+ ) -> tuple[list[Finding], str, str, str, dict[str, str], Coverage]:
289
+ """Resolve both sides, diff and finalize; the shared check pipeline.
290
+
291
+ Also returns each current interface's source file, keyed by interface, and
292
+ how many interfaces were compared.
293
+ """
268
294
  new_res = _resolve_target(target, allow_empty_glob=True)
269
295
  if not isinstance(new_res.vfs, RealFS):
270
296
  raise VfsError("target must be a directory or workspace")
@@ -305,7 +331,9 @@ def _compute(
305
331
  findings += finalize_findings(
306
332
  [f for f in transitive if f.package in deep_packages], config, level
307
333
  )
308
- return findings, new_res.source, old_res.source, prefix
334
+ files = {key: iface.source_file for key, iface in new_ifaces.items()}
335
+ cov = coverage(old_ifaces, new_ifaces)
336
+ return findings, new_res.source, old_res.source, prefix, files, cov
309
337
 
310
338
 
311
339
  def _note_skipped_schemas(res: Resolution) -> None:
@@ -326,11 +354,26 @@ def _display_prefix(target_dir: Path) -> str:
326
354
  return "" if rel == "." else rel
327
355
 
328
356
 
329
- def _exit_for(findings: list[Finding], exit_zero: bool) -> None:
357
+ def _require_baseline(cov: Coverage, baseline: str, hint: str) -> None:
358
+ """Exit 2 when the baseline is empty, naming ``baseline`` and how to resolve it.
359
+
360
+ With no baseline interfaces every current one looks added, which is never a
361
+ finding, so the check would pass without comparing anything.
362
+ """
363
+ if cov.baseline_empty:
364
+ err_console.print(
365
+ f"[red]error:[/red] {baseline} has no interfaces, so nothing was compared; {hint}"
366
+ )
367
+ raise typer.Exit(code=2)
368
+
369
+
370
+ def _exit_for(findings: list[Finding], exit_zero: bool, warnings_as_errors: bool) -> None:
371
+ """Exit 1 if an unsuppressed finding fails the check, else 0."""
330
372
  if exit_zero:
331
373
  raise typer.Exit(code=0)
332
- has_error = any(f.severity == "error" and not f.suppressed for f in findings)
333
- raise typer.Exit(code=1 if has_error else 0)
374
+ failing = {"error", "warn"} if warnings_as_errors else {"error"}
375
+ has_failure = any(f.severity in failing and not f.suppressed for f in findings)
376
+ raise typer.Exit(code=1 if has_failure else 0)
334
377
 
335
378
 
336
379
  def _emit(
@@ -338,15 +381,18 @@ def _emit(
338
381
  fmt: str,
339
382
  new_source: str,
340
383
  old_source: str,
384
+ cov: Coverage,
341
385
  display_prefix: str = "",
386
+ interface_files: dict[str, str] | None = None,
342
387
  ) -> None:
343
388
  if fmt == "json":
344
- typer.echo(json.dumps(build_json(findings), indent=2))
389
+ typer.echo(json.dumps(build_json(findings, coverage=cov), indent=2))
345
390
  elif fmt == "github":
346
- for line in build_github(findings, display_prefix):
391
+ typer.echo(cov.describe())
392
+ for line in build_github(findings, display_prefix, interface_files):
347
393
  typer.echo(line)
348
394
  else:
349
- render_text(findings, console, new_source, old_source)
395
+ render_text(findings, console, new_source, old_source, cov)
350
396
 
351
397
 
352
398
  @app.command()
@@ -372,6 +418,10 @@ def lint(
372
418
  exit_zero: Annotated[
373
419
  bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
374
420
  ] = False,
421
+ warnings_as_errors: Annotated[
422
+ bool,
423
+ typer.Option("--warnings-as-errors", help="Exit 1 on warnings as well as errors."),
424
+ ] = False,
375
425
  exclude: Annotated[
376
426
  list[str] | None,
377
427
  typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
@@ -402,10 +452,7 @@ def lint(
402
452
  else:
403
453
  render_lint(findings, console, res.source)
404
454
 
405
- if exit_zero:
406
- raise typer.Exit(code=0)
407
- has_error = any(f.severity == "error" and not f.suppressed for f in findings)
408
- raise typer.Exit(code=1 if has_error else 0)
455
+ _exit_for(findings, exit_zero, warnings_as_errors)
409
456
 
410
457
 
411
458
  def _parse_rule_overrides(items: list[str]) -> dict[str, str]:
@@ -4,13 +4,15 @@ from __future__ import annotations
4
4
 
5
5
  from .github import build_github
6
6
  from .json_report import build_json
7
- from .summary import Summary, summarize
7
+ from .summary import Coverage, Summary, coverage, summarize
8
8
  from .text import render_lint, render_text
9
9
 
10
10
  __all__ = [
11
+ "Coverage",
11
12
  "Summary",
12
13
  "build_github",
13
14
  "build_json",
15
+ "coverage",
14
16
  "render_lint",
15
17
  "render_text",
16
18
  "summarize",
@@ -2,26 +2,37 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ from collections.abc import Mapping
6
+
5
7
  from ..rules import Finding
6
8
 
7
9
  _LEVEL = {"error": "error", "warn": "warning"}
8
10
 
9
11
 
10
- def build_github(findings: list[Finding], display_prefix: str = "") -> list[str]:
12
+ def build_github(
13
+ findings: list[Finding],
14
+ display_prefix: str = "",
15
+ interface_files: Mapping[str, str] | None = None,
16
+ ) -> list[str]:
11
17
  """Build ``::error``/``::warning`` annotation lines for non-suppressed findings.
12
18
 
13
19
  Uses the new-side file/line so annotations land on the changed code.
14
20
  ``display_prefix`` is prepended to file paths so they are repo-root-relative
15
21
  (GitHub resolves annotation paths from the repo root, not the check target).
22
+ A finding with no new-side location (e.g. a removed field) is attached to its
23
+ interface's file from ``interface_files``; GitHub otherwise shows it against
24
+ the workflow file.
16
25
  """
26
+ files = interface_files or {}
17
27
  lines: list[str] = []
18
28
  for finding in findings:
19
29
  if finding.suppressed:
20
30
  continue
21
31
  command = _LEVEL.get(finding.severity, "notice")
22
32
  props = [f"title={_escape_property(finding.rule)}"]
23
- if finding.new_file:
24
- path = f"{display_prefix}/{finding.new_file}" if display_prefix else finding.new_file
33
+ file = finding.new_file or files.get(finding.interface)
34
+ if file:
35
+ path = f"{display_prefix}/{file}" if display_prefix else file
25
36
  props.insert(0, f"file={_escape_property(path)}")
26
37
  if finding.new_line is not None:
27
38
  props.insert(1, f"line={finding.new_line}")