roschema 0.2.0__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 (124) hide show
  1. {roschema-0.2.0 → roschema-0.3.0}/.github/workflows/ci.yml +3 -3
  2. {roschema-0.2.0 → roschema-0.3.0}/.github/workflows/release.yml +1 -1
  3. {roschema-0.2.0 → roschema-0.3.0}/CHANGELOG.md +36 -0
  4. roschema-0.3.0/CONTRIBUTING.md +56 -0
  5. roschema-0.3.0/PKG-INFO +496 -0
  6. roschema-0.3.0/README.md +461 -0
  7. {roschema-0.2.0 → roschema-0.3.0}/action/action.yml +22 -2
  8. {roschema-0.2.0 → roschema-0.3.0}/docs/rules.md +85 -0
  9. {roschema-0.2.0 → roschema-0.3.0}/pyproject.toml +2 -1
  10. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/cli.py +66 -19
  11. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/report/__init__.py +3 -1
  12. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/report/github.py +14 -3
  13. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/report/json_report.py +16 -5
  14. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/report/summary.py +37 -0
  15. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/report/text.py +8 -3
  16. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/gendocs.py +48 -15
  17. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl001_field_snake_case.py +2 -0
  18. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl002_constant_upper_snake.py +2 -0
  19. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl003_name_pascal_case.py +2 -0
  20. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl004_missing_doc.py +2 -0
  21. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl005_discouraged_types.py +2 -0
  22. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl006_unbounded.py +2 -0
  23. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/rsl010_srv_shadow.py +2 -0
  24. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/model.py +3 -0
  25. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/golden_check.json +6 -0
  26. {roschema-0.2.0 → roschema-0.3.0}/tests/test_action.py +21 -5
  27. {roschema-0.2.0 → roschema-0.3.0}/tests/test_check_cli.py +61 -0
  28. {roschema-0.2.0 → roschema-0.3.0}/tests/test_golden.py +4 -5
  29. {roschema-0.2.0 → roschema-0.3.0}/tests/test_lint.py +8 -0
  30. {roschema-0.2.0 → roschema-0.3.0}/tests/test_mcap.py +28 -0
  31. roschema-0.2.0/PKG-INFO +0 -354
  32. roschema-0.2.0/README.md +0 -320
  33. {roschema-0.2.0 → roschema-0.3.0}/.gitignore +0 -0
  34. {roschema-0.2.0 → roschema-0.3.0}/LICENSE +0 -0
  35. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/__init__.py +0 -0
  36. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/config.py +0 -0
  37. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/deep.py +0 -0
  38. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/differ.py +0 -0
  39. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/discovery/__init__.py +0 -0
  40. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/discovery/discover.py +0 -0
  41. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/discovery/model.py +0 -0
  42. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/discovery/resolve.py +0 -0
  43. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/formatter.py +0 -0
  44. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/freeze.py +0 -0
  45. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/ir.py +0 -0
  46. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/loader.py +0 -0
  47. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/mcap_fs.py +0 -0
  48. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/parser/__init__.py +0 -0
  49. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/parser/core.py +0 -0
  50. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/parser/errors.py +0 -0
  51. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/py.typed +0 -0
  52. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/__init__.py +0 -0
  53. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/__init__.py +0 -0
  54. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs001_field_removed.py +0 -0
  55. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs002_field_added.py +0 -0
  56. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs003_field_type_changed.py +0 -0
  57. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs004_fields_reordered.py +0 -0
  58. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs005_field_renamed.py +0 -0
  59. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs006_array_kind_changed.py +0 -0
  60. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs007_bound_tightened.py +0 -0
  61. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs008_bound_loosened.py +0 -0
  62. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs020_constant_removed.py +0 -0
  63. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs021_constant_value_changed.py +0 -0
  64. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs022_constant_type_changed.py +0 -0
  65. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs030_default_changed.py +0 -0
  66. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs040_interface_removed.py +0 -0
  67. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs060_message_version.py +0 -0
  68. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/breaking/rs110_transitive.py +0 -0
  69. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/__init__.py +0 -0
  70. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/lint/_common.py +0 -0
  71. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/rules/registry.py +0 -0
  72. {roschema-0.2.0 → roschema-0.3.0}/src/roschema/vfs.py +0 -0
  73. {roschema-0.2.0 → roschema-0.3.0}/tests/_helpers.py +0 -0
  74. {roschema-0.2.0 → roschema-0.3.0}/tests/conftest.py +0 -0
  75. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/AMENT_IGNORE +0 -0
  76. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/COLCON_IGNORE +0 -0
  77. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/action/Move.action +0 -0
  78. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/ArrayKind.msg +0 -0
  79. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundLoose.msg +0 -0
  80. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundTight.msg +0 -0
  81. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundedNest.msg +0 -0
  82. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Constants.msg +0 -0
  83. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Empty.msg +0 -0
  84. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldAdded.msg +0 -0
  85. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldRemoved.msg +0 -0
  86. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Renamed.msg +0 -0
  87. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Reordered.msg +0 -0
  88. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Suppressed.msg +0 -0
  89. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/TypeChanged.msg +0 -0
  90. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/Unchanged.msg +0 -0
  91. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/msg/WeirdSpacing.msg +0 -0
  92. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/package.xml +0 -0
  93. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_new/demo_msgs/srv/DoThing.srv +0 -0
  94. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/action/Move.action +0 -0
  95. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/ArrayKind.msg +0 -0
  96. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundLoose.msg +0 -0
  97. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundTight.msg +0 -0
  98. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundedNest.msg +0 -0
  99. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Constants.msg +0 -0
  100. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Empty.msg +0 -0
  101. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldAdded.msg +0 -0
  102. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldRemoved.msg +0 -0
  103. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Removed.msg +0 -0
  104. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Renamed.msg +0 -0
  105. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Reordered.msg +0 -0
  106. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Suppressed.msg +0 -0
  107. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/TypeChanged.msg +0 -0
  108. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/Unchanged.msg +0 -0
  109. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/msg/WeirdSpacing.msg +0 -0
  110. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/package.xml +0 -0
  111. {roschema-0.2.0 → roschema-0.3.0}/tests/fixtures/ws_old/demo_msgs/srv/DoThing.srv +0 -0
  112. {roschema-0.2.0 → roschema-0.3.0}/tests/test_cli_ls.py +0 -0
  113. {roschema-0.2.0 → roschema-0.3.0}/tests/test_cli_version.py +0 -0
  114. {roschema-0.2.0 → roschema-0.3.0}/tests/test_config.py +0 -0
  115. {roschema-0.2.0 → roschema-0.3.0}/tests/test_deep.py +0 -0
  116. {roschema-0.2.0 → roschema-0.3.0}/tests/test_differ.py +0 -0
  117. {roschema-0.2.0 → roschema-0.3.0}/tests/test_discovery.py +0 -0
  118. {roschema-0.2.0 → roschema-0.3.0}/tests/test_fmt.py +0 -0
  119. {roschema-0.2.0 → roschema-0.3.0}/tests/test_freeze.py +0 -0
  120. {roschema-0.2.0 → roschema-0.3.0}/tests/test_gendocs.py +0 -0
  121. {roschema-0.2.0 → roschema-0.3.0}/tests/test_parser.py +0 -0
  122. {roschema-0.2.0 → roschema-0.3.0}/tests/test_report_formats.py +0 -0
  123. {roschema-0.2.0 → roschema-0.3.0}/tests/test_rules.py +0 -0
  124. {roschema-0.2.0 → 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,42 @@
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
+
29
+ ## [0.2.1] - 2026-11-03
30
+
31
+ ### Added
32
+
33
+ - `docs/rules.md` opens with a one-line overview of every rule, and lint rules
34
+ show flagged and preferred examples.
35
+ - `CONTRIBUTING.md` with the development checks and release process.
36
+
37
+ ### Changed
38
+
39
+ - README restructured for new users: levels and severities, target resolution,
40
+ suppression syntax, exit codes, Action inputs and troubleshooting.
41
+
6
42
  ## [0.2.0] - 2026-11-03
7
43
 
8
44
  ### Added
@@ -0,0 +1,56 @@
1
+ # Contributing
2
+
3
+ ## Setup
4
+
5
+ roschema uses [uv](https://docs.astral.sh/uv/):
6
+
7
+ ```bash
8
+ uv sync --extra dev --extra bag
9
+ ```
10
+
11
+ ## Checks
12
+
13
+ CI runs these on every pull request; run them before pushing:
14
+
15
+ ```bash
16
+ uv run pytest
17
+ uv run ruff check .
18
+ uv run ruff format --check .
19
+ uv run mypy --strict src
20
+ uv run python -m roschema.rules.gendocs --check
21
+ ```
22
+
23
+ The tests do not need ROS and work in a shell with a ROS environment sourced:
24
+ `pyproject.toml` disables the ROS launch pytest plugins, which cannot be
25
+ imported in the roschema virtualenv.
26
+
27
+ Every fix or feature should come with a test that fails without it.
28
+
29
+ ## Rules
30
+
31
+ Each rule lives in its own module under `src/roschema/rules/breaking/` (check
32
+ rules, `RS***`) or `src/roschema/rules/lint/` (lint rules, `RSL***`) and is
33
+ registered in that package's `__init__.py`. Rule IDs are permanent: never
34
+ renumber or reuse one.
35
+
36
+ `docs/rules.md` is generated from the rule metadata. After changing a rule,
37
+ regenerate it:
38
+
39
+ ```bash
40
+ uv run python -m roschema.rules.gendocs
41
+ ```
42
+
43
+ ## Releases
44
+
45
+ 1. Bump `version` in `pyproject.toml` and turn the `[Unreleased]` section of
46
+ `CHANGELOG.md` into a dated `[X.Y.Z]` section.
47
+ 2. Merge to `main`.
48
+ 3. Tag the merge commit and push the tag:
49
+
50
+ ```bash
51
+ git tag -a vX.Y.Z -m "Release X.Y.Z"
52
+ git push origin vX.Y.Z
53
+ ```
54
+
55
+ The release workflow then checks that the tag matches the package version, runs
56
+ the checks, builds the sdist and wheel, and publishes them to PyPI.
@@ -0,0 +1,496 @@
1
+ Metadata-Version: 2.5
2
+ Name: roschema
3
+ Version: 0.3.0
4
+ Summary: Breaking-change detection, linting and formatting for ROS 2 interface definitions
5
+ Project-URL: Homepage, https://github.com/justagist/roschema
6
+ Project-URL: Documentation, https://github.com/justagist/roschema#readme
7
+ Project-URL: Repository, https://github.com/justagist/roschema
8
+ Project-URL: Issues, https://github.com/justagist/roschema/issues
9
+ Author-email: Saif Sidhik <saif.sidhik@helloleap.ai>
10
+ License-Expression: Apache-2.0
11
+ License-File: LICENSE
12
+ Keywords: action,breaking-change,interface,lint,msg,ros,ros2,srv
13
+ Classifier: Development Status :: 3 - Alpha
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Quality Assurance
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: rich>=13.0
25
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
26
+ Requires-Dist: typer>=0.12
27
+ Provides-Extra: bag
28
+ Requires-Dist: mcap>=1.1; extra == 'bag'
29
+ Provides-Extra: dev
30
+ Requires-Dist: mypy>=1.10; extra == 'dev'
31
+ Requires-Dist: pytest>=7.4; extra == 'dev'
32
+ Requires-Dist: ruff>=0.5; extra == 'dev'
33
+ Requires-Dist: tomli>=2.0; extra == 'dev'
34
+ Description-Content-Type: text/markdown
35
+
36
+ # roschema
37
+
38
+ [![CI](https://github.com/justagist/roschema/actions/workflows/ci.yml/badge.svg)](https://github.com/justagist/roschema/actions/workflows/ci.yml)
39
+ [![PyPI](https://img.shields.io/pypi/v/roschema.svg)](https://pypi.org/project/roschema/)
40
+ [![Python versions](https://img.shields.io/pypi/pyversions/roschema.svg)](https://pypi.org/project/roschema/)
41
+ [![License: Apache-2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
42
+ [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
43
+ [![Checked with mypy](https://img.shields.io/badge/mypy-strict-blue.svg)](https://mypy-lang.org/)
44
+
45
+ Catch breaking ROS 2 interface changes in the PR, not on the robot.
46
+
47
+ `roschema` detects breaking changes, lints and formats ROS 2 interface
48
+ definitions (`.msg`, `.srv`, `.action`) with zero ROS dependencies, so it runs
49
+ in a bare CI container.
50
+
51
+ ![roschema flagging a breaking interface change](https://raw.githubusercontent.com/justagist/_assets/main/roschema/roschema_demo.png)
52
+
53
+ ## Quickstart
54
+
55
+ From the root of your workspace or repository, compare the working tree against
56
+ the base branch (needs [`uv`](https://docs.astral.sh/uv/); nothing else to
57
+ install):
58
+
59
+ ```bash
60
+ uvx roschema check --against origin/main
61
+ ```
62
+
63
+ roschema finds every interface package below the current directory, compares
64
+ it with the same packages at `origin/main`, and lists each breaking change with
65
+ its rule ID, location and a suggested semver bump. It exits `1` when an error is
66
+ found, so it works as a CI gate as is; to run it on every pull request, use the
67
+ [GitHub Action](#github-action).
68
+
69
+ Next steps:
70
+
71
+ - [What gets reported](#what-gets-reported) explains levels and severities.
72
+ - The [rule reference](docs/rules.md) lists every check (`RS***`) and lint
73
+ (`RSL***`) rule, starting with a one-line overview of each.
74
+ - The [roschema-demo](https://github.com/justagist/roschema-demo) repo has a
75
+ `robot_msgs` package with a passing and a failing branch to compare, plus a
76
+ ready-to-copy workflow.
77
+
78
+ ## Installation
79
+
80
+ Requires Python 3.10+; a ROS installation is **not** needed.
81
+
82
+ ```bash
83
+ uv tool install roschema # or: pipx install roschema
84
+ uv tool install 'roschema[bag]' # with MCAP bag support, for bag-check
85
+ ```
86
+
87
+ Inside a virtualenv, `pip install roschema` works too. Outside one, Ubuntu 24.04
88
+ (the Jazzy platform) refuses `pip install` into the system Python
89
+ ("externally-managed-environment", PEP 668), so prefer `uv tool` or `pipx`.
90
+
91
+ To run it without installing anything, use `uvx roschema ...` as in the
92
+ quickstart. The [GitHub Action](#github-action) needs no installation either.
93
+
94
+ ## What gets reported
95
+
96
+ CDR, the ROS 2 wire format, is positional: fields are serialised in declaration
97
+ order with no field tags. Adding, removing, reordering or retyping a field
98
+ therefore breaks the wire format even when your code still compiles, so
99
+ roschema compares interfaces structurally rather than by text.
100
+
101
+ Every rule belongs to one or more **compatibility levels**. `--level` picks a
102
+ cumulative bundle of levels to report; the default is `source`.
103
+
104
+ | Level | Reports | Example |
105
+ |---|---|---|
106
+ | `wire` | CDR byte-stream compatibility only | a field added (RS002) |
107
+ | `source` | wire + generated C++/Python API | a constant removed (RS020) |
108
+ | `introspection` | source + name-based tooling (bags, echo, YAML) | a field renamed (RS005) |
109
+ | `behavior` | introspection + semantics (defaults, constant values) | a default changed (RS030) |
110
+
111
+ Levels decide **what is reported**; each rule's **severity** (`error`, `warn`
112
+ or `off`) decides whether a reported finding fails the run. Only errors fail;
113
+ warnings are printed but exit `0`. Rules that are off by default (such as RS060)
114
+ are reported at every level once you enable them.
115
+
116
+ The [rule overview](docs/rules.md#overview) shows each rule's levels and default
117
+ severity in one table.
118
+
119
+ ## Common usage
120
+
121
+ ```bash
122
+ # breaking-change gate in a PR (compare against the base branch)
123
+ roschema check --against origin/main
124
+
125
+ # check before pushing, against the last commit
126
+ roschema check --against HEAD
127
+
128
+ # scope to one package, or a subtree with a glob (and exclude vendored packages)
129
+ roschema check src/my_msgs --against origin/main
130
+ roschema check 'src/**' --against origin/main --exclude 'vendor_*'
131
+
132
+ # only fail on wire-level breaks (loosest gate)
133
+ roschema check --against origin/main --level wire
134
+
135
+ # machine-readable output for tooling
136
+ roschema check --against origin/main --format json
137
+
138
+ # style/naming check, and a formatting gate for CI
139
+ roschema lint
140
+ roschema fmt --check
141
+
142
+ # pin a release baseline, then check against it later (no git needed)
143
+ roschema freeze -o roschema.lock
144
+ roschema check --against roschema.lock
145
+
146
+ # is this recorded bag still compatible with the installed interfaces?
147
+ roschema bag-check recording.mcap
148
+
149
+ # what would be checked? (discovered packages and interfaces)
150
+ roschema ls
151
+ ```
152
+
153
+ ## Checking
154
+
155
+ ```
156
+ roschema check [TARGET] --against BASELINE [--level LEVEL] [--format text|json|github]
157
+ [--deep | --no-deep] [--exit-zero] [--warnings-as-errors] [--allow-empty]
158
+ [--config PATH] [--exclude GLOB]
159
+ ```
160
+
161
+ `TARGET` selects what to check; omit it to check every interface package below
162
+ the current directory.
163
+
164
+ - **A path** checks the packages below that directory.
165
+ - **A package name** (its `<name>` in `package.xml`) checks that package's
166
+ source below the current directory. If there is no such source package, the
167
+ installed copy from the ament index (`AMENT_PREFIX_PATH`) is used, with a
168
+ warning.
169
+ - **A glob** such as `'src/**'` checks every package below the matching
170
+ directories, including packages that exist only in a Git or directory
171
+ baseline (so deleting a package is reported).
172
+
173
+ Discovery skips trees marked with `COLCON_IGNORE` or `AMENT_IGNORE`, which
174
+ includes colcon's `build/`, `install/` and `log/` directories.
175
+
176
+ `--against` accepts a git ref (validated with `git rev-parse`), a directory, an
177
+ MCAP bag (`.mcap`) or a lockfile (`roschema.lock`); the type is detected
178
+ automatically. Initialised git submodules are compared at the commit the
179
+ baseline records for them.
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
+
188
+ Limitations:
189
+
190
+ - When a git ref and a directory share a name, the git ref wins; prefix the
191
+ directory with `./` (`--against ./main`) to use it instead.
192
+ - Lockfile and MCAP baselines have no real paths, so use a workspace or package
193
+ target with them rather than a path glob.
194
+ - A submodule whose recorded commit is not available locally (for example after
195
+ a shallow fetch) is skipped with a warning.
196
+
197
+ ### Deep checking
198
+
199
+ Without `--deep`, an embedded type is compared by name only. `check --deep`
200
+ resolves nested types through the workspace and the ament index and reports
201
+ transitive breakage (RS110): an interface that embeds, directly or through other
202
+ types, a type whose wire layout changed is flagged even when its own definition
203
+ did not change, with the chain that connects them. RS110 takes the severity of
204
+ the change it traces back to, and suppressed or allowlisted changes are not
205
+ followed.
206
+
207
+ ## Linting and formatting
208
+
209
+ ```
210
+ roschema lint [TARGET] [--format text|json|github] [--rules RULE=SEV] [--exit-zero]
211
+ [--warnings-as-errors] [--config PATH] [--exclude GLOB]
212
+ roschema fmt [TARGET] [--check] [--exclude GLOB]
213
+ ```
214
+
215
+ `lint` checks a single tree, with no baseline. The naming rules follow rosidl:
216
+ field names in snake_case (RSL001), constants in UPPER_SNAKE_CASE (RSL002) and
217
+ interface names in PascalCase (RSL003). It also flags ambiguous `char` and
218
+ scalar `byte` types (RSL005) and fields shared by a service's request and
219
+ response (RSL010). RSL004 (missing documentation) and RSL006 (unbounded
220
+ fields) are off by default. Enable rules in `[lint.rules]`, or per run with
221
+ `--rules`, e.g. `--rules RSL004=warn --rules RSL001=off` (a bare `--rules RSL004`
222
+ means `=warn`). `--rules` is repeatable and overrides configuration.
223
+
224
+ `fmt` formats interface files canonically: a single space between type and
225
+ name, no spaces around a constant's `=`, two spaces before a trailing comment
226
+ and one trailing newline. Comments, blank lines and section separators are kept,
227
+ and so are inline `# roschema: ignore=` suppressions. Formatting is idempotent
228
+ and never changes what `check` reports; `fmt --check` exits `1` if any file
229
+ would change.
230
+
231
+ ## Freeze (baselines without git)
232
+
233
+ ```
234
+ roschema freeze [TARGET] -o roschema.lock
235
+ roschema check [TARGET] --against roschema.lock
236
+ ```
237
+
238
+ `freeze` writes a lockfile with each interface's canonical (formatted) text and
239
+ its sha256, so a release tarball can carry its own baseline. `check --against
240
+ roschema.lock` verifies the hashes and compares against it.
241
+
242
+ The lockfile also records the roschema `tool_version`, so upgrading roschema
243
+ changes that one line on the next `freeze` even when the interfaces are the
244
+ same. Comparison ignores it.
245
+
246
+ ## Checking against a recorded bag
247
+
248
+ ```
249
+ roschema bag-check BAG.mcap [TARGETS...] [--level LEVEL] [--config PATH]
250
+ [--format text|json|github] [--exit-zero] [--warnings-as-errors]
251
+ [--exclude GLOB]
252
+ ```
253
+
254
+ Needs the `bag` extra (`uv tool install 'roschema[bag]'`).
255
+
256
+ `bag-check` uses the interface definitions recorded in an MCAP bag as the
257
+ baseline and asks whether **current code can still decode the recording**. It
258
+ therefore reports at the `wire` level by default; pass `--level` (or set
259
+ `[check] level`) to include source-level changes such as removed constants.
260
+
261
+ The bag's types are compared with the packages installed in the sourced
262
+ environment (the ament index), so system packages such as `sensor_msgs` are
263
+ compared with their installed definitions. Optional `TARGETS` (paths or package
264
+ names) replace the installed copy of their packages with your source: a package
265
+ you are editing is checked as it is now, and interfaces you deleted from it are
266
+ reported as removed. If two targets provide the same package, the last one
267
+ wins. A bag type found neither in the environment nor in a target is reported
268
+ as removed (RS040).
269
+
270
+ Limitations:
271
+
272
+ - `ros2msg` schemas are fully supported, including the concatenated definitions
273
+ of dependent types and the `SRV:` blocks recorded for service event topics.
274
+ - `ros2idl` schemas are converted on a best-effort basis for the subset that has
275
+ a `.msg` equivalent. Other encodings and IDL constructs (enums, unions,
276
+ typedefs, annotations, inheritance, constants outside structs, `wchar`) are a
277
+ tool error (exit `2`).
278
+ - Topics recorded without a definition (rosbag2 leaves them empty when it cannot
279
+ find one) are skipped and named in a note.
280
+ - Two different definitions of the same type in one bag are a tool error.
281
+ Definitions that differ only in formatting or comments are accepted.
282
+
283
+ ## Configuration
284
+
285
+ Settings come from `roschema.toml`, or from `[tool.roschema]` in
286
+ `pyproject.toml`. A file at the workspace root applies to every package, and a
287
+ file in a package directory overrides it for that package. Precedence is CLI >
288
+ package > workspace > defaults. roschema looks for the workspace file in the
289
+ target directory and its parents, up to the git repository root (or the
290
+ filesystem root outside git). In each directory, `roschema.toml` takes
291
+ precedence over `pyproject.toml`.
292
+
293
+ ```toml
294
+ [check]
295
+ level = "source" # wire | source | introspection | behavior
296
+ deep = false # --deep / --no-deep overrides this
297
+
298
+ [check.rules]
299
+ RS008 = "off" # off | warn | error
300
+
301
+ [check.allow]
302
+ # an intentional break: a fully-qualified target and a mandatory reason
303
+ "my_msgs/msg/State.old_field" = { rules = ["RS001"], reason = "removed in 2.0, see #142" }
304
+
305
+ [lint.rules]
306
+ RSL004 = "warn"
307
+ ```
308
+
309
+ Unknown keys, unknown rule IDs (or IDs in the wrong section) and invalid values
310
+ are configuration errors (exit `2`), so a misspelt setting cannot silently do
311
+ nothing. Setting a rule's severity does not change which levels are reported,
312
+ except for rules that are off by default (see
313
+ [What gets reported](#what-gets-reported)).
314
+
315
+ ### Suppressing findings
316
+
317
+ Suppressed findings are counted in the summary but never fail the run.
318
+
319
+ **Inline:** a trailing comment on the member line in the new version, e.g.
320
+ `float64 theta # roschema: ignore=RS003`. Separate several IDs with commas.
321
+ This works for lint rules too.
322
+
323
+ **Allowlist:** an entry in `[check.allow]`, keyed by the finding's target:
324
+
325
+ ```
326
+ <package>/<kind>/<Name>[.<section>][.<member>]
327
+ ```
328
+
329
+ - `<kind>` is `msg`, `srv` or `action`.
330
+ - `<section>` is given for services (`request`, `response`) and actions (`goal`,
331
+ `result`, `feedback`) only, e.g. `my_srvs/srv/SetMode.request.mode`.
332
+ - Findings about a whole interface or section (RS040 interface removed, RS110,
333
+ RS004 fields reordered) have no `<member>`, e.g. `my_msgs/msg/Old`.
334
+
335
+ `reason` is required. `rules` limits the entry to those rule IDs; omit it to
336
+ allow every rule for that target.
337
+
338
+ ## Output
339
+
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:
342
+
343
+ - **MAJOR** when the package has a wire- or source-level error;
344
+ - **MINOR** when its only notable change is an added field (RS002) whose
345
+ severity you lowered to `warn`;
346
+ - **PATCH** otherwise.
347
+
348
+ The advice only covers reported findings. Additions such as a new interface or
349
+ constant are not findings, so they never raise the advice to MINOR on their own.
350
+
351
+ **`--format github`** prints what was compared, then emits `::error`/`::warning`
352
+ workflow commands, which GitHub shows as annotations on the PR diff.
353
+
354
+ **`--format json`** emits a stable document (`version: 1`):
355
+
356
+ ```json
357
+ {
358
+ "version": 1,
359
+ "findings": [
360
+ {
361
+ "rule": "RS003",
362
+ "severity": "error",
363
+ "level": "wire",
364
+ "package": "my_msgs",
365
+ "interface": "my_msgs/msg/Pose",
366
+ "section": "message",
367
+ "target": "theta",
368
+ "message": "type changed float32 -> float64",
369
+ "old": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
370
+ "new": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
371
+ "suppressed": false
372
+ }
373
+ ],
374
+ "summary": {
375
+ "total": 1,
376
+ "errors": 1,
377
+ "warnings": 0,
378
+ "suppressed": 0,
379
+ "advisories": { "my_msgs": "MAJOR" }
380
+ },
381
+ "coverage": {
382
+ "packages": 1,
383
+ "compared": 1,
384
+ "target_only": 0,
385
+ "baseline_only": 0
386
+ }
387
+ }
388
+ ```
389
+
390
+ `old`/`new` are `null` when there is no corresponding side (a removed interface
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`.
393
+
394
+ ## Exit codes
395
+
396
+ | Code | `check`, `bag-check`, `lint` | `fmt --check` | Other commands |
397
+ |---|---|---|---|
398
+ | `0` | no unsuppressed errors (warnings allowed) | every file formatted | success |
399
+ | `1` | at least one unsuppressed error | a file would change | n/a |
400
+ | `2` | tool or usage error (bad target, baseline, configuration or file, or an empty baseline) | tool or usage error | tool or usage error |
401
+
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`.
405
+
406
+ ## GitHub Action
407
+
408
+ A composite Action runs `roschema check` on the runner (no setup needed) and
409
+ posts the findings as annotations on the PR diff:
410
+
411
+ ```yaml
412
+ # .github/workflows/interfaces.yml
413
+ on: pull_request
414
+ jobs:
415
+ interfaces:
416
+ runs-on: ubuntu-latest
417
+ steps:
418
+ - uses: actions/checkout@v4
419
+ with:
420
+ fetch-depth: 0 # needed for the git-ref baseline
421
+ - uses: justagist/roschema/action@main
422
+ with:
423
+ against: origin/${{ github.base_ref }}
424
+ # version: "roschema==0.3.0" # pin a release for reproducible results
425
+ # level: source
426
+ # deep: "true"
427
+ # warnings-as-errors: "true"
428
+ # exclude: "vendor_msgs* legacy_msgs/msg/Old"
429
+ ```
430
+
431
+ | Input | Default | Meaning |
432
+ |---|---|---|
433
+ | `target` | `.` | path, package name or glob, as for the CLI |
434
+ | `against` | `origin/main` | git ref, directory, `.mcap` bag or `roschema.lock` |
435
+ | `level` | (empty) | reporting level; empty uses the configured level, else `source` |
436
+ | `format` | `github` | `text`, `json` or `github` |
437
+ | `deep` | `"false"` | `"true"` adds `--deep` |
438
+ | `allow-empty` | `"false"` | `"true"` adds `--allow-empty` |
439
+ | `warnings-as-errors` | `"false"` | `"true"` adds `--warnings-as-errors` |
440
+ | `exclude` | (empty) | whitespace-separated globs passed as `--exclude` |
441
+ | `version` | `roschema` | the roschema requirement installed with `uvx` |
442
+
443
+ By default the Action installs the latest roschema release on every run, so new
444
+ rules can change results without any change on your side. Set `version` to pin
445
+ a release instead. A lockfile or directory baseline needs no `fetch-depth: 0`,
446
+ because it is part of the checkout.
447
+
448
+ ## Troubleshooting
449
+
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:
453
+
454
+ - the packages are not below a `COLCON_IGNORE` or `AMENT_IGNORE` marker;
455
+ - a package-name target did not fall back to the installed copy (the warning
456
+ says so); run from the directory that contains the source, or pass its path;
457
+ - packages in git submodules were not skipped (the warning names any submodule
458
+ whose baseline commit is missing; fetch it with `git submodule update`);
459
+ - the change's level is included: a constant change is not reported under
460
+ `--level wire`, and `bag-check` defaults to `wire`.
461
+
462
+ **A configuration error names an unknown key.** Each table accepts only the keys
463
+ shown in [Configuration](#configuration); the error suggests the closest one.
464
+
465
+ ## Rule reference
466
+
467
+ Every rule, its levels, default severity, rationale and an example is
468
+ documented in [docs/rules.md](docs/rules.md), generated from the rule metadata.
469
+
470
+ ## Non-goals
471
+
472
+ roschema deliberately does **not**:
473
+
474
+ - generate migration or translation rules;
475
+ - parse ROS 1 `.msg` (md5 semantics differ);
476
+ - parse hand-written `.idl` beyond the MCAP-embedded subset;
477
+ - provide any registry or server component;
478
+ - parse `CMakeLists.txt` for discovery (directory convention + ament index only).
479
+
480
+ ## Relationship to REP-2011 and type hashes
481
+
482
+ These are complementary layers, not alternatives:
483
+
484
+ - **roschema** prevents *accidental* breaks at PR time, before anything ships.
485
+ - **RIHS type hashes** (REP-2011) detect a pub/sub *mismatch at runtime* -- after
486
+ the fact, on the robot.
487
+ - **REP-2011 type-description distribution / translation** mitigates *intentional*
488
+ evolution by carrying enough information to translate between versions.
489
+
490
+ roschema is the earliest gate: catch the break in review so the later layers
491
+ never have to.
492
+
493
+ ## Contributing
494
+
495
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the development setup, checks and
496
+ release process.