roschema 0.1.3__tar.gz → 0.2.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.
- {roschema-0.1.3 → roschema-0.2.0}/.github/workflows/ci.yml +3 -1
- {roschema-0.1.3 → roschema-0.2.0}/.github/workflows/release.yml +1 -0
- {roschema-0.1.3 → roschema-0.2.0}/CHANGELOG.md +39 -0
- {roschema-0.1.3 → roschema-0.2.0}/PKG-INFO +8 -3
- {roschema-0.1.3 → roschema-0.2.0}/README.md +7 -2
- {roschema-0.1.3 → roschema-0.2.0}/action/action.yml +12 -5
- {roschema-0.1.3 → roschema-0.2.0}/docs/rules.md +8 -8
- {roschema-0.1.3 → roschema-0.2.0}/pyproject.toml +4 -2
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/cli.py +95 -29
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/config.py +34 -27
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/deep.py +21 -7
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/differ.py +28 -24
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/discover.py +1 -3
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/resolve.py +29 -7
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/freeze.py +1 -3
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/ir.py +2 -6
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/loader.py +1 -3
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/mcap_fs.py +61 -28
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/parser/__init__.py +1 -1
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/parser/core.py +17 -19
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/github.py +13 -3
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/json_report.py +15 -9
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/summary.py +2 -6
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs002_field_added.py +1 -1
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs006_array_kind_changed.py +5 -4
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs007_bound_tightened.py +1 -1
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs008_bound_loosened.py +1 -1
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs060_message_version.py +1 -4
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs110_transitive.py +2 -1
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/gendocs.py +3 -4
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl005_discouraged_types.py +6 -4
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/model.py +1 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/vfs.py +52 -6
- {roschema-0.1.3 → roschema-0.2.0}/tests/_helpers.py +9 -4
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_action.py +48 -23
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_check_cli.py +107 -6
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_config.py +29 -12
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_deep.py +54 -8
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_differ.py +20 -20
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_discovery.py +19 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_fmt.py +1 -5
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_freeze.py +1 -3
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_lint.py +12 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_mcap.py +105 -5
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_parser.py +19 -3
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_report_formats.py +13 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_vfs.py +56 -1
- {roschema-0.1.3 → roschema-0.2.0}/.gitignore +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/LICENSE +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/__init__.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/__init__.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/model.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/formatter.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/parser/errors.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/py.typed +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/__init__.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/text.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/__init__.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/__init__.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs001_field_removed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs003_field_type_changed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs004_fields_reordered.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs005_field_renamed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs020_constant_removed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs021_constant_value_changed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs022_constant_type_changed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs030_default_changed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs040_interface_removed.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/__init__.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/_common.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl001_field_snake_case.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl002_constant_upper_snake.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl003_name_pascal_case.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl004_missing_doc.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl006_unbounded.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl010_srv_shadow.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/registry.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/conftest.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/AMENT_IGNORE +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/COLCON_IGNORE +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/golden_check.json +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/action/Move.action +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/ArrayKind.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundLoose.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundTight.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundedNest.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Constants.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Empty.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldAdded.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldRemoved.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Renamed.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Reordered.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Suppressed.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/TypeChanged.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Unchanged.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/WeirdSpacing.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/package.xml +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/srv/DoThing.srv +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/action/Move.action +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/ArrayKind.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundLoose.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundTight.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundedNest.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Constants.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Empty.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldAdded.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldRemoved.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Removed.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Renamed.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Reordered.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Suppressed.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/TypeChanged.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Unchanged.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/WeirdSpacing.msg +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/package.xml +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/srv/DoThing.srv +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_cli_ls.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_cli_version.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_gendocs.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_golden.py +0 -0
- {roschema-0.1.3 → roschema-0.2.0}/tests/test_rules.py +0 -0
|
@@ -3,6 +3,45 @@
|
|
|
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.0] - 2026-11-03
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `bag-check --level` and `--config`.
|
|
11
|
+
- Git baselines read initialised submodules at their recorded commit, and warn
|
|
12
|
+
when that commit is not available locally.
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
|
|
16
|
+
- `bag-check` reports at the `wire` level by default, since constant and other
|
|
17
|
+
source-level changes do not stop current code decoding a recording.
|
|
18
|
+
- Package-name targets resolve to the workspace source package before the
|
|
19
|
+
installed copy, with a warning when the installed copy is used.
|
|
20
|
+
- Adding or removing a sequence bound (`T[]` <-> `T[<=N]`) is reported as a
|
|
21
|
+
bound change (RS007/RS008) rather than an array-kind change (RS006).
|
|
22
|
+
- RS110 takes the severity of the change it traces back to and ignores
|
|
23
|
+
suppressed, allowlisted or disabled changes.
|
|
24
|
+
- Configuring a rule's severity no longer reports it outside the selected
|
|
25
|
+
level; only rules that are off by default (such as RS060) bypass the level.
|
|
26
|
+
- The GitHub Action passes `--level` only when set, so configured levels apply.
|
|
27
|
+
- RS002 is tagged `wire` only, and RSL005 explains why `char` and scalar `byte`
|
|
28
|
+
are ambiguous instead of calling them deprecated.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- `bag-check` skips schemas recorded without a definition and compares service
|
|
33
|
+
event schemas (`SRV:` blocks) instead of failing.
|
|
34
|
+
- A same-package type written with its package (`pkg/Type` inside `pkg`) is no
|
|
35
|
+
longer reported as a type change.
|
|
36
|
+
- Duplicate field or constant names are parse errors, as in rosidl.
|
|
37
|
+
- Unknown configuration keys are configuration errors, with a suggestion.
|
|
38
|
+
- `lint --format json` no longer includes semver advisories.
|
|
39
|
+
- Text output no longer wraps long lines when not writing to a terminal.
|
|
40
|
+
- Files are read and written as UTF-8 regardless of locale.
|
|
41
|
+
- GitHub annotation text is escaped.
|
|
42
|
+
- `wchar` in MCAP IDL schemas is rejected instead of narrowed to `char`.
|
|
43
|
+
- The README's GitHub Action example references an existing ref (`@main`).
|
|
44
|
+
|
|
6
45
|
## [0.1.3] - 2026-11-03
|
|
7
46
|
|
|
8
47
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: roschema
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.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
|
|
@@ -173,7 +173,7 @@ roschema fmt [TARGET] [--check]
|
|
|
173
173
|
|
|
174
174
|
`lint` checks a single tree (no baseline). Naming rules follow rosidl: field
|
|
175
175
|
names snake_case (RSL001), constants UPPER_SNAKE (RSL002), interface names
|
|
176
|
-
PascalCase (RSL003); it also flags
|
|
176
|
+
PascalCase (RSL003); it also flags ambiguous `char`/scalar-`byte` (RSL005) and
|
|
177
177
|
request/response field shadowing in services (RSL010). RSL004 (missing doc) and
|
|
178
178
|
RSL006 (unbounded) are off by default; enable via `[lint.rules]` or per run with
|
|
179
179
|
`--rules` (e.g. `--rules RSL004=warn --rules RSL001=off`; a bare `--rules RSL004`
|
|
@@ -307,9 +307,10 @@ jobs:
|
|
|
307
307
|
- uses: actions/checkout@v4
|
|
308
308
|
with:
|
|
309
309
|
fetch-depth: 0 # needed for the git-ref baseline
|
|
310
|
-
- uses: justagist/roschema/action@
|
|
310
|
+
- uses: justagist/roschema/action@main
|
|
311
311
|
with:
|
|
312
312
|
against: origin/${{ github.base_ref }}
|
|
313
|
+
# version: "roschema==0.2.0" # pin a release for reproducible results
|
|
313
314
|
# level: source
|
|
314
315
|
# deep: "true"
|
|
315
316
|
# exclude: "vendor_msgs* legacy_msgs/msg/Old"
|
|
@@ -319,6 +320,10 @@ jobs:
|
|
|
319
320
|
`roschema.lock`). A lockfile or directory baseline needs no `fetch-depth: 0`
|
|
320
321
|
because it is a plain file in the checkout.
|
|
321
322
|
|
|
323
|
+
By default the Action installs the latest roschema release, so new rules can
|
|
324
|
+
change results without any change on your side. Uncomment `version` to pin a
|
|
325
|
+
release instead.
|
|
326
|
+
|
|
322
327
|
## Rule reference
|
|
323
328
|
|
|
324
329
|
Every rule, its levels, default severity and rationale is documented in
|
|
@@ -139,7 +139,7 @@ roschema fmt [TARGET] [--check]
|
|
|
139
139
|
|
|
140
140
|
`lint` checks a single tree (no baseline). Naming rules follow rosidl: field
|
|
141
141
|
names snake_case (RSL001), constants UPPER_SNAKE (RSL002), interface names
|
|
142
|
-
PascalCase (RSL003); it also flags
|
|
142
|
+
PascalCase (RSL003); it also flags ambiguous `char`/scalar-`byte` (RSL005) and
|
|
143
143
|
request/response field shadowing in services (RSL010). RSL004 (missing doc) and
|
|
144
144
|
RSL006 (unbounded) are off by default; enable via `[lint.rules]` or per run with
|
|
145
145
|
`--rules` (e.g. `--rules RSL004=warn --rules RSL001=off`; a bare `--rules RSL004`
|
|
@@ -273,9 +273,10 @@ jobs:
|
|
|
273
273
|
- uses: actions/checkout@v4
|
|
274
274
|
with:
|
|
275
275
|
fetch-depth: 0 # needed for the git-ref baseline
|
|
276
|
-
- uses: justagist/roschema/action@
|
|
276
|
+
- uses: justagist/roschema/action@main
|
|
277
277
|
with:
|
|
278
278
|
against: origin/${{ github.base_ref }}
|
|
279
|
+
# version: "roschema==0.2.0" # pin a release for reproducible results
|
|
279
280
|
# level: source
|
|
280
281
|
# deep: "true"
|
|
281
282
|
# exclude: "vendor_msgs* legacy_msgs/msg/Old"
|
|
@@ -285,6 +286,10 @@ jobs:
|
|
|
285
286
|
`roschema.lock`). A lockfile or directory baseline needs no `fetch-depth: 0`
|
|
286
287
|
because it is a plain file in the checkout.
|
|
287
288
|
|
|
289
|
+
By default the Action installs the latest roschema release, so new rules can
|
|
290
|
+
change results without any change on your side. Uncomment `version` to pin a
|
|
291
|
+
release instead.
|
|
292
|
+
|
|
288
293
|
## Rule reference
|
|
289
294
|
|
|
290
295
|
Every rule, its levels, default severity and rationale is documented in
|
|
@@ -12,8 +12,10 @@ inputs:
|
|
|
12
12
|
description: Baseline -- a git ref, directory, .mcap bag or roschema.lock.
|
|
13
13
|
default: origin/main
|
|
14
14
|
level:
|
|
15
|
-
description:
|
|
16
|
-
|
|
15
|
+
description: >-
|
|
16
|
+
Reporting level (wire, source, introspection, behavior). Empty uses the
|
|
17
|
+
level configured in roschema.toml, else source.
|
|
18
|
+
default: ""
|
|
17
19
|
format:
|
|
18
20
|
description: Output format (text, json, github).
|
|
19
21
|
default: github
|
|
@@ -26,7 +28,10 @@ inputs:
|
|
|
26
28
|
whitespace only so patterns reach roschema literally.
|
|
27
29
|
default: ""
|
|
28
30
|
version:
|
|
29
|
-
description:
|
|
31
|
+
description: >-
|
|
32
|
+
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.0") for
|
|
34
|
+
reproducible results.
|
|
30
35
|
default: roschema
|
|
31
36
|
|
|
32
37
|
runs:
|
|
@@ -46,8 +51,10 @@ runs:
|
|
|
46
51
|
set -euo pipefail
|
|
47
52
|
args=(check "$ROSCHEMA_TARGET"
|
|
48
53
|
--against "$ROSCHEMA_AGAINST"
|
|
49
|
-
--level "$ROSCHEMA_LEVEL"
|
|
50
54
|
--format "$ROSCHEMA_FORMAT")
|
|
55
|
+
if [ -n "$ROSCHEMA_LEVEL" ]; then
|
|
56
|
+
args+=(--level "$ROSCHEMA_LEVEL")
|
|
57
|
+
fi
|
|
51
58
|
if [ "$ROSCHEMA_DEEP" = "true" ]; then
|
|
52
59
|
args+=(--deep)
|
|
53
60
|
fi
|
|
@@ -55,4 +62,4 @@ runs:
|
|
|
55
62
|
for glob in "${excludes[@]}"; do
|
|
56
63
|
args+=(--exclude "$glob")
|
|
57
64
|
done
|
|
58
|
-
uvx --from "$ROSCHEMA_VERSION" roschema "${args[@]}"
|
|
65
|
+
uvx --refresh-package roschema --from "$ROSCHEMA_VERSION" roschema "${args[@]}"
|
|
@@ -26,7 +26,7 @@ int32 a
|
|
|
26
26
|
|
|
27
27
|
A field was added to an interface section.
|
|
28
28
|
|
|
29
|
-
- Levels: wire
|
|
29
|
+
- Levels: wire
|
|
30
30
|
- Default severity: error
|
|
31
31
|
|
|
32
32
|
*Rationale:* Appending a field is source-compatible but still changes the CDR wire layout, so a new publisher and an old subscriber cannot interoperate.
|
|
@@ -91,12 +91,12 @@ float32 yaw
|
|
|
91
91
|
|
|
92
92
|
### RS006 -- Array kind changed
|
|
93
93
|
|
|
94
|
-
A field changed between
|
|
94
|
+
A field changed between scalar, fixed-size array and sequence forms.
|
|
95
95
|
|
|
96
96
|
- Levels: wire, source
|
|
97
97
|
- Default severity: error
|
|
98
98
|
|
|
99
|
-
*Rationale:*
|
|
99
|
+
*Rationale:* A sequence carries a length prefix that a fixed-size array and a scalar do not, so the CDR layout and the generated container type both change. Adding or removing a sequence bound is reported as RS007 or RS008 instead.
|
|
100
100
|
|
|
101
101
|
```
|
|
102
102
|
# before
|
|
@@ -107,7 +107,7 @@ int32[8] data
|
|
|
107
107
|
|
|
108
108
|
### RS007 -- Bound tightened
|
|
109
109
|
|
|
110
|
-
A
|
|
110
|
+
A sequence or string gained a bound or had its bound reduced.
|
|
111
111
|
|
|
112
112
|
- Levels: wire
|
|
113
113
|
- Default severity: error
|
|
@@ -123,7 +123,7 @@ string<=16 name
|
|
|
123
123
|
|
|
124
124
|
### RS008 -- Bound loosened
|
|
125
125
|
|
|
126
|
-
A
|
|
126
|
+
A sequence or string lost its bound or had its bound increased.
|
|
127
127
|
|
|
128
128
|
- Levels: wire
|
|
129
129
|
- Default severity: warn
|
|
@@ -239,7 +239,7 @@ int32 b
|
|
|
239
239
|
|
|
240
240
|
### RS110 -- Transitively broken by an embedded type
|
|
241
241
|
|
|
242
|
-
An interface embeds (directly or through nested types) another interface whose wire layout changed, so it is broken even though its own definition did not change. Reported only with --deep.
|
|
242
|
+
An interface embeds (directly or through nested types) another interface whose wire layout changed, so it is broken even though its own definition did not change. Reported only with --deep, with the severity of the reported change it traces back to; suppressed changes are not followed.
|
|
243
243
|
|
|
244
244
|
- Levels: wire
|
|
245
245
|
- Default severity: error
|
|
@@ -290,11 +290,11 @@ A field has no trailing documentation comment.
|
|
|
290
290
|
|
|
291
291
|
### RSL005 -- Discouraged type
|
|
292
292
|
|
|
293
|
-
Uses `char`, or a scalar `byte`,
|
|
293
|
+
Uses `char`, or a scalar `byte`, whose generated types are ambiguous.
|
|
294
294
|
|
|
295
295
|
- Default severity: warn
|
|
296
296
|
|
|
297
|
-
*Rationale:* ROS 2
|
|
297
|
+
*Rationale:* rosidl generates `char` as `uint8` although the ROS 2 type table lists it as a DDS `char`, and a scalar `byte` becomes a Python `bytes` object rather than an integer; `uint8` states the intent. `byte[]` remains idiomatic for opaque binary data.
|
|
298
298
|
|
|
299
299
|
### RSL006 -- Unbounded string or array
|
|
300
300
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "roschema"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.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"
|
|
@@ -64,4 +64,6 @@ files = ["src"]
|
|
|
64
64
|
|
|
65
65
|
[tool.pytest.ini_options]
|
|
66
66
|
testpaths = ["tests"]
|
|
67
|
-
|
|
67
|
+
# A sourced ROS environment auto-loads its launch testing plugins, which fail to
|
|
68
|
+
# import in a virtualenv without ROS's Python dependencies (e.g. PyYAML).
|
|
69
|
+
addopts = "-q -p no:launch_testing -p no:launch_ros -p no:launch_pytest"
|
|
@@ -6,6 +6,7 @@ import json
|
|
|
6
6
|
import os
|
|
7
7
|
import posixpath
|
|
8
8
|
from collections.abc import Callable
|
|
9
|
+
from dataclasses import replace
|
|
9
10
|
from pathlib import Path
|
|
10
11
|
from typing import Annotated
|
|
11
12
|
|
|
@@ -23,6 +24,7 @@ from .config import (
|
|
|
23
24
|
from .deep import transitive_findings
|
|
24
25
|
from .differ import diff_interfaces
|
|
25
26
|
from .discovery import (
|
|
27
|
+
DiscoveredPackage,
|
|
26
28
|
Resolution,
|
|
27
29
|
apply_exclude,
|
|
28
30
|
resolve_ament,
|
|
@@ -34,6 +36,7 @@ from .formatter import format_text
|
|
|
34
36
|
from .freeze import build_lock
|
|
35
37
|
from .ir import InterfaceFile
|
|
36
38
|
from .loader import load_interfaces
|
|
39
|
+
from .mcap_fs import McapTypeFS
|
|
37
40
|
from .parser import ParseError
|
|
38
41
|
from .report import build_github, build_json, render_lint, render_text
|
|
39
42
|
from .rules import Finding, all_lint_rules, evaluate, lint_interfaces
|
|
@@ -44,8 +47,14 @@ app = typer.Typer(
|
|
|
44
47
|
no_args_is_help=True,
|
|
45
48
|
add_completion=False,
|
|
46
49
|
)
|
|
47
|
-
|
|
48
|
-
|
|
50
|
+
# Soft wrapping leaves line breaks to the terminal, so CI logs and pipes (where
|
|
51
|
+
# Rich assumes 80 columns) keep paths and messages on one line.
|
|
52
|
+
console = Console(soft_wrap=True)
|
|
53
|
+
err_console = Console(stderr=True, soft_wrap=True)
|
|
54
|
+
|
|
55
|
+
# A bag check asks whether current code can still decode the recording, which is
|
|
56
|
+
# a wire question; constant and other source-level changes do not affect it.
|
|
57
|
+
BAG_DEFAULT_LEVEL = "wire"
|
|
49
58
|
|
|
50
59
|
|
|
51
60
|
def _version_callback(value: bool) -> None:
|
|
@@ -79,7 +88,10 @@ def check(
|
|
|
79
88
|
] = None,
|
|
80
89
|
against: Annotated[
|
|
81
90
|
str,
|
|
82
|
-
typer.Option(
|
|
91
|
+
typer.Option(
|
|
92
|
+
"--against",
|
|
93
|
+
help="Baseline: git ref, directory, MCAP bag (.mcap) or lockfile (.lock).",
|
|
94
|
+
),
|
|
83
95
|
] = ..., # type: ignore[assignment]
|
|
84
96
|
fmt: Annotated[
|
|
85
97
|
str, typer.Option("--format", help="Output format: text, json or github.")
|
|
@@ -144,6 +156,17 @@ def bag_check(
|
|
|
144
156
|
fmt: Annotated[
|
|
145
157
|
str, typer.Option("--format", help="Output format: text, json or github.")
|
|
146
158
|
] = "text",
|
|
159
|
+
level: Annotated[
|
|
160
|
+
str | None,
|
|
161
|
+
typer.Option(
|
|
162
|
+
"--level",
|
|
163
|
+
help="Reporting level: wire (default for bags), source, introspection, behavior.",
|
|
164
|
+
),
|
|
165
|
+
] = None,
|
|
166
|
+
config_path: Annotated[
|
|
167
|
+
Path | None,
|
|
168
|
+
typer.Option("--config", help="Explicit config file (roschema.toml or pyproject.toml)."),
|
|
169
|
+
] = None,
|
|
147
170
|
exit_zero: Annotated[
|
|
148
171
|
bool, typer.Option("--exit-zero", help="Exit 0 even when findings exist.")
|
|
149
172
|
] = False,
|
|
@@ -162,6 +185,9 @@ def bag_check(
|
|
|
162
185
|
if fmt not in ("text", "json", "github"):
|
|
163
186
|
err_console.print(f"[red]error:[/red] unknown format {fmt!r}")
|
|
164
187
|
raise typer.Exit(code=2)
|
|
188
|
+
if level is not None and level not in LEVEL_BUNDLES:
|
|
189
|
+
err_console.print(f"[red]error:[/red] unknown level {level!r}")
|
|
190
|
+
raise typer.Exit(code=2)
|
|
165
191
|
if not bag.endswith(".mcap"):
|
|
166
192
|
err_console.print(f"[red]error:[/red] expected a .mcap bag, got {bag!r}")
|
|
167
193
|
raise typer.Exit(code=2)
|
|
@@ -169,15 +195,18 @@ def bag_check(
|
|
|
169
195
|
excludes = exclude or []
|
|
170
196
|
try:
|
|
171
197
|
old_res = resolve_bag(bag)
|
|
198
|
+
_note_skipped_schemas(old_res)
|
|
172
199
|
old_ifaces = load_interfaces(old_res.vfs, apply_exclude(old_res.packages, excludes))
|
|
173
|
-
new_ifaces, new_source = _resolve_current(
|
|
174
|
-
|
|
200
|
+
new_ifaces, new_source, target_packages = _resolve_current(
|
|
201
|
+
old_ifaces, targets or [], excludes
|
|
202
|
+
)
|
|
203
|
+
config = load_config(Path.cwd(), target_packages, config_path)
|
|
175
204
|
except (VfsError, ParseError, ConfigError) as exc:
|
|
176
205
|
err_console.print(f"[red]error:[/red] {exc}")
|
|
177
206
|
raise typer.Exit(code=2) from exc
|
|
178
207
|
|
|
179
208
|
findings = finalize_findings(
|
|
180
|
-
evaluate(diff_interfaces(old_ifaces, new_ifaces)), config,
|
|
209
|
+
evaluate(diff_interfaces(old_ifaces, new_ifaces)), config, level, BAG_DEFAULT_LEVEL
|
|
181
210
|
)
|
|
182
211
|
_emit(findings, fmt, new_source, old_res.source)
|
|
183
212
|
_exit_for(findings, exit_zero)
|
|
@@ -185,24 +214,46 @@ def bag_check(
|
|
|
185
214
|
|
|
186
215
|
def _resolve_current(
|
|
187
216
|
bag_ifaces: dict[str, InterfaceFile], targets: list[str], excludes: list[str]
|
|
188
|
-
) -> tuple[dict[str, InterfaceFile], str]:
|
|
217
|
+
) -> tuple[dict[str, InterfaceFile], str, tuple[DiscoveredPackage, ...]]:
|
|
189
218
|
"""Build the current-side interfaces for a bag check.
|
|
190
219
|
|
|
191
220
|
Starts from the ament index (for the packages the bag references), then
|
|
192
|
-
overlays any explicit targets so they take precedence.
|
|
221
|
+
overlays any explicit targets so they take precedence. Also returns the
|
|
222
|
+
target packages with paths relative to the working directory, so their
|
|
223
|
+
package configuration can be loaded.
|
|
193
224
|
"""
|
|
194
225
|
current: dict[str, InterfaceFile] = {}
|
|
226
|
+
target_packages: list[DiscoveredPackage] = []
|
|
195
227
|
for package in sorted({key.split("/", 1)[0] for key in bag_ifaces}):
|
|
196
228
|
res = resolve_ament(package)
|
|
197
229
|
if res is not None:
|
|
198
230
|
current.update(load_interfaces(res.vfs, apply_exclude(res.packages, excludes)))
|
|
199
231
|
for target in targets:
|
|
200
|
-
res =
|
|
232
|
+
res = _resolve_target(target, include_empty=True)
|
|
201
233
|
replaced = {pkg.name for pkg in res.packages}
|
|
202
234
|
current = {key: iface for key, iface in current.items() if iface.package not in replaced}
|
|
203
235
|
current.update(load_interfaces(res.vfs, apply_exclude(res.packages, excludes)))
|
|
236
|
+
target_packages = [pkg for pkg in target_packages if pkg.name not in replaced]
|
|
237
|
+
if isinstance(res.vfs, RealFS):
|
|
238
|
+
target_packages += [
|
|
239
|
+
replace(pkg, path=os.path.relpath(res.vfs.root / pkg.path, Path.cwd()))
|
|
240
|
+
for pkg in res.packages
|
|
241
|
+
]
|
|
204
242
|
source = "targets+environment" if targets else "environment (ament index)"
|
|
205
|
-
return current, source
|
|
243
|
+
return current, source, tuple(target_packages)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _resolve_target(
|
|
247
|
+
target: str | None, *, allow_empty_glob: bool = False, include_empty: bool = False
|
|
248
|
+
) -> Resolution:
|
|
249
|
+
"""Resolve a CLI target, warning when a package name resolves to an installed copy."""
|
|
250
|
+
res = resolve_target(target, allow_empty_glob=allow_empty_glob, include_empty=include_empty)
|
|
251
|
+
if res.source.startswith("ament:"):
|
|
252
|
+
err_console.print(
|
|
253
|
+
f"[yellow]warning:[/yellow] {target!r} is not a source package under the "
|
|
254
|
+
f"working directory; using the installed copy from {res.source[len('ament:') :]}"
|
|
255
|
+
)
|
|
256
|
+
return res
|
|
206
257
|
|
|
207
258
|
|
|
208
259
|
def _compute(
|
|
@@ -214,10 +265,17 @@ def _compute(
|
|
|
214
265
|
deep: bool | None = None,
|
|
215
266
|
) -> tuple[list[Finding], str, str, str]:
|
|
216
267
|
"""Resolve both sides, diff and finalize; the shared check pipeline."""
|
|
217
|
-
new_res =
|
|
268
|
+
new_res = _resolve_target(target, allow_empty_glob=True)
|
|
218
269
|
if not isinstance(new_res.vfs, RealFS):
|
|
219
270
|
raise VfsError("target must be a directory or workspace")
|
|
220
271
|
old_res = resolve_old(new_res.vfs.root)
|
|
272
|
+
_note_skipped_schemas(old_res)
|
|
273
|
+
if isinstance(old_res.vfs, GitTreeFS):
|
|
274
|
+
for path in old_res.vfs.missing_submodules:
|
|
275
|
+
err_console.print(
|
|
276
|
+
f"[yellow]warning:[/yellow] the baseline commit of submodule {path!r} is "
|
|
277
|
+
"not available locally, so its packages are not compared"
|
|
278
|
+
)
|
|
221
279
|
config = load_config(new_res.vfs.root, new_res.packages, config_path)
|
|
222
280
|
excludes = exclude or []
|
|
223
281
|
new_ifaces = load_interfaces(new_res.vfs, apply_exclude(new_res.packages, excludes))
|
|
@@ -240,14 +298,25 @@ def _compute(
|
|
|
240
298
|
raise VfsError(f"target glob {target!r} matched no interface packages on either side")
|
|
241
299
|
old_packages = tuple(p for p in old_packages if p.name in scope)
|
|
242
300
|
old_ifaces = load_interfaces(old_res.vfs, apply_exclude(old_packages, excludes))
|
|
243
|
-
|
|
301
|
+
findings = finalize_findings(evaluate(diff_interfaces(old_ifaces, new_ifaces)), config, level)
|
|
244
302
|
deep_packages = {p.name for p in new_res.packages if config.effective_deep(p.name, deep)}
|
|
245
303
|
if deep_packages:
|
|
246
|
-
|
|
247
|
-
|
|
304
|
+
transitive = transitive_findings(new_ifaces, findings)
|
|
305
|
+
findings += finalize_findings(
|
|
306
|
+
[f for f in transitive if f.package in deep_packages], config, level
|
|
307
|
+
)
|
|
248
308
|
return findings, new_res.source, old_res.source, prefix
|
|
249
309
|
|
|
250
310
|
|
|
311
|
+
def _note_skipped_schemas(res: Resolution) -> None:
|
|
312
|
+
"""Name the bag schemas recorded without a definition, which are not checked."""
|
|
313
|
+
if isinstance(res.vfs, McapTypeFS) and res.vfs.skipped:
|
|
314
|
+
err_console.print(
|
|
315
|
+
f"[dim]note: {len(res.vfs.skipped)} bag schema(s) have no recorded definition "
|
|
316
|
+
f"and are not checked: {', '.join(res.vfs.skipped)}[/dim]"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
|
|
251
320
|
def _display_prefix(target_dir: Path) -> str:
|
|
252
321
|
"""Repo-root-relative prefix for a real-filesystem target, or ``""``."""
|
|
253
322
|
top = git_toplevel(target_dir)
|
|
@@ -315,7 +384,7 @@ def lint(
|
|
|
315
384
|
|
|
316
385
|
try:
|
|
317
386
|
overrides = _parse_rule_overrides(rules or [])
|
|
318
|
-
res =
|
|
387
|
+
res = _resolve_target(target)
|
|
319
388
|
if not isinstance(res.vfs, RealFS):
|
|
320
389
|
raise VfsError("lint target must be a directory or workspace")
|
|
321
390
|
config = load_config(res.vfs.root, res.packages, config_path)
|
|
@@ -326,7 +395,7 @@ def lint(
|
|
|
326
395
|
|
|
327
396
|
findings = finalize_lint(lint_interfaces(interfaces), config, overrides)
|
|
328
397
|
if fmt == "json":
|
|
329
|
-
typer.echo(json.dumps(build_json(findings), indent=2))
|
|
398
|
+
typer.echo(json.dumps(build_json(findings, advisories=False), indent=2))
|
|
330
399
|
elif fmt == "github":
|
|
331
400
|
for line in build_github(findings, _display_prefix(res.vfs.root)):
|
|
332
401
|
typer.echo(line)
|
|
@@ -371,7 +440,7 @@ def fmt(
|
|
|
371
440
|
) -> None:
|
|
372
441
|
"""Canonically format interface files (idempotent, comment-preserving)."""
|
|
373
442
|
try:
|
|
374
|
-
res =
|
|
443
|
+
res = _resolve_target(target)
|
|
375
444
|
if not isinstance(res.vfs, RealFS):
|
|
376
445
|
raise VfsError("fmt target must be a directory or workspace")
|
|
377
446
|
packages = apply_exclude(res.packages, exclude or [])
|
|
@@ -383,7 +452,7 @@ def fmt(
|
|
|
383
452
|
if formatted != original:
|
|
384
453
|
changed.append(ref.path)
|
|
385
454
|
if not check_only:
|
|
386
|
-
(res.vfs.root / ref.path).write_text(formatted)
|
|
455
|
+
(res.vfs.root / ref.path).write_text(formatted, encoding="utf-8")
|
|
387
456
|
except (VfsError, ParseError) as exc:
|
|
388
457
|
err_console.print(f"[red]error:[/red] {exc}")
|
|
389
458
|
raise typer.Exit(code=2) from exc
|
|
@@ -407,9 +476,9 @@ def freeze(
|
|
|
407
476
|
str | None,
|
|
408
477
|
typer.Argument(help="Path, package name, or omit for the current workspace."),
|
|
409
478
|
] = None,
|
|
410
|
-
output: Annotated[
|
|
411
|
-
|
|
412
|
-
|
|
479
|
+
output: Annotated[Path, typer.Option("-o", "--output", help="Lockfile path to write.")] = Path(
|
|
480
|
+
"roschema.lock"
|
|
481
|
+
),
|
|
413
482
|
exclude: Annotated[
|
|
414
483
|
list[str] | None,
|
|
415
484
|
typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
|
|
@@ -417,13 +486,13 @@ def freeze(
|
|
|
417
486
|
) -> None:
|
|
418
487
|
"""Write a roschema.lock baseline (canonical text + sha256 per interface)."""
|
|
419
488
|
try:
|
|
420
|
-
res =
|
|
489
|
+
res = _resolve_target(target)
|
|
421
490
|
interfaces = load_interfaces(res.vfs, apply_exclude(res.packages, exclude or []))
|
|
422
491
|
except (VfsError, ParseError) as exc:
|
|
423
492
|
err_console.print(f"[red]error:[/red] {exc}")
|
|
424
493
|
raise typer.Exit(code=2) from exc
|
|
425
494
|
|
|
426
|
-
output.write_text(build_lock(interfaces, __version__))
|
|
495
|
+
output.write_text(build_lock(interfaces, __version__), encoding="utf-8")
|
|
427
496
|
console.print(f"wrote {len(interfaces)} interface(s) to {output}")
|
|
428
497
|
|
|
429
498
|
|
|
@@ -441,7 +510,7 @@ def ls(
|
|
|
441
510
|
) -> None:
|
|
442
511
|
"""List discovered interface packages and the source they came from."""
|
|
443
512
|
try:
|
|
444
|
-
resolution =
|
|
513
|
+
resolution = _resolve_target(target)
|
|
445
514
|
except VfsError as exc:
|
|
446
515
|
err_console.print(f"[red]error:[/red] {exc}")
|
|
447
516
|
raise typer.Exit(code=2) from exc
|
|
@@ -463,8 +532,7 @@ def _to_json(resolution: Resolution) -> dict[str, object]:
|
|
|
463
532
|
"name": pkg.name,
|
|
464
533
|
"path": pkg.path,
|
|
465
534
|
"interfaces": [
|
|
466
|
-
{"kind": ref.kind, "name": ref.name, "path": ref.path}
|
|
467
|
-
for ref in pkg.interfaces
|
|
535
|
+
{"kind": ref.kind, "name": ref.name, "path": ref.path} for ref in pkg.interfaces
|
|
468
536
|
],
|
|
469
537
|
}
|
|
470
538
|
for pkg in resolution.packages
|
|
@@ -484,9 +552,7 @@ def _print_text(resolution: Resolution) -> None:
|
|
|
484
552
|
console.print(f" {ref.kind:<7} {ref.name} [dim]{ref.path}[/dim]")
|
|
485
553
|
total += 1
|
|
486
554
|
plural = "interface" if total == 1 else "interfaces"
|
|
487
|
-
console.print(
|
|
488
|
-
f"\n[dim]{len(resolution.packages)} package(s), {total} {plural}[/dim]"
|
|
489
|
-
)
|
|
555
|
+
console.print(f"\n[dim]{len(resolution.packages)} package(s), {total} {plural}[/dim]")
|
|
490
556
|
|
|
491
557
|
|
|
492
558
|
if __name__ == "__main__": # pragma: no cover
|