roschema 0.3.0__tar.gz → 0.4.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 (126) hide show
  1. {roschema-0.3.0 → roschema-0.4.0}/CHANGELOG.md +20 -0
  2. {roschema-0.3.0 → roschema-0.4.0}/PKG-INFO +41 -14
  3. {roschema-0.3.0 → roschema-0.4.0}/README.md +40 -13
  4. {roschema-0.3.0 → roschema-0.4.0}/action/action.yml +4 -1
  5. {roschema-0.3.0 → roschema-0.4.0}/pyproject.toml +1 -1
  6. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/cli.py +150 -38
  7. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/config.py +24 -11
  8. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/report/__init__.py +2 -0
  9. roschema-0.4.0/src/roschema/report/changes.py +92 -0
  10. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/report/json_report.py +27 -3
  11. roschema-0.4.0/src/roschema/report/markdown.py +92 -0
  12. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/report/summary.py +42 -17
  13. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/report/text.py +42 -11
  14. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/gendocs.py +28 -0
  15. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/model.py +3 -1
  16. {roschema-0.3.0 → roschema-0.4.0}/tests/test_action.py +16 -3
  17. {roschema-0.3.0 → roschema-0.4.0}/tests/test_check_cli.py +95 -3
  18. roschema-0.4.0/tests/test_cli_explain.py +23 -0
  19. {roschema-0.3.0 → roschema-0.4.0}/tests/test_config.py +23 -1
  20. {roschema-0.3.0 → roschema-0.4.0}/tests/test_gendocs.py +14 -0
  21. {roschema-0.3.0 → roschema-0.4.0}/tests/test_golden.py +10 -2
  22. {roschema-0.3.0 → roschema-0.4.0}/tests/test_mcap.py +14 -0
  23. {roschema-0.3.0 → roschema-0.4.0}/tests/test_report_formats.py +29 -1
  24. roschema-0.4.0/tests/test_report_markdown.py +61 -0
  25. {roschema-0.3.0 → roschema-0.4.0}/.github/workflows/ci.yml +0 -0
  26. {roschema-0.3.0 → roschema-0.4.0}/.github/workflows/release.yml +0 -0
  27. {roschema-0.3.0 → roschema-0.4.0}/.gitignore +0 -0
  28. {roschema-0.3.0 → roschema-0.4.0}/CONTRIBUTING.md +0 -0
  29. {roschema-0.3.0 → roschema-0.4.0}/LICENSE +0 -0
  30. {roschema-0.3.0 → roschema-0.4.0}/docs/rules.md +0 -0
  31. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/__init__.py +0 -0
  32. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/deep.py +0 -0
  33. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/differ.py +0 -0
  34. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/discovery/__init__.py +0 -0
  35. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/discovery/discover.py +0 -0
  36. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/discovery/model.py +0 -0
  37. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/discovery/resolve.py +0 -0
  38. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/formatter.py +0 -0
  39. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/freeze.py +0 -0
  40. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/ir.py +0 -0
  41. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/loader.py +0 -0
  42. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/mcap_fs.py +0 -0
  43. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/parser/__init__.py +0 -0
  44. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/parser/core.py +0 -0
  45. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/parser/errors.py +0 -0
  46. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/py.typed +0 -0
  47. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/report/github.py +0 -0
  48. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/__init__.py +0 -0
  49. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/__init__.py +0 -0
  50. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs001_field_removed.py +0 -0
  51. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs002_field_added.py +0 -0
  52. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs003_field_type_changed.py +0 -0
  53. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs004_fields_reordered.py +0 -0
  54. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs005_field_renamed.py +0 -0
  55. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs006_array_kind_changed.py +0 -0
  56. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs007_bound_tightened.py +0 -0
  57. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs008_bound_loosened.py +0 -0
  58. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs020_constant_removed.py +0 -0
  59. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs021_constant_value_changed.py +0 -0
  60. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs022_constant_type_changed.py +0 -0
  61. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs030_default_changed.py +0 -0
  62. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs040_interface_removed.py +0 -0
  63. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs060_message_version.py +0 -0
  64. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/breaking/rs110_transitive.py +0 -0
  65. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/__init__.py +0 -0
  66. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/_common.py +0 -0
  67. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl001_field_snake_case.py +0 -0
  68. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl002_constant_upper_snake.py +0 -0
  69. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl003_name_pascal_case.py +0 -0
  70. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl004_missing_doc.py +0 -0
  71. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl005_discouraged_types.py +0 -0
  72. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl006_unbounded.py +0 -0
  73. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/lint/rsl010_srv_shadow.py +0 -0
  74. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/rules/registry.py +0 -0
  75. {roschema-0.3.0 → roschema-0.4.0}/src/roschema/vfs.py +0 -0
  76. {roschema-0.3.0 → roschema-0.4.0}/tests/_helpers.py +0 -0
  77. {roschema-0.3.0 → roschema-0.4.0}/tests/conftest.py +0 -0
  78. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/AMENT_IGNORE +0 -0
  79. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/COLCON_IGNORE +0 -0
  80. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/golden_check.json +0 -0
  81. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/action/Move.action +0 -0
  82. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/ArrayKind.msg +0 -0
  83. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundLoose.msg +0 -0
  84. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundTight.msg +0 -0
  85. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/BoundedNest.msg +0 -0
  86. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/Constants.msg +0 -0
  87. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/Empty.msg +0 -0
  88. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldAdded.msg +0 -0
  89. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/FieldRemoved.msg +0 -0
  90. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/Renamed.msg +0 -0
  91. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/Reordered.msg +0 -0
  92. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/Suppressed.msg +0 -0
  93. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/TypeChanged.msg +0 -0
  94. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/Unchanged.msg +0 -0
  95. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/msg/WeirdSpacing.msg +0 -0
  96. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/package.xml +0 -0
  97. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_new/demo_msgs/srv/DoThing.srv +0 -0
  98. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/action/Move.action +0 -0
  99. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/ArrayKind.msg +0 -0
  100. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundLoose.msg +0 -0
  101. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundTight.msg +0 -0
  102. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/BoundedNest.msg +0 -0
  103. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Constants.msg +0 -0
  104. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Empty.msg +0 -0
  105. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldAdded.msg +0 -0
  106. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/FieldRemoved.msg +0 -0
  107. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Removed.msg +0 -0
  108. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Renamed.msg +0 -0
  109. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Reordered.msg +0 -0
  110. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Suppressed.msg +0 -0
  111. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/TypeChanged.msg +0 -0
  112. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/Unchanged.msg +0 -0
  113. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/msg/WeirdSpacing.msg +0 -0
  114. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/package.xml +0 -0
  115. {roschema-0.3.0 → roschema-0.4.0}/tests/fixtures/ws_old/demo_msgs/srv/DoThing.srv +0 -0
  116. {roschema-0.3.0 → roschema-0.4.0}/tests/test_cli_ls.py +0 -0
  117. {roschema-0.3.0 → roschema-0.4.0}/tests/test_cli_version.py +0 -0
  118. {roschema-0.3.0 → roschema-0.4.0}/tests/test_deep.py +0 -0
  119. {roschema-0.3.0 → roschema-0.4.0}/tests/test_differ.py +0 -0
  120. {roschema-0.3.0 → roschema-0.4.0}/tests/test_discovery.py +0 -0
  121. {roschema-0.3.0 → roschema-0.4.0}/tests/test_fmt.py +0 -0
  122. {roschema-0.3.0 → roschema-0.4.0}/tests/test_freeze.py +0 -0
  123. {roschema-0.3.0 → roschema-0.4.0}/tests/test_lint.py +0 -0
  124. {roschema-0.3.0 → roschema-0.4.0}/tests/test_parser.py +0 -0
  125. {roschema-0.3.0 → roschema-0.4.0}/tests/test_rules.py +0 -0
  126. {roschema-0.3.0 → roschema-0.4.0}/tests/test_vfs.py +0 -0
@@ -3,6 +3,26 @@
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.4.0] - 2026-11-03
7
+
8
+ ### Added
9
+
10
+ - JSON findings link to their rule's documentation (`help_uri`).
11
+ - `roschema explain RULE` prints a rule's reference section.
12
+ - `check --verbose` and `bag-check --verbose` list the changes that are not
13
+ reported (added, outside the level, rule off or suppressed) with the reason;
14
+ JSON adds a `changes` array.
15
+ - `--summary FILE` appends a Markdown report; the Action writes it to the job
16
+ summary, so every finding and the semver advice are visible even when GitHub
17
+ truncates the annotations.
18
+
19
+ ### Changed
20
+
21
+ - The semver advice suggests MINOR for a package that adds an interface or a
22
+ constant, and is printed even when there are no findings.
23
+ - The coverage line names the first few new interfaces and says "new" and
24
+ "removed" instead of "only in the target" and "only in the baseline".
25
+
6
26
  ## [0.3.0] - 2026-11-03
7
27
 
8
28
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: roschema
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -148,6 +148,9 @@ roschema bag-check recording.mcap
148
148
 
149
149
  # what would be checked? (discovered packages and interfaces)
150
150
  roschema ls
151
+
152
+ # what does a rule report, and why?
153
+ roschema explain RS004
151
154
  ```
152
155
 
153
156
  ## Checking
@@ -155,7 +158,7 @@ roschema ls
155
158
  ```
156
159
  roschema check [TARGET] --against BASELINE [--level LEVEL] [--format text|json|github]
157
160
  [--deep | --no-deep] [--exit-zero] [--warnings-as-errors] [--allow-empty]
158
- [--config PATH] [--exclude GLOB]
161
+ [--verbose] [--summary FILE] [--config PATH] [--exclude GLOB]
159
162
  ```
160
163
 
161
164
  `TARGET` selects what to check; omit it to check every interface package below
@@ -179,7 +182,10 @@ automatically. Initialised git submodules are compared at the commit the
179
182
  baseline records for them.
180
183
 
181
184
  Every report says how much was compared, for example `compared 12 interface(s)
182
- in 3 package(s); 1 only in the target`. A baseline with no interfaces at all is
185
+ in 3 package(s); 1 new (my_msgs/msg/Status)`. New interfaces are not findings,
186
+ since adding one breaks nothing; the line names the first few so that an
187
+ unexpected one (say, from an unrelated checkout below the target) is easy to
188
+ spot. A baseline with no interfaces at all is
183
189
  an error (exit `2`): every current interface would look newly added, so the
184
190
  check would pass without comparing anything. That usually means the wrong
185
191
  baseline or target; on the pull request that adds the first interfaces, pass
@@ -248,7 +254,7 @@ same. Comparison ignores it.
248
254
  ```
249
255
  roschema bag-check BAG.mcap [TARGETS...] [--level LEVEL] [--config PATH]
250
256
  [--format text|json|github] [--exit-zero] [--warnings-as-errors]
251
- [--exclude GLOB]
257
+ [--verbose] [--summary FILE] [--exclude GLOB]
252
258
  ```
253
259
 
254
260
  Needs the `bag` extra (`uv tool install 'roschema[bag]'`).
@@ -341,16 +347,27 @@ allow every rule for that target.
341
347
  package and ends with a summary and a semver advice per package:
342
348
 
343
349
  - **MAJOR** when the package has a wire- or source-level error;
344
- - **MINOR** when its only notable change is an added field (RS002) whose
345
- severity you lowered to `warn`;
350
+ - **MINOR** when it adds an interface or a constant (neither is a finding,
351
+ since neither breaks anything), or an added field (RS002) whose severity you
352
+ lowered to `warn`;
346
353
  - **PATCH** otherwise.
347
354
 
348
- The advice only covers reported findings. Additions such as a new interface or
349
- constant are not findings, so they never raise the advice to MINOR on their own.
355
+ **`--verbose`** also lists, under "not reported", every change that produced no
356
+ reported finding, and why: a new interface or constant (no rule reports either,
357
+ at any level; an added field is RS002), a rule whose level is outside the one
358
+ being checked, a rule that is off, or a suppression, with the allowlist reason.
359
+ Comment-only edits are not changes, so they never appear.
350
360
 
351
361
  **`--format github`** prints what was compared, then emits `::error`/`::warning`
352
362
  workflow commands, which GitHub shows as annotations on the PR diff.
353
363
 
364
+ **`--summary FILE`** appends a Markdown report to `FILE` as well, whatever the
365
+ `--format`: what was compared, a table of findings linked to the rule
366
+ reference, the semver advice, and the unreported changes in a collapsed
367
+ section. Pointed at `$GITHUB_STEP_SUMMARY`, it becomes the job summary, which
368
+ matters because GitHub shows only the first 10 error and 10 warning annotations
369
+ of a step.
370
+
354
371
  **`--format json`** emits a stable document (`version: 1`):
355
372
 
356
373
  ```json
@@ -368,7 +385,8 @@ workflow commands, which GitHub shows as annotations on the PR diff.
368
385
  "message": "type changed float32 -> float64",
369
386
  "old": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
370
387
  "new": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
371
- "suppressed": false
388
+ "suppressed": false,
389
+ "help_uri": "https://github.com/justagist/roschema/blob/v0.4.0/docs/rules.md#rs003----field-type-changed"
372
390
  }
373
391
  ],
374
392
  "summary": {
@@ -389,7 +407,12 @@ workflow commands, which GitHub shows as annotations on the PR diff.
389
407
 
390
408
  `old`/`new` are `null` when there is no corresponding side (a removed interface
391
409
  has no `new`). `coverage` counts interfaces present on both sides (`compared`)
392
- and on one side only. `lint` output has no `advisories` or `coverage`.
410
+ and on one side only. `help_uri` links to the rule's section of the
411
+ [rule reference](docs/rules.md) for the installed release. With `--verbose`, a
412
+ `changes` array lists the unreported changes (each with `rule`, `package`,
413
+ `interface`, `section`, `target`, `message` and `reason`), including suppressed
414
+ findings with the reason they are suppressed. `lint` output has no
415
+ `advisories`, `coverage` or `changes`.
393
416
 
394
417
  ## Exit codes
395
418
 
@@ -406,7 +429,8 @@ turns `1` into `0` for `check`, `bag-check` and `lint`; tool errors still exit
406
429
  ## GitHub Action
407
430
 
408
431
  A composite Action runs `roschema check` on the runner (no setup needed) and
409
- posts the findings as annotations on the PR diff:
432
+ posts the findings as annotations on the PR diff, with a full report (findings,
433
+ semver advice and unreported changes) as the job summary:
410
434
 
411
435
  ```yaml
412
436
  # .github/workflows/interfaces.yml
@@ -421,7 +445,7 @@ jobs:
421
445
  - uses: justagist/roschema/action@main
422
446
  with:
423
447
  against: origin/${{ github.base_ref }}
424
- # version: "roschema==0.3.0" # pin a release for reproducible results
448
+ # version: "roschema==0.4.0" # pin a release for reproducible results
425
449
  # level: source
426
450
  # deep: "true"
427
451
  # warnings-as-errors: "true"
@@ -448,8 +472,10 @@ because it is part of the checkout.
448
472
  ## Troubleshooting
449
473
 
450
474
  **"no breaking changes found", but you expected some.** The line under the
451
- header says how many interfaces were compared. Run `roschema ls` with the same
452
- target to see which packages and interfaces are discovered, then check that:
475
+ header says how many interfaces were compared, and `--verbose` lists the changes
476
+ that were seen but not reported, with the reason. Run `roschema ls` with the
477
+ same target to see which packages and interfaces are discovered, then check
478
+ that:
453
479
 
454
480
  - the packages are not below a `COLCON_IGNORE` or `AMENT_IGNORE` marker;
455
481
  - a package-name target did not fall back to the installed copy (the warning
@@ -466,6 +492,7 @@ shown in [Configuration](#configuration); the error suggests the closest one.
466
492
 
467
493
  Every rule, its levels, default severity, rationale and an example is
468
494
  documented in [docs/rules.md](docs/rules.md), generated from the rule metadata.
495
+ `roschema explain RULE` prints the same section in the terminal.
469
496
 
470
497
  ## Non-goals
471
498
 
@@ -113,6 +113,9 @@ roschema bag-check recording.mcap
113
113
 
114
114
  # what would be checked? (discovered packages and interfaces)
115
115
  roschema ls
116
+
117
+ # what does a rule report, and why?
118
+ roschema explain RS004
116
119
  ```
117
120
 
118
121
  ## Checking
@@ -120,7 +123,7 @@ roschema ls
120
123
  ```
121
124
  roschema check [TARGET] --against BASELINE [--level LEVEL] [--format text|json|github]
122
125
  [--deep | --no-deep] [--exit-zero] [--warnings-as-errors] [--allow-empty]
123
- [--config PATH] [--exclude GLOB]
126
+ [--verbose] [--summary FILE] [--config PATH] [--exclude GLOB]
124
127
  ```
125
128
 
126
129
  `TARGET` selects what to check; omit it to check every interface package below
@@ -144,7 +147,10 @@ automatically. Initialised git submodules are compared at the commit the
144
147
  baseline records for them.
145
148
 
146
149
  Every report says how much was compared, for example `compared 12 interface(s)
147
- in 3 package(s); 1 only in the target`. A baseline with no interfaces at all is
150
+ in 3 package(s); 1 new (my_msgs/msg/Status)`. New interfaces are not findings,
151
+ since adding one breaks nothing; the line names the first few so that an
152
+ unexpected one (say, from an unrelated checkout below the target) is easy to
153
+ spot. A baseline with no interfaces at all is
148
154
  an error (exit `2`): every current interface would look newly added, so the
149
155
  check would pass without comparing anything. That usually means the wrong
150
156
  baseline or target; on the pull request that adds the first interfaces, pass
@@ -213,7 +219,7 @@ same. Comparison ignores it.
213
219
  ```
214
220
  roschema bag-check BAG.mcap [TARGETS...] [--level LEVEL] [--config PATH]
215
221
  [--format text|json|github] [--exit-zero] [--warnings-as-errors]
216
- [--exclude GLOB]
222
+ [--verbose] [--summary FILE] [--exclude GLOB]
217
223
  ```
218
224
 
219
225
  Needs the `bag` extra (`uv tool install 'roschema[bag]'`).
@@ -306,16 +312,27 @@ allow every rule for that target.
306
312
  package and ends with a summary and a semver advice per package:
307
313
 
308
314
  - **MAJOR** when the package has a wire- or source-level error;
309
- - **MINOR** when its only notable change is an added field (RS002) whose
310
- severity you lowered to `warn`;
315
+ - **MINOR** when it adds an interface or a constant (neither is a finding,
316
+ since neither breaks anything), or an added field (RS002) whose severity you
317
+ lowered to `warn`;
311
318
  - **PATCH** otherwise.
312
319
 
313
- The advice only covers reported findings. Additions such as a new interface or
314
- constant are not findings, so they never raise the advice to MINOR on their own.
320
+ **`--verbose`** also lists, under "not reported", every change that produced no
321
+ reported finding, and why: a new interface or constant (no rule reports either,
322
+ at any level; an added field is RS002), a rule whose level is outside the one
323
+ being checked, a rule that is off, or a suppression, with the allowlist reason.
324
+ Comment-only edits are not changes, so they never appear.
315
325
 
316
326
  **`--format github`** prints what was compared, then emits `::error`/`::warning`
317
327
  workflow commands, which GitHub shows as annotations on the PR diff.
318
328
 
329
+ **`--summary FILE`** appends a Markdown report to `FILE` as well, whatever the
330
+ `--format`: what was compared, a table of findings linked to the rule
331
+ reference, the semver advice, and the unreported changes in a collapsed
332
+ section. Pointed at `$GITHUB_STEP_SUMMARY`, it becomes the job summary, which
333
+ matters because GitHub shows only the first 10 error and 10 warning annotations
334
+ of a step.
335
+
319
336
  **`--format json`** emits a stable document (`version: 1`):
320
337
 
321
338
  ```json
@@ -333,7 +350,8 @@ workflow commands, which GitHub shows as annotations on the PR diff.
333
350
  "message": "type changed float32 -> float64",
334
351
  "old": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
335
352
  "new": { "file": "my_msgs/msg/Pose.msg", "line": 1 },
336
- "suppressed": false
353
+ "suppressed": false,
354
+ "help_uri": "https://github.com/justagist/roschema/blob/v0.4.0/docs/rules.md#rs003----field-type-changed"
337
355
  }
338
356
  ],
339
357
  "summary": {
@@ -354,7 +372,12 @@ workflow commands, which GitHub shows as annotations on the PR diff.
354
372
 
355
373
  `old`/`new` are `null` when there is no corresponding side (a removed interface
356
374
  has no `new`). `coverage` counts interfaces present on both sides (`compared`)
357
- and on one side only. `lint` output has no `advisories` or `coverage`.
375
+ and on one side only. `help_uri` links to the rule's section of the
376
+ [rule reference](docs/rules.md) for the installed release. With `--verbose`, a
377
+ `changes` array lists the unreported changes (each with `rule`, `package`,
378
+ `interface`, `section`, `target`, `message` and `reason`), including suppressed
379
+ findings with the reason they are suppressed. `lint` output has no
380
+ `advisories`, `coverage` or `changes`.
358
381
 
359
382
  ## Exit codes
360
383
 
@@ -371,7 +394,8 @@ turns `1` into `0` for `check`, `bag-check` and `lint`; tool errors still exit
371
394
  ## GitHub Action
372
395
 
373
396
  A composite Action runs `roschema check` on the runner (no setup needed) and
374
- posts the findings as annotations on the PR diff:
397
+ posts the findings as annotations on the PR diff, with a full report (findings,
398
+ semver advice and unreported changes) as the job summary:
375
399
 
376
400
  ```yaml
377
401
  # .github/workflows/interfaces.yml
@@ -386,7 +410,7 @@ jobs:
386
410
  - uses: justagist/roschema/action@main
387
411
  with:
388
412
  against: origin/${{ github.base_ref }}
389
- # version: "roschema==0.3.0" # pin a release for reproducible results
413
+ # version: "roschema==0.4.0" # pin a release for reproducible results
390
414
  # level: source
391
415
  # deep: "true"
392
416
  # warnings-as-errors: "true"
@@ -413,8 +437,10 @@ because it is part of the checkout.
413
437
  ## Troubleshooting
414
438
 
415
439
  **"no breaking changes found", but you expected some.** The line under the
416
- header says how many interfaces were compared. Run `roschema ls` with the same
417
- target to see which packages and interfaces are discovered, then check that:
440
+ header says how many interfaces were compared, and `--verbose` lists the changes
441
+ that were seen but not reported, with the reason. Run `roschema ls` with the
442
+ same target to see which packages and interfaces are discovered, then check
443
+ that:
418
444
 
419
445
  - the packages are not below a `COLCON_IGNORE` or `AMENT_IGNORE` marker;
420
446
  - a package-name target did not fall back to the installed copy (the warning
@@ -431,6 +457,7 @@ shown in [Configuration](#configuration); the error suggests the closest one.
431
457
 
432
458
  Every rule, its levels, default severity, rationale and an example is
433
459
  documented in [docs/rules.md](docs/rules.md), generated from the rule metadata.
460
+ `roschema explain RULE` prints the same section in the terminal.
434
461
 
435
462
  ## Non-goals
436
463
 
@@ -38,7 +38,7 @@ inputs:
38
38
  version:
39
39
  description: >-
40
40
  roschema requirement for uvx. Unpinned runs the latest release (refreshed
41
- on every run, even with a uv cache); pin it (e.g. "roschema==0.3.0") for
41
+ on every run, even with a uv cache); pin it (e.g. "roschema==0.4.0") for
42
42
  reproducible results.
43
43
  default: roschema
44
44
 
@@ -78,6 +78,9 @@ runs:
78
78
  if [ "$ROSCHEMA_WARNINGS_AS_ERRORS" = "true" ]; then
79
79
  args+=(--warnings-as-errors)
80
80
  fi
81
+ if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
82
+ args+=(--summary "$GITHUB_STEP_SUMMARY")
83
+ fi
81
84
  read -r -a excludes <<< "${ROSCHEMA_EXCLUDE//$'\n'/ }"
82
85
  for glob in "${excludes[@]}"; do
83
86
  args+=(--exclude "$glob")
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roschema"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -2,16 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import difflib
5
6
  import json
6
7
  import os
7
8
  import posixpath
8
9
  from collections.abc import Callable
9
- from dataclasses import replace
10
+ from dataclasses import dataclass, replace
10
11
  from pathlib import Path
11
12
  from typing import Annotated
12
13
 
13
14
  import typer
14
15
  from rich.console import Console
16
+ from rich.markdown import Markdown
15
17
 
16
18
  from . import __version__
17
19
  from .config import (
@@ -38,8 +40,18 @@ from .ir import InterfaceFile
38
40
  from .loader import load_interfaces
39
41
  from .mcap_fs import McapTypeFS
40
42
  from .parser import ParseError
41
- from .report import Coverage, build_github, build_json, coverage, render_lint, render_text
42
- from .rules import Finding, all_lint_rules, evaluate, lint_interfaces
43
+ from .report import (
44
+ Coverage,
45
+ build_github,
46
+ build_json,
47
+ build_markdown,
48
+ coverage,
49
+ render_lint,
50
+ render_text,
51
+ )
52
+ from .report.changes import Change, additions, by_location, suppressed_changes
53
+ from .rules import Finding, all_lint_rules, all_rules, evaluate, lint_interfaces
54
+ from .rules.gendocs import docs_url, find_rule, render_rule
43
55
  from .vfs import GitTreeFS, RealFS, VfsError, git_toplevel
44
56
 
45
57
  app = typer.Typer(
@@ -52,6 +64,31 @@ app = typer.Typer(
52
64
  console = Console(soft_wrap=True)
53
65
  err_console = Console(stderr=True, soft_wrap=True)
54
66
 
67
+
68
+ @dataclass(frozen=True)
69
+ class _Result:
70
+ """What a check or bag check found, for reporting.
71
+
72
+ Args:
73
+ findings: Finalized findings, including suppressed ones.
74
+ new_source: Description of the target side.
75
+ old_source: Description of the baseline side.
76
+ coverage: Interface counts across both sides.
77
+ changes: Changes that are not findings: additions, and findings dropped
78
+ for their level or because their rule is off.
79
+ interface_files: Each current interface's source file, by interface key.
80
+ display_prefix: Repo-root-relative prefix for annotation paths.
81
+ """
82
+
83
+ findings: list[Finding]
84
+ new_source: str
85
+ old_source: str
86
+ coverage: Coverage
87
+ changes: list[Change]
88
+ interface_files: dict[str, str]
89
+ display_prefix: str = ""
90
+
91
+
55
92
  # A bag check asks whether current code can still decode the recording, which is
56
93
  # a wire question; constant and other source-level changes do not affect it.
57
94
  BAG_DEFAULT_LEVEL = "wire"
@@ -117,6 +154,21 @@ def check(
117
154
  bool,
118
155
  typer.Option("--warnings-as-errors", help="Exit 1 on warnings as well as errors."),
119
156
  ] = False,
157
+ verbose: Annotated[
158
+ bool,
159
+ typer.Option(
160
+ "--verbose",
161
+ help="Also list changes that are not reported (added, below the level, rule off "
162
+ "or suppressed) and why.",
163
+ ),
164
+ ] = False,
165
+ summary_path: Annotated[
166
+ Path | None,
167
+ typer.Option(
168
+ "--summary",
169
+ help="Also append a Markdown report to this file, e.g. $GITHUB_STEP_SUMMARY.",
170
+ ),
171
+ ] = None,
120
172
  allow_empty: Annotated[
121
173
  bool,
122
174
  typer.Option(
@@ -138,24 +190,28 @@ def check(
138
190
  raise typer.Exit(code=2)
139
191
 
140
192
  try:
141
- findings, new_source, old_source, prefix, files, cov = _compute(
193
+ result = _compute(
142
194
  target, lambda root: resolve_baseline(against, root), level, config_path, exclude, deep
143
195
  )
144
196
  except (VfsError, ParseError, ConfigError) as exc:
145
197
  err_console.print(f"[red]error:[/red] {exc}")
146
198
  raise typer.Exit(code=2) from exc
147
199
 
148
- if old_source.startswith("git:") and Path(against).is_dir():
200
+ if result.old_source.startswith("git:") and Path(against).is_dir():
149
201
  err_console.print(
150
202
  f"[dim]note: {against!r} matched a git ref; "
151
203
  f"use './{against}' to compare against the directory instead.[/dim]"
152
204
  )
153
205
  if not allow_empty:
154
206
  _require_baseline(
155
- cov, f"the baseline ({old_source})", "pass --allow-empty if that is expected"
207
+ result.coverage,
208
+ f"the baseline ({result.old_source})",
209
+ "pass --allow-empty if that is expected",
156
210
  )
157
- _emit(findings, fmt, new_source, old_source, cov, prefix, files)
158
- _exit_for(findings, exit_zero, warnings_as_errors)
211
+ _emit(result, fmt, verbose)
212
+ if summary_path is not None:
213
+ _write_summary(result, summary_path)
214
+ _exit_for(result.findings, exit_zero, warnings_as_errors)
159
215
 
160
216
 
161
217
  @app.command(name="bag-check")
@@ -189,6 +245,21 @@ def bag_check(
189
245
  bool,
190
246
  typer.Option("--warnings-as-errors", help="Exit 1 on warnings as well as errors."),
191
247
  ] = False,
248
+ verbose: Annotated[
249
+ bool,
250
+ typer.Option(
251
+ "--verbose",
252
+ help="Also list changes that are not reported (added, below the level, rule off "
253
+ "or suppressed) and why.",
254
+ ),
255
+ ] = False,
256
+ summary_path: Annotated[
257
+ Path | None,
258
+ typer.Option(
259
+ "--summary",
260
+ help="Also append a Markdown report to this file, e.g. $GITHUB_STEP_SUMMARY.",
261
+ ),
262
+ ] = None,
192
263
  exclude: Annotated[
193
264
  list[str] | None,
194
265
  typer.Option("--exclude", help="Glob of packages/interfaces to skip."),
@@ -224,14 +295,21 @@ def bag_check(
224
295
  err_console.print(f"[red]error:[/red] {exc}")
225
296
  raise typer.Exit(code=2) from exc
226
297
 
227
- findings = finalize_findings(
228
- evaluate(diff_interfaces(old_ifaces, new_ifaces)), config, level, BAG_DEFAULT_LEVEL
298
+ events = diff_interfaces(old_ifaces, new_ifaces)
299
+ dropped: list[tuple[Finding, str]] = []
300
+ result = _Result(
301
+ findings=finalize_findings(evaluate(events), config, level, BAG_DEFAULT_LEVEL, dropped),
302
+ new_source=new_source,
303
+ old_source=old_res.source,
304
+ coverage=coverage(old_ifaces, new_ifaces),
305
+ changes=additions(events) + [Change.from_finding(f, why) for f, why in dropped],
306
+ interface_files={key: iface.source_file for key, iface in new_ifaces.items()},
229
307
  )
230
- cov = coverage(old_ifaces, new_ifaces)
231
- _require_baseline(cov, "the bag", "check the --exclude patterns")
232
- files = {key: iface.source_file for key, iface in new_ifaces.items()}
233
- _emit(findings, fmt, new_source, old_res.source, cov, interface_files=files)
234
- _exit_for(findings, exit_zero, warnings_as_errors)
308
+ _require_baseline(result.coverage, "the bag", "check the --exclude patterns")
309
+ _emit(result, fmt, verbose)
310
+ if summary_path is not None:
311
+ _write_summary(result, summary_path)
312
+ _exit_for(result.findings, exit_zero, warnings_as_errors)
235
313
 
236
314
 
237
315
  def _resolve_current(
@@ -285,12 +363,8 @@ def _compute(
285
363
  config_path: Path | None,
286
364
  exclude: list[str] | None,
287
365
  deep: bool | None = None,
288
- ) -> tuple[list[Finding], str, str, str, dict[str, str], Coverage]:
289
- """Resolve both sides, diff and finalize; the shared check pipeline.
290
-
291
- Also returns each current interface's source file, keyed by interface, and
292
- how many interfaces were compared.
293
- """
366
+ ) -> _Result:
367
+ """Resolve both sides, diff and finalize; the shared check pipeline."""
294
368
  new_res = _resolve_target(target, allow_empty_glob=True)
295
369
  if not isinstance(new_res.vfs, RealFS):
296
370
  raise VfsError("target must be a directory or workspace")
@@ -324,16 +398,24 @@ def _compute(
324
398
  raise VfsError(f"target glob {target!r} matched no interface packages on either side")
325
399
  old_packages = tuple(p for p in old_packages if p.name in scope)
326
400
  old_ifaces = load_interfaces(old_res.vfs, apply_exclude(old_packages, excludes))
327
- findings = finalize_findings(evaluate(diff_interfaces(old_ifaces, new_ifaces)), config, level)
401
+ events = diff_interfaces(old_ifaces, new_ifaces)
402
+ dropped: list[tuple[Finding, str]] = []
403
+ findings = finalize_findings(evaluate(events), config, level, dropped=dropped)
328
404
  deep_packages = {p.name for p in new_res.packages if config.effective_deep(p.name, deep)}
329
405
  if deep_packages:
330
406
  transitive = transitive_findings(new_ifaces, findings)
331
407
  findings += finalize_findings(
332
- [f for f in transitive if f.package in deep_packages], config, level
408
+ [f for f in transitive if f.package in deep_packages], config, level, dropped=dropped
333
409
  )
334
- files = {key: iface.source_file for key, iface in new_ifaces.items()}
335
- cov = coverage(old_ifaces, new_ifaces)
336
- return findings, new_res.source, old_res.source, prefix, files, cov
410
+ return _Result(
411
+ findings=findings,
412
+ new_source=new_res.source,
413
+ old_source=old_res.source,
414
+ coverage=coverage(old_ifaces, new_ifaces),
415
+ changes=additions(events) + [Change.from_finding(f, why) for f, why in dropped],
416
+ interface_files={key: iface.source_file for key, iface in new_ifaces.items()},
417
+ display_prefix=prefix,
418
+ )
337
419
 
338
420
 
339
421
  def _note_skipped_schemas(res: Resolution) -> None:
@@ -376,23 +458,37 @@ def _exit_for(findings: list[Finding], exit_zero: bool, warnings_as_errors: bool
376
458
  raise typer.Exit(code=1 if has_failure else 0)
377
459
 
378
460
 
379
- def _emit(
380
- findings: list[Finding],
381
- fmt: str,
382
- new_source: str,
383
- old_source: str,
384
- cov: Coverage,
385
- display_prefix: str = "",
386
- interface_files: dict[str, str] | None = None,
387
- ) -> None:
461
+ def _write_summary(result: _Result, path: Path) -> None:
462
+ """Append the Markdown report to ``path``; GitHub collects step summaries this way."""
463
+ report = build_markdown(
464
+ result.findings, result.new_source, result.old_source, result.coverage, result.changes
465
+ )
466
+ try:
467
+ with path.open("a", encoding="utf-8") as handle:
468
+ handle.write(report)
469
+ except OSError as exc:
470
+ err_console.print(f"[red]error:[/red] cannot write the summary to {str(path)!r}: {exc}")
471
+ raise typer.Exit(code=2) from exc
472
+
473
+
474
+ def _emit(result: _Result, fmt: str, verbose: bool) -> None:
475
+ findings, cov, changes = result.findings, result.coverage, result.changes
388
476
  if fmt == "json":
389
- typer.echo(json.dumps(build_json(findings, coverage=cov), indent=2))
477
+ doc = build_json(findings, coverage=cov, changes=changes, verbose=verbose)
478
+ typer.echo(json.dumps(doc, indent=2))
390
479
  elif fmt == "github":
391
480
  typer.echo(cov.describe())
392
- for line in build_github(findings, display_prefix, interface_files):
481
+ if verbose:
482
+ for change in by_location([*changes, *suppressed_changes(findings)]):
483
+ rule = f"{change.rule} " if change.rule else ""
484
+ line = f"{rule}{change.qualified_target}: {change.message} ({change.reason})"
485
+ # A line break (e.g. in an allowlist reason) could start a
486
+ # workflow command; commands are only read at line starts.
487
+ typer.echo("not reported: " + " ".join(line.splitlines()))
488
+ for line in build_github(findings, result.display_prefix, result.interface_files):
393
489
  typer.echo(line)
394
490
  else:
395
- render_text(findings, console, new_source, old_source, cov)
491
+ render_text(findings, console, result.new_source, result.old_source, cov, changes, verbose)
396
492
 
397
493
 
398
494
  @app.command()
@@ -543,6 +639,22 @@ def freeze(
543
639
  console.print(f"wrote {len(interfaces)} interface(s) to {output}")
544
640
 
545
641
 
642
+ @app.command()
643
+ def explain(
644
+ rule_id: Annotated[str, typer.Argument(metavar="RULE", help="Rule ID, e.g. RS003.")],
645
+ ) -> None:
646
+ """Describe a rule: what it reports, its levels, rationale and an example."""
647
+ rule = find_rule(rule_id)
648
+ if rule is None:
649
+ ids = [r.id for r in all_rules()] + [r.id for r in all_lint_rules()]
650
+ close = difflib.get_close_matches(rule_id.upper(), ids, n=1)
651
+ hint = f"; did you mean {close[0]}?" if close else ""
652
+ err_console.print(f"[red]error:[/red] unknown rule {rule_id!r}{hint}")
653
+ raise typer.Exit(code=2)
654
+ console.print(Markdown(render_rule(rule)))
655
+ console.print(f"\n{docs_url(rule.id)}")
656
+
657
+
546
658
  @app.command()
547
659
  def ls(
548
660
  target: Annotated[