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