From fd7bdb4d7eb5b3f9eae227693d0086bbd0b46a8c Mon Sep 17 00:00:00 2001 From: David Tarazi Date: Thu, 23 Jul 2026 17:03:45 -0700 Subject: [PATCH 1/4] created first iteration of validate dbc action --- .github/workflows/test.yml | 40 +++++ .gitignore | 3 + README.md | 75 ++++++++ action.yml | 57 ++++++ scripts/validate_dbc.py | 167 ++++++++++++++++++ test/fixtures/error_classical_oversize.dbc | 12 ++ test/fixtures/error_duplicate_signal_name.dbc | 13 ++ test/fixtures/error_overlap.dbc | 13 ++ test/fixtures/error_syntax.dbc | 12 ++ test/fixtures/valid.dbc | 13 ++ test/fixtures/warning_canfd_length.dbc | 16 ++ test/fixtures/warning_duplicate_frame_id.dbc | 15 ++ test/fixtures/warning_signal_range.dbc | 12 ++ test/test_validate_dbc.py | 77 ++++++++ 14 files changed, 525 insertions(+) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 action.yml create mode 100644 scripts/validate_dbc.py create mode 100644 test/fixtures/error_classical_oversize.dbc create mode 100644 test/fixtures/error_duplicate_signal_name.dbc create mode 100644 test/fixtures/error_overlap.dbc create mode 100644 test/fixtures/error_syntax.dbc create mode 100644 test/fixtures/valid.dbc create mode 100644 test/fixtures/warning_canfd_length.dbc create mode 100644 test/fixtures/warning_duplicate_frame_id.dbc create mode 100644 test/fixtures/warning_signal_range.dbc create mode 100644 test/test_validate_dbc.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..8371170 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,40 @@ +name: Test validate_dbc action + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.10' + - run: python -m pip install --upgrade "cantools>=40,<41" pytest + - run: python -m pytest test/ -v + + action-integration: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Clean fixture must pass + uses: ./ + with: + files: test/fixtures/valid.dbc + + - name: Broken fixture must fail + id: bad + continue-on-error: true + uses: ./ + with: + files: test/fixtures/error_syntax.dbc + + - name: Assert the broken fixture failed + if: steps.bad.outcome != 'failure' + run: | + echo "Expected the action to FAIL on error_syntax.dbc, but it reported '${{ steps.bad.outcome }}'." + exit 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a160f49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ diff --git a/README.md b/README.md index 3543350..f702b50 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,77 @@ # validate_dbc Github Action for validating .dbc files using cantools + + +A GitHub Action that lints CAN `.dbc` files for **syntax and structural +defects** using [cantools](https://github.com/cantools/cantools). Inspired by DBC Utility's [DBC validation release checklist](https://dbcutility.com/blog/dbc-validation-release-checklist/), it runs the checks that can be done without a live CAN bus. This should at the very least provide a lint. + +Findings are emitted as GitHub Actions annotations, so they show up inline on +the pull request. Errors fail the job, and warnings are reported but do not result in failure. + +## What it checks + +| Check | Severity | +|---|---| +| File does not parse in cantools (syntax / format error) | **error** | +| Signal overlap, out-of-bounds signal, or multiplexer error | **error** | +| Classical CAN message with payload length above 8 bytes | **error** | +| Duplicate signal name within a single message | **error** | +| Duplicate frame ids across messages (may be variant-specific) | warning | +| Standard id above 11 bits not marked extended | warning | +| CAN FD message with a non-standard payload length | warning | +| Physical min/max that cannot fit the signal's bit width and scale | warning | + +**Out of scope** (needs a real bus, captured logs, or human review, so it is +deliberately *not* checked): log replay, physical-value plausibility, unit +correctness, enum-vs-firmware agreement, and release-note review. + +## Usage + +```yaml +name: DBC Lint +on: + pull_request: + paths: ['**.dbc'] +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: polymathrobotics/validate_dbc@v1 + # No inputs needed: validates every .dbc in the repo by default. +``` + +Validate only a single directory: + +```yaml + - uses: polymathrobotics/validate_dbc@v1 + with: + path: dbc_dir +``` + +Validate an explicit set of files: + +```yaml + - uses: polymathrobotics/validate_dbc@v1 + with: + files: | + path/to/example.dbc +``` + +## Inputs + +| Input | Default | Description | +|---|---|---| +| `path` | `.` | Directory searched recursively for `.dbc` files when `files` is empty. | +| `files` | `''` | Explicit space/newline-separated list of `.dbc` files. Overrides `path`. | +| `python-version` | `3.10` | Python version used to run the validator. | +| `cantools-version` | `>=40,<41` | Version specifier appended to `cantools` for `pip install`. | + +## Versioning + +Pin to a major version tag (`@v1`) to receive compatible updates, or to a full +commit SHA for maximum reproducibility. + +## License + +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..5e68aec --- /dev/null +++ b/action.yml @@ -0,0 +1,57 @@ +name: 'Validate DBC' +description: 'Lint CAN .dbc files for syntax and structural defects with cantools.' +author: 'Polymath Robotics' + +branding: + icon: 'check-circle' + color: 'green' + +inputs: + path: + description: 'Directory to search recursively for .dbc files (used when `files` is empty).' + required: false + default: '.' + files: + description: 'Explicit space- or newline-separated list of .dbc files. Overrides `path` when set.' + required: false + default: '' + python-version: + description: 'Python version used to run the validator.' + required: false + default: '3.10' + cantools-version: + description: 'pip version specifier appended to "cantools" (e.g. ">=40,<41").' + required: false + default: '>=40,<41' + +runs: + using: 'composite' + steps: + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ inputs.python-version }} + + - name: Install cantools + shell: bash + env: + CANTOOLS_VERSION: ${{ inputs.cantools-version }} + run: python -m pip install --upgrade "cantools${CANTOOLS_VERSION}" + + - name: Validate DBC files + shell: bash + env: + INPUT_FILES: ${{ inputs.files }} + INPUT_PATH: ${{ inputs.path }} + ACTION_PATH: ${{ github.action_path }} + run: | + if [ -n "$INPUT_FILES" ]; then + read -r -a files <<< "$INPUT_FILES" + else + mapfile -t files < <(find "$INPUT_PATH" -type f -name '*.dbc' | sort) + fi + if [ "${#files[@]}" -eq 0 ]; then + echo "No .dbc files found." + exit 0 + fi + python "$ACTION_PATH/scripts/validate_dbc.py" "${files[@]}" diff --git a/scripts/validate_dbc.py b/scripts/validate_dbc.py new file mode 100644 index 0000000..373c47f --- /dev/null +++ b/scripts/validate_dbc.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Validate .dbc files for syntax and structural defects. + +Runs the CI-automatable subset of a DBC lint checks inspired by DBC Utility's DBC Validation Release Checklist +(https://dbcutility.com/blog/dbc-validation-release-checklist/) against the +DBC files passed as command-line arguments. +""" + +from __future__ import annotations + +import re +import sys + +import cantools + +# Valid CAN FD data-length-code payload sizes, in bytes. +CAN_FD_VALID_LENGTHS = {0, 1, 2, 3, 4, 5, 6, 7, 8, 12, 16, 20, 24, 32, 48, 64} +CLASSICAL_CAN_MAX_LENGTH = 8 +STANDARD_ID_MAX = 0x7FF + +_LINE_RE = re.compile(r'at line (\d+)') + + +class Finding: + """A single validation finding tied to a file (and optionally a line).""" + + def __init__(self, path, level, message, line=None): + self.path = path + self.level = level # 'error' or 'warning' + self.message = message + self.line = line + + def annotation(self): + """Render as a GitHub Actions workflow command.""" + location = f'file={self.path}' + if self.line is not None: + location += f',line={self.line}' + text = self.message.replace('\n', ' ').strip() + return f'::{self.level} {location}::{text}' + + +def _line_from_error(exc): + """Pull a line number out of a cantools error message, if present.""" + match = _LINE_RE.search(str(exc)) + return int(match.group(1)) if match else None + + +def _check_parse(path): + """Load with strict=False. Returns (db, findings); db is None on failure.""" + try: + db = cantools.database.load_file(path, strict=False) + except Exception as exc: # noqa: BLE001 - any load failure is a syntax/format error + return None, [Finding(path, 'error', f'Failed to parse: {exc}', _line_from_error(exc))] + return db, [] + + +def _check_strict(path): + """Semantic checks (overlap / bounds / mux) via cantools strict mode. + + cantools strict mode does the byte-order-aware bit-layout math itself, so we + reuse it instead of re-implementing Motorola/Intel bit numbering by hand. + """ + try: + cantools.database.load_file(path, strict=True) + except Exception as exc: # noqa: BLE001 - strict-only failures are structural defects + return [Finding(path, 'error', f'Structural error (overlap/bounds/mux): {exc}', + _line_from_error(exc))] + return [] + + +def _check_signal_ranges(path, msg): + """Warn when a declared physical range cannot fit the signal's bit width.""" + findings = [] + for sig in msg.signals: + if sig.minimum is None or sig.maximum is None or not sig.scale: + continue + raw_span = (2 ** sig.length) - 1 + representable = abs(raw_span * sig.scale) + declared = abs(sig.maximum - sig.minimum) + # Allow one scale step of tolerance for rounding in the DBC. + if declared > representable + abs(sig.scale): + findings.append(Finding( + path, 'warning', + f'Signal "{msg.name}.{sig.name}" declared range [{sig.minimum}, {sig.maximum}] ' + f'exceeds what {sig.length} bits at scale {sig.scale} can represent (~{representable})')) + return findings + + +def _check_messages(path, db): + """Structural checks cantools does not perform on its own.""" + findings = [] + seen_ids = {} + + for msg in db.messages: + seen_ids.setdefault((msg.frame_id, bool(msg.is_extended_frame)), []).append(msg.name) + + if not msg.is_fd and msg.length > CLASSICAL_CAN_MAX_LENGTH: + findings.append(Finding( + path, 'error', + f'Message "{msg.name}" is classical CAN but has payload length ' + f'{msg.length} > {CLASSICAL_CAN_MAX_LENGTH} bytes')) + + if msg.is_fd and msg.length not in CAN_FD_VALID_LENGTHS: + findings.append(Finding( + path, 'warning', + f'Message "{msg.name}" is CAN FD with non-standard payload length {msg.length}')) + + if not msg.is_extended_frame and msg.frame_id > STANDARD_ID_MAX: + findings.append(Finding( + path, 'warning', + f'Message "{msg.name}" id 0x{msg.frame_id:X} exceeds 11 bits but is not marked extended')) + + counts = {} + for sig in msg.signals: + counts[sig.name] = counts.get(sig.name, 0) + 1 + for name, count in counts.items(): + if count > 1: + findings.append(Finding( + path, 'error', + f'Message "{msg.name}" has duplicate signal name "{name}"')) + + findings.extend(_check_signal_ranges(path, msg)) + + for (frame_id, extended), names in seen_ids.items(): + if len(names) > 1: + kind = 'extended' if extended else 'standard' + findings.append(Finding( + path, 'warning', + f'Duplicate {kind} frame id 0x{frame_id:X} used by messages: {", ".join(names)}')) + + return findings + + +def validate_file(path): + """Run every check against a single file and return its findings.""" + db, findings = _check_parse(path) + if db is None: + return findings + signal_count = sum(len(m.signals) for m in db.messages) + print(f'{path}: parsed OK ({len(db.messages)} messages, {signal_count} signals)') + findings.extend(_check_strict(path)) + findings.extend(_check_messages(path, db)) + return findings + + +def main(argv): + paths = argv[1:] + if not paths: + print('No DBC files to validate.') + return 0 + + all_findings = [] + for path in paths: + all_findings.extend(validate_file(path)) + + for finding in all_findings: + print(finding.annotation()) + + errors = [f for f in all_findings if f.level == 'error'] + warnings = [f for f in all_findings if f.level == 'warning'] + print(f'\nSummary: {len(errors)} error(s), {len(warnings)} warning(s) ' + f'across {len(paths)} file(s).') + return 1 if errors else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv)) diff --git a/test/fixtures/error_classical_oversize.dbc b/test/fixtures/error_classical_oversize.dbc new file mode 100644 index 0000000..b5f8829 --- /dev/null +++ b/test/fixtures/error_classical_oversize.dbc @@ -0,0 +1,12 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 300 Oversize_Message: 16 Vector__XXX + SG_ Signal_Z : 0|8@1+ (1,0) [0|255] "" Vector__XXX diff --git a/test/fixtures/error_duplicate_signal_name.dbc b/test/fixtures/error_duplicate_signal_name.dbc new file mode 100644 index 0000000..4220c04 --- /dev/null +++ b/test/fixtures/error_duplicate_signal_name.dbc @@ -0,0 +1,13 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 400 Dup_Signal_Message: 8 Vector__XXX + SG_ Same_Name : 0|8@1+ (1,0) [0|255] "" Vector__XXX + SG_ Same_Name : 8|8@1+ (1,0) [0|255] "" Vector__XXX diff --git a/test/fixtures/error_overlap.dbc b/test/fixtures/error_overlap.dbc new file mode 100644 index 0000000..1938139 --- /dev/null +++ b/test/fixtures/error_overlap.dbc @@ -0,0 +1,13 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 200 Overlap_Message: 8 Vector__XXX + SG_ Signal_X : 0|16@1+ (1,0) [0|65535] "" Vector__XXX + SG_ Signal_Y : 8|16@1+ (1,0) [0|65535] "" Vector__XXX diff --git a/test/fixtures/error_syntax.dbc b/test/fixtures/error_syntax.dbc new file mode 100644 index 0000000..c527dd8 --- /dev/null +++ b/test/fixtures/error_syntax.dbc @@ -0,0 +1,12 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ not_a_number Bad_Message: 8 Vector__XXX + SG_ Some_Signal : 0|8@1+ (1,0) [0|255] "" Vector__XXX diff --git a/test/fixtures/valid.dbc b/test/fixtures/valid.dbc new file mode 100644 index 0000000..912516f --- /dev/null +++ b/test/fixtures/valid.dbc @@ -0,0 +1,13 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 2364540158 Example_Message: 8 Vector__XXX + SG_ Example_Signal_A : 0|8@1+ (1,0) [0|255] "" Vector__XXX + SG_ Example_Signal_B : 8|16@1+ (0.1,0) [0|6553.5] "rpm" Vector__XXX diff --git a/test/fixtures/warning_canfd_length.dbc b/test/fixtures/warning_canfd_length.dbc new file mode 100644 index 0000000..edbf627 --- /dev/null +++ b/test/fixtures/warning_canfd_length.dbc @@ -0,0 +1,16 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 700 Fd_Message: 9 Vector__XXX + SG_ Fd_Signal : 0|8@1+ (1,0) [0|255] "" Vector__XXX + +BA_DEF_ BO_ "VFrameFormat" ENUM "StandardCAN","ExtendedCAN","reserved","reserved","reserved","reserved","reserved","reserved","reserved","reserved","reserved","reserved","reserved","reserved","StandardCAN_FD","ExtendedCAN_FD"; +BA_DEF_DEF_ "VFrameFormat" "StandardCAN"; +BA_ "VFrameFormat" BO_ 700 14; diff --git a/test/fixtures/warning_duplicate_frame_id.dbc b/test/fixtures/warning_duplicate_frame_id.dbc new file mode 100644 index 0000000..5d50737 --- /dev/null +++ b/test/fixtures/warning_duplicate_frame_id.dbc @@ -0,0 +1,15 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 500 Message_A: 8 Vector__XXX + SG_ Signal_A : 0|8@1+ (1,0) [0|255] "" Vector__XXX + +BO_ 500 Message_B: 8 Vector__XXX + SG_ Signal_B : 0|8@1+ (1,0) [0|255] "" Vector__XXX diff --git a/test/fixtures/warning_signal_range.dbc b/test/fixtures/warning_signal_range.dbc new file mode 100644 index 0000000..904f73e --- /dev/null +++ b/test/fixtures/warning_signal_range.dbc @@ -0,0 +1,12 @@ +VERSION "" + + +NS_ : + +BS_: + +BU_: + + +BO_ 600 Range_Message: 8 Vector__XXX + SG_ Wide_Range : 0|1@1+ (1,0) [0|5] "" Vector__XXX diff --git a/test/test_validate_dbc.py b/test/test_validate_dbc.py new file mode 100644 index 0000000..de2e7d2 --- /dev/null +++ b/test/test_validate_dbc.py @@ -0,0 +1,77 @@ +"""Per-case tests for the DBC validator. + +Each fixture in test/fixtures/ exercises one check. A `valid` fixture must be +clean; each `error_*` fixture must yield at least one error-level finding whose +message contains the expected phrase; each `warning_*` fixture must yield the +expected warning and NO errors. + +Note: the "standard id above 11 bits not marked extended" warning is not +reachable as a standalone case -- cantools rejects such a file at parse time, +so it surfaces as a parse error (covered by error_syntax-style parse failures). +""" + +from __future__ import annotations + +import importlib.util +import pathlib + +import pytest + +HERE = pathlib.Path(__file__).resolve().parent +FIXTURES = HERE / 'fixtures' + +# Import the validator that ships with the action (../scripts/validate_dbc.py). +_spec = importlib.util.spec_from_file_location( + 'validate_dbc', HERE.parent / 'scripts' / 'validate_dbc.py') +validate_dbc = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(validate_dbc) + +# fixture filename -> substring expected in an ERROR-level finding +ERROR_CASES = { + 'error_syntax.dbc': 'Failed to parse', + 'error_overlap.dbc': 'overlapping', + 'error_classical_oversize.dbc': 'payload length', + 'error_duplicate_signal_name.dbc': 'duplicate signal name', +} + +# fixture filename -> substring expected in a WARNING-level finding +WARNING_CASES = { + 'warning_duplicate_frame_id.dbc': 'Duplicate', + 'warning_canfd_length.dbc': 'CAN FD with non-standard payload length', + 'warning_signal_range.dbc': 'exceeds what', +} + + +def findings_for(name): + return validate_dbc.validate_file(str(FIXTURES / name)) + + +def test_valid_fixture_is_clean(): + assert findings_for('valid.dbc') == [] + + +@pytest.mark.parametrize('fixture, phrase', sorted(ERROR_CASES.items())) +def test_error_fixture(fixture, phrase): + findings = findings_for(fixture) + errors = [f for f in findings if f.level == 'error'] + assert errors, f'{fixture} should produce at least one error' + assert any(phrase in f.message for f in errors), \ + f'{fixture} error(s) should mention "{phrase}"; got {[f.message for f in errors]}' + + +@pytest.mark.parametrize('fixture, phrase', sorted(WARNING_CASES.items())) +def test_warning_fixture(fixture, phrase): + findings = findings_for(fixture) + errors = [f for f in findings if f.level == 'error'] + warnings = [f for f in findings if f.level == 'warning'] + assert not errors, f'{fixture} should not produce errors; got {[f.message for f in errors]}' + assert any(phrase in f.message for f in warnings), \ + f'{fixture} warning(s) should mention "{phrase}"; got {[f.message for f in warnings]}' + + +def test_main_exit_code_nonzero_on_error(): + assert validate_dbc.main(['prog', str(FIXTURES / 'error_overlap.dbc')]) == 1 + + +def test_main_exit_code_zero_on_warning_only(): + assert validate_dbc.main(['prog', str(FIXTURES / 'warning_signal_range.dbc')]) == 0 From 245188897a7044b1491b9bfb879f909feb69cf4e Mon Sep 17 00:00:00 2001 From: David Tarazi Date: Thu, 23 Jul 2026 17:10:24 -0700 Subject: [PATCH 2/4] fix double run CI --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8371170..57548b4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,6 +2,7 @@ name: Test validate_dbc action on: push: + branches: [main] pull_request: workflow_dispatch: From 3bbb08a2d22dde8291a000bea0c73d923fd12744 Mon Sep 17 00:00:00 2001 From: David Tarazi Date: Thu, 23 Jul 2026 17:16:01 -0700 Subject: [PATCH 3/4] fix test workflow --- .github/workflows/test.yml | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 57548b4..cdd5fb8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,25 +17,13 @@ jobs: - run: python -m pip install --upgrade "cantools>=40,<41" pytest - run: python -m pytest test/ -v + # Smoke test that the composite action itself runs end to end. The validator + # logic (including that broken files fail) is covered by unit-tests above action-integration: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - - name: Clean fixture must pass + - name: Action runs clean on a valid file uses: ./ with: files: test/fixtures/valid.dbc - - - name: Broken fixture must fail - id: bad - continue-on-error: true - uses: ./ - with: - files: test/fixtures/error_syntax.dbc - - - name: Assert the broken fixture failed - if: steps.bad.outcome != 'failure' - run: | - echo "Expected the action to FAIL on error_syntax.dbc, but it reported '${{ steps.bad.outcome }}'." - exit 1 From 2f7a7b1684e3d926266f7ef7c0794fbee994b27b Mon Sep 17 00:00:00 2001 From: David Tarazi Date: Thu, 23 Jul 2026 17:22:04 -0700 Subject: [PATCH 4/4] update description --- action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/action.yml b/action.yml index 5e68aec..3f777c3 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: 'Validate DBC' -description: 'Lint CAN .dbc files for syntax and structural defects with cantools.' +description: 'Github Action for validating .dbc files using cantools.' author: 'Polymath Robotics' branding: