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.
Files changed (121) hide show
  1. {roschema-0.1.3 → roschema-0.2.0}/.github/workflows/ci.yml +3 -1
  2. {roschema-0.1.3 → roschema-0.2.0}/.github/workflows/release.yml +1 -0
  3. {roschema-0.1.3 → roschema-0.2.0}/CHANGELOG.md +39 -0
  4. {roschema-0.1.3 → roschema-0.2.0}/PKG-INFO +8 -3
  5. {roschema-0.1.3 → roschema-0.2.0}/README.md +7 -2
  6. {roschema-0.1.3 → roschema-0.2.0}/action/action.yml +12 -5
  7. {roschema-0.1.3 → roschema-0.2.0}/docs/rules.md +8 -8
  8. {roschema-0.1.3 → roschema-0.2.0}/pyproject.toml +4 -2
  9. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/cli.py +95 -29
  10. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/config.py +34 -27
  11. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/deep.py +21 -7
  12. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/differ.py +28 -24
  13. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/discover.py +1 -3
  14. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/resolve.py +29 -7
  15. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/freeze.py +1 -3
  16. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/ir.py +2 -6
  17. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/loader.py +1 -3
  18. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/mcap_fs.py +61 -28
  19. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/parser/__init__.py +1 -1
  20. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/parser/core.py +17 -19
  21. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/github.py +13 -3
  22. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/json_report.py +15 -9
  23. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/summary.py +2 -6
  24. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs002_field_added.py +1 -1
  25. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs006_array_kind_changed.py +5 -4
  26. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs007_bound_tightened.py +1 -1
  27. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs008_bound_loosened.py +1 -1
  28. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs060_message_version.py +1 -4
  29. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs110_transitive.py +2 -1
  30. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/gendocs.py +3 -4
  31. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl005_discouraged_types.py +6 -4
  32. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/model.py +1 -0
  33. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/vfs.py +52 -6
  34. {roschema-0.1.3 → roschema-0.2.0}/tests/_helpers.py +9 -4
  35. {roschema-0.1.3 → roschema-0.2.0}/tests/test_action.py +48 -23
  36. {roschema-0.1.3 → roschema-0.2.0}/tests/test_check_cli.py +107 -6
  37. {roschema-0.1.3 → roschema-0.2.0}/tests/test_config.py +29 -12
  38. {roschema-0.1.3 → roschema-0.2.0}/tests/test_deep.py +54 -8
  39. {roschema-0.1.3 → roschema-0.2.0}/tests/test_differ.py +20 -20
  40. {roschema-0.1.3 → roschema-0.2.0}/tests/test_discovery.py +19 -0
  41. {roschema-0.1.3 → roschema-0.2.0}/tests/test_fmt.py +1 -5
  42. {roschema-0.1.3 → roschema-0.2.0}/tests/test_freeze.py +1 -3
  43. {roschema-0.1.3 → roschema-0.2.0}/tests/test_lint.py +12 -0
  44. {roschema-0.1.3 → roschema-0.2.0}/tests/test_mcap.py +105 -5
  45. {roschema-0.1.3 → roschema-0.2.0}/tests/test_parser.py +19 -3
  46. {roschema-0.1.3 → roschema-0.2.0}/tests/test_report_formats.py +13 -0
  47. {roschema-0.1.3 → roschema-0.2.0}/tests/test_vfs.py +56 -1
  48. {roschema-0.1.3 → roschema-0.2.0}/.gitignore +0 -0
  49. {roschema-0.1.3 → roschema-0.2.0}/LICENSE +0 -0
  50. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/__init__.py +0 -0
  51. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/__init__.py +0 -0
  52. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/discovery/model.py +0 -0
  53. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/formatter.py +0 -0
  54. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/parser/errors.py +0 -0
  55. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/py.typed +0 -0
  56. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/__init__.py +0 -0
  57. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/report/text.py +0 -0
  58. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/__init__.py +0 -0
  59. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/__init__.py +0 -0
  60. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs001_field_removed.py +0 -0
  61. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs003_field_type_changed.py +0 -0
  62. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs004_fields_reordered.py +0 -0
  63. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs005_field_renamed.py +0 -0
  64. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs020_constant_removed.py +0 -0
  65. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs021_constant_value_changed.py +0 -0
  66. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs022_constant_type_changed.py +0 -0
  67. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs030_default_changed.py +0 -0
  68. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/breaking/rs040_interface_removed.py +0 -0
  69. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/__init__.py +0 -0
  70. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/_common.py +0 -0
  71. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl001_field_snake_case.py +0 -0
  72. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl002_constant_upper_snake.py +0 -0
  73. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl003_name_pascal_case.py +0 -0
  74. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl004_missing_doc.py +0 -0
  75. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl006_unbounded.py +0 -0
  76. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/lint/rsl010_srv_shadow.py +0 -0
  77. {roschema-0.1.3 → roschema-0.2.0}/src/roschema/rules/registry.py +0 -0
  78. {roschema-0.1.3 → roschema-0.2.0}/tests/conftest.py +0 -0
  79. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/AMENT_IGNORE +0 -0
  80. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/COLCON_IGNORE +0 -0
  81. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/golden_check.json +0 -0
  82. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/action/Move.action +0 -0
  83. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/ArrayKind.msg +0 -0
  84. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundLoose.msg +0 -0
  85. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundTight.msg +0 -0
  86. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundedNest.msg +0 -0
  87. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Constants.msg +0 -0
  88. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Empty.msg +0 -0
  89. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldAdded.msg +0 -0
  90. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldRemoved.msg +0 -0
  91. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Renamed.msg +0 -0
  92. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Reordered.msg +0 -0
  93. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Suppressed.msg +0 -0
  94. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/TypeChanged.msg +0 -0
  95. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/Unchanged.msg +0 -0
  96. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/msg/WeirdSpacing.msg +0 -0
  97. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/package.xml +0 -0
  98. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_new/demo_msgs/srv/DoThing.srv +0 -0
  99. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/action/Move.action +0 -0
  100. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/ArrayKind.msg +0 -0
  101. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundLoose.msg +0 -0
  102. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundTight.msg +0 -0
  103. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundedNest.msg +0 -0
  104. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Constants.msg +0 -0
  105. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Empty.msg +0 -0
  106. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldAdded.msg +0 -0
  107. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldRemoved.msg +0 -0
  108. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Removed.msg +0 -0
  109. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Renamed.msg +0 -0
  110. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Reordered.msg +0 -0
  111. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Suppressed.msg +0 -0
  112. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/TypeChanged.msg +0 -0
  113. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/Unchanged.msg +0 -0
  114. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/msg/WeirdSpacing.msg +0 -0
  115. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/package.xml +0 -0
  116. {roschema-0.1.3 → roschema-0.2.0}/tests/fixtures/ws_old/demo_msgs/srv/DoThing.srv +0 -0
  117. {roschema-0.1.3 → roschema-0.2.0}/tests/test_cli_ls.py +0 -0
  118. {roschema-0.1.3 → roschema-0.2.0}/tests/test_cli_version.py +0 -0
  119. {roschema-0.1.3 → roschema-0.2.0}/tests/test_gendocs.py +0 -0
  120. {roschema-0.1.3 → roschema-0.2.0}/tests/test_golden.py +0 -0
  121. {roschema-0.1.3 → roschema-0.2.0}/tests/test_rules.py +0 -0
@@ -22,7 +22,9 @@ jobs:
22
22
  - name: Install
23
23
  run: uv sync --extra dev --extra bag
24
24
  - name: Ruff
25
- run: uv run ruff check .
25
+ run: |
26
+ uv run ruff check .
27
+ uv run ruff format --check .
26
28
  - name: Mypy
27
29
  run: uv run mypy --strict src
28
30
  - name: Rule docs in sync
@@ -20,6 +20,7 @@ jobs:
20
20
  run: |
21
21
  uv run pytest -q
22
22
  uv run ruff check .
23
+ uv run ruff format --check .
23
24
  uv run mypy --strict src
24
25
  uv run python -m roschema.rules.gendocs --check
25
26
  - name: Tag matches package version
@@ -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.1.3
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 deprecated `char`/scalar-`byte` (RSL005) and
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@v1
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 deprecated `char`/scalar-`byte` (RSL005) and
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@v1
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: Reporting level (wire, source, introspection, behavior).
16
- default: source
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: roschema version/specifier for uvx (e.g. "roschema==0.1.0").
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, source
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 fixed, bounded and unbounded array forms.
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:* Fixed, bounded and unbounded arrays use different CDR encodings and generate different container types.
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 bounded array or string had its capacity reduced.
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 bounded array or string had its capacity increased.
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`, both deprecated aliases in ROS 2.
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 deprecated `char` and `byte` as scalar types in favour of `uint8`/`int8`; `byte[]` remains idiomatic for opaque binary data.
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.1.3"
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
- addopts = "-q"
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
- console = Console()
48
- err_console = Console(stderr=True)
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("--against", help="Baseline: git ref or directory path."),
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(old_ifaces, targets or [], excludes)
174
- config = load_config(Path.cwd(), (), None)
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, None
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 = resolve_target(target, include_empty=True)
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 = resolve_target(target, allow_empty_glob=True)
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
- raw = evaluate(diff_interfaces(old_ifaces, new_ifaces))
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
- raw = raw + [f for f in transitive_findings(new_ifaces, raw) if f.package in deep_packages]
247
- findings = finalize_findings(raw, config, level)
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 = resolve_target(target)
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 = resolve_target(target)
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
- Path, typer.Option("-o", "--output", help="Lockfile path to write.")
412
- ] = Path("roschema.lock"),
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 = resolve_target(target)
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 = resolve_target(target)
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