diff --git a/CHANGELOG.md b/CHANGELOG.md
index 35765c8..8956186 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to this project are documented here. Versions follow
## [Unreleased]
+## [1.10.0] — 2026-08-16
+
+### Added
+
+- **Filter out reordered statements in generated C.** When a regenerated model
+ emits the same independent assignments in a different order, the diff now
+ recognises it as noise instead of a change — but only when it can prove the
+ new order computes identical values. A real edit mixed into the reordering
+ still shows as a change.
+- **Flag an interface or calibration change the code did not follow.** When a
+ model's ARXML or A2L really changed but its generated C stayed identical — a
+ new port or characteristic with no matching code — the report and the terminal
+ point it out, the usual sign of a stale regenerate. A code-only change is not
+ flagged. It is a heads-up only: it never changes a file's verdict or the exit
+ code.
+- **Name calibration changes by kind.** The AUTOSAR change chips now say
+ “+1 Characteristic” / “+1 Measurement” instead of a generic “+1 A2L”, in both
+ the report and the viewer, and the viewer shows them on the file header line.
+- **Write the result as JSON or SARIF for a pipeline.** `--json` emits the full
+ scan — every file's verdict, the summary and the exit code — for a build to
+ read directly instead of screen-scraping. `--sarif` emits a SARIF 2.1.0 log
+ of the files that need action, so GitHub or Azure DevOps code scanning can
+ annotate them inline on a pull request.
+
## [1.9.0] — 2026-08-14
Diff and review C++, Python, YAML and JSON files alongside the AUTOSAR output.
diff --git a/compare_tool/__init__.py b/compare_tool/__init__.py
index 50f3c15..9949047 100644
--- a/compare_tool/__init__.py
+++ b/compare_tool/__init__.py
@@ -1,3 +1,3 @@
"""CodeGen Compare Tool - AUTOSAR MATLAB codegen diff with noise filtering."""
-__version__ = "1.9.0"
+__version__ = "1.10.0"
diff --git a/compare_tool/c_rules.py b/compare_tool/c_rules.py
index 87baf8c..eed337a 100644
--- a/compare_tool/c_rules.py
+++ b/compare_tool/c_rules.py
@@ -6,6 +6,7 @@
"""
import re
+from collections import Counter
from difflib import SequenceMatcher
from . import linediff
@@ -435,6 +436,130 @@ def autogen_noise_map(old_lines, new_lines, old_ids=None, new_ids=None):
return mapping
+# --- straight-line reorder (Embedded Coder reschedules independent stmts) ---
+#
+# Regenerating a model routinely emits the same independent assignments in a
+# different order (output ports, temporaries), which the raw and shadow text
+# both read as a change even though the block computes identical values. This
+# is the one residual churn the text rules cannot see past: it is not a rename,
+# not a comment, not a whole-block move -- it is a *reschedule*. Proving it safe
+# needs the data dependence between statements, so it is decided here on the
+# meaning of the lines, not their spelling.
+
+_ASSIGN_RE = re.compile(r'^([A-Za-z_]\w*)\s*=\s*(.*)$')
+# an identifier glued to a '(' is a call; a cast '(real_T)x' has the '(' after
+# an operator or nothing, so it is not matched and stays allowed
+_CALL_RE = re.compile(r'[A-Za-z_]\w*\s*\(')
+
+
+def _parse_scalar_stmt(line):
+ """``(canonical_content, writes, reads)`` for a side-effect-free scalar
+ assignment ``ident = expr;``, or ``None`` for anything else.
+
+ ``None`` is the conservative answer, and the caller turns any ``None`` in a
+ block into "this is a real change". A declaration carrying a type, a call, a
+ store through an array / pointer / field, a control-flow line, or two
+ statements on one line all return ``None``.
+
+ The restriction is what makes the dependence exact. Every accepted statement
+ writes a plain scalar identifier and no accepted RHS contains a call, so no
+ store can alias another statement's read: two distinct names denote two
+ distinct objects. Read/write sets keyed by name are then the true data
+ dependence, not an approximation of it.
+ """
+ s = line.strip()
+ if not s.endswith(';'):
+ return None
+ body = s[:-1]
+ if ';' in body:
+ return None # more than one statement on the line
+ m = _ASSIGN_RE.match(body)
+ if not m:
+ return None
+ lhs, rhs = m.group(1), m.group(2)
+ if not rhs or rhs[0] == '=':
+ return None # '==' comparison, or an empty RHS -- not an assignment
+ if _CALL_RE.search(rhs):
+ return None # a call may have side effects; moving it is not safe
+ if lhs in C_KEYWORDS:
+ return None
+ content = canonical_generated(body)
+ reads = frozenset(t for t in tokenize(rhs) if is_identifier(t))
+ return content, frozenset((lhs,)), reads
+
+
+def reorder_equivalent(old_lines, new_lines):
+ """True when two straight-line blocks hold the SAME statements in a
+ dependence-preserving different order -- Embedded Coder rescheduling
+ independent assignments, which computes exactly the same values.
+
+ Proven, not guessed. Every line on both sides must be a safe scalar
+ assignment (see :func:`_parse_scalar_stmt`); the two sides must be a
+ permutation of one statement multiset; and every pair of statements that
+ share a variable with at least one *writing* it must keep their relative
+ order. Two schedules of a straight-line block that agree on the order of
+ every dependent pair are both linear extensions of the same dependence DAG,
+ so they compute identical results. Any unsafe line, any multiset mismatch,
+ or any flipped dependent pair returns False and the block stays real.
+
+ Residual assumption, stated plainly: a ``volatile`` scalar read is invisible
+ here (it looks like a plain identifier), so two reads of the same volatile
+ object could in principle be reordered. Embedded Coder does not emit that,
+ and any *write* to the shared name keeps the pair ordered regardless. This
+ is the same class of thing the whole tool cannot see (it never expands a
+ macro), and it errs toward calling a block real, never toward hiding one.
+ """
+ if not (2 <= len(old_lines) == len(new_lines) <= 200):
+ return False
+ old = [_parse_scalar_stmt(l) for l in old_lines]
+ new = [_parse_scalar_stmt(l) for l in new_lines]
+ if any(s is None for s in old) or any(s is None for s in new):
+ return False
+ old_keys = [s[0] for s in old]
+ new_keys = [s[0] for s in new]
+ if len(set(old_keys)) != len(old_keys):
+ return False # a repeated statement makes the old<->new pairing ambiguous
+ if Counter(old_keys) != Counter(new_keys):
+ return False # not a permutation: a statement was added / removed / changed
+ if old_keys == new_keys:
+ return False # nothing was actually reordered -- not this rule's case
+ new_pos = {k: i for i, k in enumerate(new_keys)}
+ for a in range(len(old)):
+ _ka, wa, ra = old[a]
+ for b in range(a + 1, len(old)):
+ kb, wb, rb = old[b]
+ # a precedes b in the old order; they are dependent when they share
+ # a variable and at least one of them writes it (true / anti / output
+ # dependence). A dependent pair must keep its order in the new one.
+ if (wa & rb) or (wa & wb) or (wb & ra):
+ if new_pos[old_keys[a]] > new_pos[kb]:
+ return False
+ return True
+
+
+def is_safe_reorder(old_shadow_lines, new_shadow_lines, hunks):
+ """True when the whole set of surviving change ``hunks`` is one
+ dependence-preserving reorder of a single straight-line block.
+
+ The block spans the first changed shadow line to the last, on each side, and
+ includes the unchanged statements between them -- so the dependence check is
+ complete: a hunk statement never crosses the unchanged block boundary, and
+ any real change among the hunks lands a foreign statement inside the span
+ and fails the permutation test (see :func:`reorder_equivalent`). All or
+ nothing on purpose: one genuine change mixed in leaves every hunk real,
+ which is the safe direction.
+ """
+ if not hunks:
+ return False
+ o1 = min(h[0] for h in hunks)
+ o2 = max(h[1] for h in hunks)
+ n1 = min(h[2] for h in hunks)
+ n2 = max(h[3] for h in hunks)
+ old_block = [l for l in old_shadow_lines[o1:o2] if l.strip()]
+ new_block = [l for l in new_shadow_lines[n1:n2] if l.strip()]
+ return reorder_equivalent(old_block, new_block)
+
+
# --- RTE access-point summary (AUTOSAR blockset codegen) ---
# Standard RTE API verbs (AUTOSAR_SWS_RTE). Unknown verbs are simply not
diff --git a/compare_tool/consistency.py b/compare_tool/consistency.py
new file mode 100644
index 0000000..8ab42a7
--- /dev/null
+++ b/compare_tool/consistency.py
@@ -0,0 +1,76 @@
+"""Cross-artifact consistency advisories.
+
+A model's ARXML is its contract and its A2L is its calibration surface; both are
+realised by the generated C. So the dependency runs one way: if the **interface
+or the calibration really changed, the code must have changed too** -- a new
+port needs a new RTE access, a new characteristic needs a new symbol. When an
+ARXML or A2L change lands with no corresponding change in the generated C, the
+folder holds a mix that the per-file diff cannot point at: each file is
+individually fine, and the inconsistency lives strictly *between* them. The
+everyday cause is a stale or partial regenerate -- the model was re-exported but
+the code was not.
+
+The reverse is **not** flagged. Code that changed while the ARXML and A2L did
+not is the ordinary case: an internal logic or gain edit touches no interface
+and no calibration variable, so there is nothing for them to follow.
+
+This is an **advisory, never a verdict**. It never folds a file, moves a count
+or changes the exit code. It only reports which artifact families of a model
+carry a change the tool already stands behind.
+
+Stdlib only, no Qt: the report and the CLI both import it.
+"""
+
+from .diff_engine import ruleset_for
+
+# a family carries a change when at least one of its files got one of these
+# verdicts -- the ones the tool reports as "something happened here"
+_CHANGED = frozenset(('real-change', 'added', 'deleted'))
+_FAMILIES = ('c', 'arxml', 'a2l')
+
+# the interface / calibration surfaces, and how each is spelled in the advisory
+_SURFACES = (('arxml', 'ARXML'), ('a2l', 'A2L'))
+
+
+def _families(rels, results):
+ """``(present, changed)`` for one model's files: two ``{family: bool}``
+ dicts over :data:`_FAMILIES`. ``present`` is True when the model has any
+ file of that family in the compare at all; ``changed`` when at least one
+ such file carries a reported change."""
+ present = {f: False for f in _FAMILIES}
+ changed = {f: False for f in _FAMILIES}
+ for rel in rels:
+ fam = ruleset_for(rel)
+ if fam not in _FAMILIES:
+ continue
+ present[fam] = True
+ if results[rel]['status'] in _CHANGED:
+ changed[fam] = True
+ return present, changed
+
+
+def model_advisories(groups, results, shared_group=None):
+ """``[(model, message)]`` for models whose ARXML or A2L really changed while
+ the generated C did not.
+
+ ``groups`` is ``{model: [rel, ...]}`` (the report's model grouping);
+ ``shared_group`` names the catch-all bucket to skip, since it is not one
+ model. A model is judged only when it has a C file in the compare -- with no
+ generated code there is nothing that should have followed the change. A
+ code-only change (C changed, the surfaces did not) is never flagged. Sorted
+ by model name for a stable report and CLI.
+ """
+ out = []
+ for model in sorted(groups):
+ if shared_group is not None and model == shared_group:
+ continue
+ present, changed = _families(groups[model], results)
+ if not present['c'] or changed['c']:
+ # no code to have followed, or the code changed too -- both fine
+ continue
+ surfaces = [label for fam, label in _SURFACES
+ if present[fam] and changed[fam]]
+ if surfaces:
+ out.append((model, '{} changed but the generated C did not'
+ .format(' and '.join(surfaces))))
+ return out
diff --git a/compare_tool/diff_engine.py b/compare_tool/diff_engine.py
index 1e159e5..86a60d6 100644
--- a/compare_tool/diff_engine.py
+++ b/compare_tool/diff_engine.py
@@ -6,7 +6,7 @@
by testing single normalization rules one at a time.
Hunk dict: {kind, old_range: [i1, i2), new_range: [j1, j2)} (0-based lines)
-kind in {real, moved, comment, rename, uuid, timestamp, sw-version,
+kind in {real, moved, comment, rename, reorder, uuid, timestamp, sw-version,
description, whitespace, mixed}
Moved blocks: a pure-delete hunk whose non-blank shadow content reappears
@@ -355,6 +355,18 @@ def compare_pair(old_text, new_text, path):
kept.append(h)
candidates = kept
+ # MATLAB codegen reschedules independent statements (output assignments,
+ # temporaries): the raw and shadow text both read as a change, but the block
+ # computes the same values. When the whole surviving change set is one
+ # dependence-preserving permutation of a straight-line block, it is proven
+ # noise (c_rules.reorder_equivalent) -- fail-safe: a real change mixed in, or
+ # any line that is not a safe scalar assignment, leaves every hunk real.
+ reorder_hunks = []
+ if ruleset == 'c' and candidates and c_rules.is_safe_reorder(
+ final_old_shadow_lines, new_shadow_lines, candidates):
+ reorder_hunks = list(candidates)
+ candidates = []
+
real_hunks = candidates
moved_del, moved_ins = _detect_moves(candidates, final_old_shadow_lines,
new_shadow_lines, ruleset)
@@ -391,6 +403,8 @@ def compare_pair(old_text, new_text, path):
break
if kind is None and autogen_hunks and _overlaps(h, autogen_hunks):
kind = 'rename' # autogen-name swap (rtb_/mangle/temp)
+ if kind is None and reorder_hunks and _overlaps(h, reorder_hunks):
+ kind = 'reorder' # independent statements rescheduled
if kind is None:
kind = 'mixed' # ignorable but caused by >1 rule combined
for name, ov, nv in variants:
diff --git a/compare_tool/gitsource.py b/compare_tool/gitsource.py
index c728c6a..e778174 100644
--- a/compare_tool/gitsource.py
+++ b/compare_tool/gitsource.py
@@ -63,16 +63,6 @@ def _run(root, args, timeout=30, capture_to=None):
return p.stdout.decode('utf-8', 'replace')
-def git_available():
- try:
- subprocess.run(['git', '--version'], stdout=subprocess.DEVNULL,
- stderr=subprocess.DEVNULL, timeout=10,
- creationflags=_NO_WINDOW)
- except (OSError, subprocess.SubprocessError):
- return False
- return True
-
-
def repo_root(path):
"""The work tree containing `path`, or None if it is not in a repository.
diff --git a/compare_tool/main.py b/compare_tool/main.py
index 5ff5cf6..05b5e5a 100644
--- a/compare_tool/main.py
+++ b/compare_tool/main.py
@@ -15,9 +15,9 @@
import tempfile
from pathlib import Path
-from . import review, theme, zipsource
+from . import review, serialize, theme, zipsource
from .diff_engine import RULES
-from .report import build_arxml_report, build_report
+from .report import build_arxml_report, build_report, consistency_advisories
from .view_model import SWC_DISPLAY, iface_kind, swc_item
from .scanner import (scan, summarize, summarize_a2l, summarize_ifaces,
summarize_rte, summarize_swcs)
@@ -168,6 +168,14 @@ def summary_lines(results, counts):
lines.append(' + {} ({}) in {}'.format(n, kind, rel))
for rel, n, kind in a2l_removed:
lines.append(' - {} ({}) in {}'.format(n, kind, rel))
+
+ # cross-artifact heads-up: a model whose ARXML and C did not change
+ # together. Advisory only -- it never moves a count or the exit code
+ advisories = consistency_advisories(results)
+ if advisories:
+ lines.append('Consistency check:')
+ for model, msg in advisories:
+ lines.append(' !! {}: {}'.format(model, msg))
return lines
@@ -226,6 +234,15 @@ def _parser():
'Reviewed badge that hides the changes already signed '
'off. Not loaded unless named: a report must not pick '
'up someone else\'s sign-off by accident')
+ ap.add_argument('--json', metavar='OUT.json', default=None,
+ help='also write the full scan as schema-versioned JSON for '
+ 'a pipeline to read -- per-file verdict, hunks, renames, '
+ 'AUTOSAR extras, the run summary, the consistency '
+ 'advisories and the exit code')
+ ap.add_argument('--sarif', metavar='OUT.sarif', default=None,
+ help='also write a SARIF 2.1.0 log of the files that need '
+ 'action (modified / added / deleted / error), so GitHub '
+ 'or Azure DevOps code scanning can annotate them inline')
ap.add_argument('--exit-zero', action='store_true',
help='always exit 0 even when real changes exist '
'(report-only mode for CI pipelines); compare '
@@ -377,15 +394,52 @@ def progress(done, total, rel):
else:
print('Report written: {}'.format(out.resolve()))
+ code = _exit_code(counts, args.exit_zero)
+ # the machine outputs carry the SAME exit code the process returns, so a
+ # pipeline reading the JSON and a pipeline reading $? cannot disagree
+ if args.json or args.sarif:
+ try:
+ _write_machine(args, results, counts, old_root, new_root, code,
+ args.baseline_name or old_zip,
+ args.current_name or new_zip)
+ except OSError as e:
+ # a machine output a pipeline asked for is a record; failing to
+ # write it leaves the run without the file it will gate on
+ print('!! MACHINE OUTPUT NOT WRITTEN -- {}'.format(_write_hint(
+ Path(args.json or args.sarif), e)), file=sys.stderr)
+ return 2
+ return code
+
+
+def _exit_code(counts, exit_zero):
+ """The process exit code for a completed scan.
+
+ 2 whenever a path could not be compared -- an incomplete compare must never
+ look green, and ``--exit-zero`` cannot mask it. Otherwise 1 when real
+ differences exist (the CI gate), 0 when they do not or ``--exit-zero``.
+ """
if counts['error']:
- # fail-safe: an incomplete compare must never look green, even with
- # --exit-zero -- an uncompared file could hide a real change
return 2
- if args.exit_zero:
+ if exit_zero:
return 0
- # exit code 1 when real differences exist (CI gate)
return 1 if counts['real-change'] or counts['added'] or counts['deleted'] else 0
+def _write_machine(args, results, counts, old_root, new_root, code,
+ old_label, new_label):
+ """Write the JSON and/or SARIF outputs the CLI was asked for. Raises
+ ``OSError`` on a failed write, which the caller turns into exit 2."""
+ if args.json:
+ advisories = consistency_advisories(results)
+ text = serialize.dumps(results, counts, old_root, new_root, code,
+ old_label, new_label, advisories)
+ Path(args.json).write_text(text, encoding='utf-8')
+ print('JSON written: {}'.format(Path(args.json).resolve()))
+ if args.sarif:
+ Path(args.sarif).write_text(serialize.dumps_sarif(results),
+ encoding='utf-8')
+ print('SARIF written: {}'.format(Path(args.sarif).resolve()))
+
+
if __name__ == '__main__':
sys.exit(main())
diff --git a/compare_tool/qtviewer/diffpane.py b/compare_tool/qtviewer/diffpane.py
index aa2e0c7..2476eaf 100644
--- a/compare_tool/qtviewer/diffpane.py
+++ b/compare_tool/qtviewer/diffpane.py
@@ -20,6 +20,7 @@
itself, and the two never touch -- the diff owns background, syntax owns text.
"""
+from collections import Counter
from pathlib import Path
from PySide6.QtCore import QEvent, QPointF, QRect, QRectF, QSize, Qt, Signal
@@ -33,8 +34,9 @@
from .. import funcname, review, theme
from ..scanner import looks_binary, read_text
from ..syntax import language_for
-from ..view_model import (MUTED, SWC_DISPLAY, Row, aligned_rows, char_span,
- hunk_row_starts, mode_of, mute_rows, row_with)
+from ..view_model import (A2L_KINDS, MUTED, SWC_DISPLAY, Row, a2l_kind_label,
+ aligned_rows, char_span, hunk_row_starts, mode_of,
+ mute_rows, row_with)
from . import highlight
from .highlight import CodeHighlighter
from .icons import logo_pixmap
@@ -73,9 +75,14 @@ def _pm(label, added, removed, changed=0):
def _semantic_summary(result):
- """Compact AUTOSAR / A2L change rollup for the file header, reusing the
+ """Compact AUTOSAR / A2L change chips for the file header, reusing the
semantic diffs the scanner already attached (interfaces, SWC ports /
- runnables / events, RTE access points, A2L objects). '' when none."""
+ runnables / events, RTE access points, A2L objects). '' when none.
+
+ Each chip names the concrete object kind -- '+1 Characteristic', not a
+ generic '+1 A2L' -- through the same view-model seam the report chips use,
+ so a change reads the same in both surfaces. No 'AUTOSAR / A2L:' prefix:
+ the chips ride on the header line beside 'Change k of N'."""
chips = []
s = result.get('swc')
if s:
@@ -91,9 +98,11 @@ def _semantic_summary(result):
chips.append(_pm('RTE', len(t['added']), len(t['removed'])))
a = result.get('a2l')
if a:
- chips.append(_pm('A2L', len(a['added']), len(a['removed'])))
- chips = [c for c in chips if c]
- return 'AUTOSAR / A2L: ' + ' · '.join(chips) if chips else ''
+ add = Counter(k for _n, k in a['added'])
+ rem = Counter(k for _n, k in a['removed'])
+ for kind in A2L_KINDS:
+ chips.append(_pm(a2l_kind_label(kind), add.get(kind, 0), rem.get(kind, 0)))
+ return ' · '.join(c for c in chips if c)
# per-side row background by mode, as theme roles; None = context (editor base
# colour). Looked up at paint time, so a theme switch is a repaint and never a
@@ -360,9 +369,6 @@ def __init__(self):
head_row.addWidget(self._header, 1)
head_row.addWidget(self._fn)
head_row.addLayout(self.nav_actions)
- self._sem = QLabel('')
- self._sem.setWordWrap(True)
- self._sem.setVisible(False)
self.old_edit = DiffEditor()
self.new_edit = DiffEditor()
# VS Code-style "sticky scroll": the enclosing function's signature
@@ -405,12 +411,12 @@ def __init__(self):
dl = QVBoxLayout(diff_page)
dl.setContentsMargins(0, 0, 0, 0)
dl.setSpacing(0)
- # header + semantic line stay at their natural (small) height; the
- # editor body takes ALL remaining vertical space (stretch=1), so the
- # two-pane diff fills the pane from just under the header instead of
- # being pushed to the bottom by an oversized header gap
+ # the header row stays at its natural (small) height; the editor body
+ # takes ALL remaining vertical space (stretch=1), so the two-pane diff
+ # fills the pane from just under the header instead of being pushed to
+ # the bottom by an oversized header gap. The AUTOSAR/A2L chips ride on
+ # the header line itself (see _load_rows), not a second line below.
dl.addLayout(head_row)
- dl.addWidget(self._sem)
dl.addWidget(self._find_bar)
dl.addWidget(body, 1)
@@ -491,8 +497,6 @@ def _style_widgets(self):
theme.c('sticky-border')))
self._sticky_old.setStyleSheet(sticky_qss)
self._sticky_new.setStyleSheet(sticky_qss)
- self._sem.setStyleSheet('color:{}; padding:0 10px 6px; font-size:12px;'
- .format(theme.c('fg-dim')))
self._find_count.setStyleSheet('color:{}; font-size:12px;'
.format(theme.c('st-ign')))
# neutral strip, coloured only in the OLD/NEW tag text and a thin
@@ -1061,7 +1065,7 @@ def _show_file(self, rel, result, old_root, new_root):
return
lines = read_text(path).split('\n')
self._load_one_side(rel, label, lines,
- 'new' if status == 'added' else 'old')
+ 'new' if status == 'added' else 'old', result)
return
# real-change / ignorable-only / identical all show the two-pane code;
# identical has no hunks so it renders as plain context (no highlights)
@@ -1092,11 +1096,13 @@ def _load_rows(self, rel, status, result=None):
if n_moved:
head += ' · {} Moved line{}'.format(n_moved,
'' if n_moved == 1 else 's')
+ # the AUTOSAR/A2L chips ride on the header line, after the file name and
+ # before "Change k of N", instead of a second line under it
+ sem = _semantic_summary(result or {})
+ if sem:
+ head += ' · ' + sem
self._head_base = head
self._header.setText(head)
- sem = _semantic_summary(result or {})
- self._sem.setText(sem)
- self._sem.setVisible(bool(sem))
# configure before the text lands: setPlainText runs a full highlight
# pass of its own, so this way the file is coloured once, not twice
modes = [r.mode for r in rows]
@@ -1178,7 +1184,7 @@ def _load_rows(self, rel, status, result=None):
self.old_edit.verticalScrollBar().setValue(0)
self._on_vscroll() # no change to reveal: name the scope at line 1
- def _load_one_side(self, rel, label, lines, side):
+ def _load_one_side(self, rel, label, lines, side, result=None):
# rows are marked 'ctx': the pane is already one solid colour, so the
# map has nothing to add by repeating it -- but they ARE the file, and
# the find box searches rows, so a whole added file has to have them
@@ -1192,11 +1198,15 @@ def _load_one_side(self, rel, label, lines, side):
self._stop_units = []
self._pos_text = ''
self._clear_selections() # nothing of the previous file may survive
- self._sem.setVisible(False)
# keep _head_base in step with the shown header (an added/deleted file
# has no change stops, but leaving a stale base from the previous file
- # is exactly the kind of drift that bites later)
- self._head_base = '{} · {}'.format(rel, label)
+ # is exactly the kind of drift that bites later). A whole added/deleted
+ # ARXML/A2L still carries its chips (+N Characteristic, +N Port) inline
+ head = '{} · {}'.format(rel, label)
+ sem = _semantic_summary(result or {})
+ if sem:
+ head += ' · ' + sem
+ self._head_base = head
self._header.setText(self._head_base)
edit = self.old_edit if side == 'old' else self.new_edit
other = self.new_edit if side == 'old' else self.old_edit
diff --git a/compare_tool/report.py b/compare_tool/report.py
index 6b812ac..efe2882 100644
--- a/compare_tool/report.py
+++ b/compare_tool/report.py
@@ -11,12 +11,12 @@
import re
from pathlib import Path
-from . import filepair, funcname, review, syntax, theme
+from . import consistency, filepair, funcname, review, syntax, theme
from .diff_engine import ruleset_for
from .scanner import (looks_binary, read_text, summarize, summarize_a2l,
summarize_ifaces, summarize_rte, summarize_swcs)
-from .view_model import (SWC_DISPLAY, char_span, iface_kind, mode_of,
- swc_item)
+from .view_model import (A2L_KINDS, SWC_DISPLAY, a2l_kind_label, char_span,
+ iface_kind, mode_of, swc_item)
CONTEXT = 3
MAX_CONTENT = 400 # max lines shown for added/deleted file content
@@ -929,9 +929,15 @@ def _counts_html(rels, results):
def _autosar_chips(rels, results):
"""Compact AUTOSAR change rollup for one model group, e.g.
- '+1 Interface · +2/−1 Port · ~1 Event · +3 RTE'."""
- ia = ir = sa = sr = ra = rr = aa = ar = 0
+ '+1 Interface · +2/−1 Port · ~1 Event · +3 RTE · +1 Characteristic'.
+
+ A2L is split by object kind (Characteristic / Measurement) rather than a
+ generic '+N A2L', through the same seam the viewer header uses, so the two
+ surfaces name a calibration change the same way."""
+ ia = ir = sa = sr = ra = rr = 0
cats = {cat.key: [0, 0, 0] for cat in SWC_DISPLAY}
+ a2l_add = {k: 0 for k in A2L_KINDS}
+ a2l_rem = {k: 0 for k in A2L_KINDS}
for rel in rels:
r = results[rel]
d = r.get('ifaces')
@@ -952,8 +958,10 @@ def _autosar_chips(rels, results):
rr += len(t['removed'])
a = r.get('a2l')
if a:
- aa += len(a['added'])
- ar += len(a['removed'])
+ for _n, kind in a['added']:
+ a2l_add[kind] = a2l_add.get(kind, 0) + 1
+ for _n, kind in a['removed']:
+ a2l_rem[kind] = a2l_rem.get(kind, 0) + 1
def chip(a, r, c, label):
bits = []
@@ -967,7 +975,9 @@ def chip(a, r, c, label):
chips = [chip(sa, sr, 0, 'SWC'), chip(ia, ir, 0, 'Interface')]
chips += [chip(*(cats[cat.key] + [cat.noun])) for cat in SWC_DISPLAY]
- chips += [chip(ra, rr, 0, 'RTE'), chip(aa, ar, 0, 'A2L')]
+ chips.append(chip(ra, rr, 0, 'RTE'))
+ chips += [chip(a2l_add.get(k, 0), a2l_rem.get(k, 0), 0, a2l_kind_label(k))
+ for k in A2L_KINDS]
return ' · '.join(c for c in chips if c)
@@ -987,6 +997,30 @@ def _overview_table(groups, results, model_anchors):
'{}'.format(''.join(rows)))
+def consistency_advisories(results):
+ """Cross-artifact advisories for a scan (see :mod:`compare_tool.consistency`).
+
+ Public so the CLI summary and the report render the SAME list from the SAME
+ model grouping -- the seam is here because the grouping is. ``[]`` when the
+ layout has no models (the flat fallback), which is also when there is no
+ model whose artifacts could be out of step."""
+ groups = _model_groups(results)
+ if not groups:
+ return []
+ return consistency.model_advisories(groups, results, SHARED_GROUP)
+
+
+def _consistency_html(advisories):
+ """The cross-artifact advisory block, or '' when there is nothing to say.
+ A caution, not a verdict: it never counts toward the summary or exit code."""
+ if not advisories:
+ return ''
+ rows = ['
⚠ {} — {}
'
+ .format(_esc(model), _esc(msg)) for model, msg in advisories]
+ return 'Consistency check
{}
'.format(
+ ''.join(rows))
+
+
def _agg_status(node, results):
"""Folder status = most significant child status."""
best = 'identical'
@@ -1600,6 +1634,11 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None,
if groups:
parts.append(_overview_table(groups, results, model_anchors))
parts.append(_autosar_section(results, anchors))
+ if groups:
+ # below the AUTOSAR changes: it reads them (a surface that moved) against
+ # the code, so it belongs after the reader has seen what moved
+ parts.append(_consistency_html(
+ consistency.model_advisories(groups, results, SHARED_GROUP)))
if results:
parts.append('Folder tree
'
diff --git a/compare_tool/serialize.py b/compare_tool/serialize.py
new file mode 100644
index 0000000..f756938
--- /dev/null
+++ b/compare_tool/serialize.py
@@ -0,0 +1,167 @@
+"""Machine-readable output of a scan: JSON and SARIF.
+
+The HTML report is for a human and the exit code is for a gate; neither lets a
+pipeline read *what* changed. A build server that wants to annotate a pull
+request, feed a dashboard, or drive its own policy needs the verdicts as data.
+
+The result dict is already the tool's contract (see the architecture doc), so
+this serialises it under an explicit, versioned schema rather than leaking the
+in-memory shape -- a consumer pins ``schema`` and is insulated from an internal
+refactor. Every value is JSON-safe: ranges are lists, the semantic extras are
+lists of arrays, and nothing here holds a set or a tuple by the time it is
+dumped.
+
+Two shapes, one source:
+
+* **JSON** -- the whole scan: per-file status, hunks, renames, notes, move
+ pairing and the AUTOSAR semantic extras, plus the run summary, the exit code
+ and the cross-artifact advisories. The complete record, for a consumer that
+ wants everything.
+* **SARIF 2.1.0** -- only the files a reviewer must act on (real-change, added,
+ deleted, error), each a result with a level, so GitHub / Azure DevOps code
+ scanning can annotate them inline. Noise and identical files are not
+ findings and are left out.
+
+Stdlib only (``json``), no Qt: it ships in the zipapp.
+"""
+
+import datetime
+import json
+
+from . import __version__
+
+SCHEMA = 1
+
+# how a verdict maps to a SARIF result level. Only these four are emitted as
+# findings; identical and the noise verdicts are not something to act on.
+_SARIF_LEVEL = {
+ 'error': 'error', # a path that could not be compared -- loudest
+ 'real-change': 'warning',
+ 'added': 'warning',
+ 'deleted': 'warning',
+}
+_SARIF_RULE_NAME = {
+ 'error': 'CompareIncomplete',
+ 'real-change': 'Modified',
+ 'added': 'Added',
+ 'deleted': 'Deleted',
+}
+
+
+def _hunk(h):
+ out = {'kind': h['kind'], 'old_range': list(h['old_range']),
+ 'new_range': list(h['new_range'])}
+ for k in ('moved_to', 'moved_from'):
+ if k in h:
+ out[k] = h[k]
+ return out
+
+
+def _file_entry(rel, r):
+ """One file's record. Kept flat and explicit so the schema is a contract,
+ not whatever the engine happens to store."""
+ entry = {'path': rel, 'status': r['status'], 'binary': r.get('binary', False)}
+ if r.get('notes'):
+ entry['notes'] = list(r['notes'])
+ if r.get('renames'):
+ entry['renames'] = dict(r['renames'])
+ hunks = r.get('hunks') or []
+ if hunks:
+ entry['hunks'] = [_hunk(h) for h in hunks]
+ for k in ('moved_from', 'moved_to', 'move_status', 'move_similarity'):
+ if k in r:
+ entry[k] = r[k]
+ # semantic extras are already lists of arrays (json-safe); pass through
+ for k in ('ifaces', 'swc', 'rte', 'a2l'):
+ if k in r:
+ entry[k] = r[k]
+ return entry
+
+
+def build(results, counts, old_root, new_root, exit_code,
+ old_label=None, new_label=None, advisories=()):
+ """The JSON document for a scan, as a plain dict ready for :func:`json.dumps`.
+
+ ``exit_code`` is passed in rather than recomputed so the file and the
+ process agree by construction. ``advisories`` is the cross-artifact list
+ from :func:`compare_tool.report.consistency_advisories`.
+ """
+ doc = {
+ 'schema': SCHEMA,
+ 'tool': 'codegen-compare-tool',
+ 'version': __version__,
+ 'generated': datetime.datetime.now().isoformat(timespec='seconds'),
+ 'baseline': str(old_root),
+ 'current': str(new_root),
+ 'summary': dict(counts),
+ 'exit_code': exit_code,
+ 'files': [_file_entry(rel, results[rel]) for rel in sorted(results)],
+ 'consistency': [{'model': m, 'message': msg} for m, msg in advisories],
+ }
+ if old_label:
+ doc['baseline_label'] = old_label
+ if new_label:
+ doc['current_label'] = new_label
+ return doc
+
+
+def dumps(results, counts, old_root, new_root, exit_code,
+ old_label=None, new_label=None, advisories=()):
+ return json.dumps(build(results, counts, old_root, new_root, exit_code,
+ old_label, new_label, advisories),
+ indent=2, ensure_ascii=False)
+
+
+def _sarif_result(rel, r):
+ status = r['status']
+ if status == 'real-change':
+ if r.get('binary'):
+ text = 'Modified (binary)'
+ else:
+ n_real = sum(1 for h in r.get('hunks', []) if h['kind'] == 'real')
+ n_moved = sum(1 for h in r.get('hunks', []) if h['kind'] == 'moved')
+ text = 'Modified: {} hunk(s){}'.format(
+ n_real, ', {} moved'.format(n_moved) if n_moved else '')
+ elif status == 'error':
+ text = 'NOT compared -- treat as potentially changed: {}'.format(
+ '; '.join(r.get('notes', [])) or 'unknown')
+ else:
+ text = _SARIF_RULE_NAME[status]
+ return {
+ 'ruleId': status,
+ 'level': _SARIF_LEVEL[status],
+ 'message': {'text': text},
+ 'locations': [{'physicalLocation': {
+ 'artifactLocation': {'uri': rel}}}],
+ }
+
+
+def build_sarif(results):
+ """A SARIF 2.1.0 log with one result per file that needs action.
+
+ Identical and noise-only files are not findings, so they are absent -- a
+ code-scanning surface should light up only what a reviewer has to look at.
+ """
+ rules = [{'id': status,
+ 'name': _SARIF_RULE_NAME[status],
+ 'shortDescription': {'text': _SARIF_RULE_NAME[status]}}
+ for status in _SARIF_LEVEL]
+ findings = [_sarif_result(rel, results[rel]) for rel in sorted(results)
+ if results[rel]['status'] in _SARIF_LEVEL]
+ return {
+ 'version': '2.1.0',
+ '$schema': 'https://json.schemastore.org/sarif-2.1.0.json',
+ 'runs': [{
+ 'tool': {'driver': {
+ 'name': 'codegen-compare-tool',
+ 'version': __version__,
+ 'informationUri': 'https://github.com/longvo92/codegen-compare-tool',
+ 'rules': rules,
+ }},
+ 'results': findings,
+ }],
+ }
+
+
+def dumps_sarif(results):
+ return json.dumps(build_sarif(results), indent=2, ensure_ascii=False)
diff --git a/compare_tool/view_model.py b/compare_tool/view_model.py
index 9c8ff3a..e7bf1f5 100644
--- a/compare_tool/view_model.py
+++ b/compare_tool/view_model.py
@@ -25,6 +25,7 @@
import re
from collections import namedtuple
+from .a2l_rules import A2L_OBJECT_KINDS
from .arxml_rules import SWC_CATEGORIES
_WORD_RE = re.compile(r'\w')
@@ -67,6 +68,18 @@
SWC_DISPLAY = tuple(SwcCategory(key, *_SWC_LABELS[key]) for key in SWC_CATEGORIES)
+# A2L object kinds, in the order a chip lists them. A generic '+N A2L' count
+# hides which kind moved, so both the report chips and the viewer header break
+# it apart through this one seam and cannot spell it two ways.
+A2L_KINDS = A2L_OBJECT_KINDS
+
+
+def a2l_kind_label(kind):
+ """'CHARACTERISTIC' -> 'Characteristic' for a chip label, so it sits beside
+ 'Port' and 'Interface' rather than shouting in all-caps."""
+ return kind.capitalize()
+
+
def iface_kind(tag):
"""'SENDER-RECEIVER-INTERFACE' -> 'SENDER-RECEIVER' for display."""
return tag.replace('-INTERFACE', '')
diff --git a/docs/architecture.md b/docs/architecture.md
index d98f3ca..e7793b6 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -98,6 +98,8 @@ compare_tool/
├── langspec.py # the comment/string grammar per language, shared by syntax.py (colouring) and the diff shadow (folding) so they agree; generic comment stripper for Python/YAML/JSON
├── syntax.py # line-at-a-time C / C++ / XML / A2L / Python / JSON / YAML token spans, Qt-free so it ships in the .pyz
├── funcname.py # enclosing scope name per line (C/C++ function / Python class·method / SHORT-NAME / A2L block), Qt-free — feeds hunk captions and the "Affected" list
+├── consistency.py # cross-artifact advisory: a model whose ARXML/A2L really changed but whose generated C did not follow (heads-up only, never a verdict)
+├── serialize.py # machine-readable output of a scan: schema-versioned JSON (the whole record) and SARIF 2.1.0 (the files needing action) for a pipeline
├── review.py # reviewer notes and sign-offs, keyed by change content so they survive a rescan
├── gitsource.py # read-only `git archive` of a commit into a temp folder, so a commit can be the OLD side
├── zipsource.py # read-only unpack of a .zip artifact into a temp folder, so a zip can be either side
@@ -242,8 +244,16 @@ consumes one dict per compared path:
```
Ranges are 0-based, end-exclusive, into the **raw** lines of each side.
-`kind` is one of `real`, `moved`, `comment`, `rename`, `uuid`, `timestamp`,
-`sw-version`, `description`, `whitespace`, `mixed`.
+`kind` is one of `real`, `moved`, `comment`, `rename`, `reorder`, `uuid`,
+`timestamp`, `sw-version`, `description`, `whitespace`, `mixed`.
+
+`reorder` is the one ignorable kind decided on *meaning* rather than spelling:
+when the whole surviving change set is a dependence-preserving permutation of a
+straight-line block of scalar assignments, it computes the same values and is
+proven noise (`c_rules.reorder_equivalent`). Like the autogen-rename kind it is
+detected on the shadow hunks and applied by overlap, not through
+`_build_variants`; any real change mixed in, or any line that is not a safe
+scalar assignment, leaves every hunk real.
The semantic extras are computed only where they can matter: a shadow-equal
file has the same content, so it cannot have moved the AUTOSAR surface.
diff --git a/docs/usage.md b/docs/usage.md
index 44aaea2..09e1022 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -160,6 +160,7 @@ appears in the file with its real verdict.
|---|---|---|
| `comment` | C/C++/A2L comments (`//`, `/* */`), XML comments (``), `#` line comments (Python, YAML). Python docstrings and JSON are **not** folded — a triple-quoted string is code, and JSON has no comments | .c .h .cpp .hpp .arxml .a2l .py .yaml .yml |
| `rename` | Consistent 1-to-1 variable renaming (MATLAB auto-generated names). Anything the mapping can't fully explain stays a real change | .c .h |
+| `reorder` | Independent statements emitted in a different order (Embedded Coder rescheduling). Only folded when the block is straight-line scalar assignments **and** the new order preserves every data dependence — otherwise it stays a real change | .c .h |
| `uuid` | `UUID="..."` attributes | .arxml .xml |
| `timestamp` | `
` blocks, `` | .arxml .xml |
| `sw-version` | `` version stamps (bumped on every regenerate). Anchored, so `` and the like are untouched | .arxml .xml |
@@ -190,6 +191,26 @@ so are `rtb_AND_…` → `rtb_OR_…` (a different block drives that buffer) and
`Sub_…_step` → `Sub_…_Init` (a different entry point). Digits glued to a block
name (`rtb_Switch1` vs `rtb_Switch2`) are part of the name, not a mangle tail.
+### Reorder
+
+Regenerating a model routinely emits the same independent assignments in a
+different order — output ports, temporaries — which the text reads as a change
+even though the block computes identical values. A `reorder` fold recognises
+this, but only where it can be **proven**, never guessed:
+
+- every line on both sides is a side-effect-free scalar assignment
+ (`ident = expr;` — no call, no store through an array/pointer/field, no
+ control flow, no declaration with a type);
+- the two sides hold the same statements, just permuted;
+- the new order preserves **every data dependence** — whenever two statements
+ share a variable and one writes it, their relative order is unchanged.
+
+Two straight-line schedules that agree on the order of every dependent pair
+compute the same result, so the reorder is behaviour-preserving. Anything that
+does not meet all three — a call between the lines, a changed right-hand side, a
+flipped dependent pair — leaves the whole block a real change. It errs toward
+calling a block real, never toward hiding one.
+
### Comment is its own category
A file whose differences are *only* comments is reported as **Comment**,
@@ -241,6 +262,26 @@ Files are grouped by **Simulink model** using the Embedded Coder AUTOSAR naming
convention (`X.c`, `X.h`, `X.arxml`, `Rte_X.h`, `X_data.c`, the modular ARXML
set, …). Files that match no model land in a final **Shared / other** group.
+## Consistency check
+
+A model's ARXML is its contract and its A2L is its calibration surface; both are
+realised by the generated C. So the dependency runs **one way**: if the
+interface or the calibration really changed, the code must have changed too — a
+new port needs a new RTE access, a new characteristic needs a new symbol. When
+an ARXML or A2L change lands with the generated C left identical, the report
+(below the AUTOSAR changes) and the terminal flag the model — the usual sign of
+a stale or partial regenerate, and the one thing a file-by-file view cannot
+show, because each file is individually fine and the mismatch lives *between*
+them.
+
+The reverse is **not** flagged. Code that changed while the ARXML and A2L did
+not is the ordinary case — an internal logic or gain edit touches no interface
+and no calibration variable, so there is nothing for them to follow.
+
+It is a **heads-up, not a verdict**: it never folds a file, moves a count, or
+changes the exit code. Only a *real* surface change counts — an ARXML that
+merely churned UUIDs did not really change, so a stale C is not a desync.
+
## HTML report
Self-contained file, one per compare: badge toggles, folder tree, filter box,
@@ -296,6 +337,33 @@ python -m compare_tool "$OLD_DIR" "$NEW_DIR" \
See [azure-pipelines.yml](../azure-pipelines.yml) for a working example (OLD
checked out via `git worktree`, NEW is the working tree).
+### Machine-readable output
+
+The HTML report is for a human and the exit code is for a gate. For a build that
+wants to read *what* changed — annotate a pull request, feed a dashboard, drive
+its own policy — write the result as data:
+
+```bash
+python -m compare_tool old_dir new_dir --json result.json --sarif result.sarif
+```
+
+Both are additive: the HTML report is still written. Either can be given alone.
+
+- `--json` writes the whole scan under a versioned `schema`: every file's
+ verdict, its hunks, renames and AUTOSAR extras, the run summary, the
+ consistency advisories, and the same `exit_code` the process returns (so the
+ file and `$?` cannot disagree). Pin `schema` and an internal refactor will not
+ move the shape under you.
+- `--sarif` writes a [SARIF 2.1.0](https://sarifweb.azurewebsites.net/) log of
+ only the files that need action — modified, added, deleted, error — each with
+ a level (`error` for a path that could not be compared, `warning` otherwise).
+ Upload it to GitHub code scanning or Azure DevOps to see the changes annotated
+ inline on the pull request. Identical and noise-only files are not findings
+ and are left out.
+
+A write that fails is loud: like a missing HTML report, it exits `2` — a
+pipeline that asked for the file must not proceed as if it got one.
+
## Single-file build
```powershell
diff --git a/tests/fixtures/demo/README.md b/tests/fixtures/demo/README.md
new file mode 100644
index 0000000..9d29b0c
--- /dev/null
+++ b/tests/fixtures/demo/README.md
@@ -0,0 +1,49 @@
+# Demo tree
+
+One before/after pair, one compare, that shows the whole tool: every noise rule
+plus the three newest features. Run it and look at the report and the terminal:
+
+```bash
+python -m compare_tool tests/fixtures/demo/old tests/fixtures/demo/new \
+ --report demo.html --json demo.json --sarif demo.sarif
+```
+
+Four top-level models make the newest features' point. `rules/` and `models/`
+are the tool's own noise-rule and model-grouping fixtures folded in beside
+them, so the same run also shows comment/uuid/timestamp/rename noise, an added
+file, a deleted file, and Modified files sitting right next to what does not
+count — everything a reviewer would otherwise need several compares to see.
+
+Four models, each making one point:
+
+| Model | Files | What it shows |
+|---|---|---|
+| **SpeedCtrl** | `.c` `.h` `.arxml` | **Reordered statements are noise.** `SpeedCtrl.c` emits the same three independent gains in a different order (and a new timestamp). It is filed under **Unimportant**, not Modified — the values are identical, and the tool proves it before hiding it. |
+| **StaleGen** | `.c` `.arxml` `.a2l` | **Cross-artifact consistency.** The ARXML gained a port and the A2L gained a characteristic, but the C is byte-for-byte unchanged — the interface and calibration moved without the code. The report and the terminal flag *"ARXML and A2L changed but the generated C did not"*, the usual sign of a stale regenerate. |
+| **TorqueLimiter** | `.c` `.arxml` | **A code-only change is normal.** The C changed (a gain went 1.25 → 1.45) while the ARXML did not. A logic edit touches no interface, so this is **not** flagged — the check only fires when a surface changed without the code following. |
+| **PedalMap** | `.c` `.arxml` `.a2l` | **The healthy case, plus machine output.** The C, the ARXML (a new `Scaled` port) and the A2L (a new `K_PedalOffset`) all changed together, so no flag — and the AUTOSAR summary lists the new port and characteristic. |
+
+`rules/` (noise coverage, one file per rule) and `models/` (the `Ctrl` model,
+for the model-grouping / Overview table):
+
+| Path | What it shows |
+|---|---|
+| `rules/src/comment_only.c` | **Comment** — banner/comment churn only |
+| `rules/src/rename_only.c` | **Unimportant** — a consistent 1-to-1 identifier rename |
+| `rules/src/real_change.c` | **Modified** — a real change beside a comment change |
+| `rules/src/added.c` / `deleted.h` | **Added** / **Deleted** |
+| `rules/arxml/uuid_only.arxml` | **Unimportant** — `UUID="…"` churn only |
+| `rules/arxml/admindata.arxml` | **Unimportant** — `` timestamp churn |
+| `rules/arxml/iface.arxml` | **Modified** — a port-interface change, alongside a UUID bump |
+| `rules/a2l/comment_only.a2l` | **Comment** |
+| `rules/a2l/cal.a2l` | **Modified** — a calibration object change |
+| `models/Ctrl.*` | a second model, so the Overview table groups more than one |
+
+What the outputs carry:
+
+- **`demo.html`** — the human report: a *Consistency check* section (below the AUTOSAR changes) names StaleGen, `SpeedCtrl.c` sits under Unimportant with its rows greyed until you click, and the folder tree/Overview show every verdict at once.
+- **`demo.json`** — the whole scan under a versioned schema, including the same exit code the process returns.
+- **`demo.sarif`** — only the files that need action. Unimportant, Comment and identical files are *not* findings.
+
+`test_demo.py` asserts every one of these claims, so the demo cannot drift out of
+step with what it says it does.
diff --git a/tests/fixtures/demo/new/PedalMap.a2l b/tests/fixtures/demo/new/PedalMap.a2l
new file mode 100644
index 0000000..3eb28fa
--- /dev/null
+++ b/tests/fixtures/demo/new/PedalMap.a2l
@@ -0,0 +1,11 @@
+ASAP2_VERSION 1 71
+/begin PROJECT PedalMap ""
+ /begin MODULE CAL ""
+ /begin CHARACTERISTIC K_PedalGain "pedal scaling factor"
+ VALUE 0x8000 __UBYTE_Z 0 IDENTICAL 0 100
+ /end CHARACTERISTIC
+ /begin CHARACTERISTIC K_PedalOffset "pedal zero offset"
+ VALUE 0x8010 __UBYTE_Z 0 IDENTICAL 0 50
+ /end CHARACTERISTIC
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/new/PedalMap.arxml b/tests/fixtures/demo/new/PedalMap.arxml
new file mode 100644
index 0000000..8fd3fe6
--- /dev/null
+++ b/tests/fixtures/demo/new/PedalMap.arxml
@@ -0,0 +1,23 @@
+
+
+
+
+ Powertrain
+
+
+ PedalMap
+
+
+ Raw
+ /Powertrain/If_Raw
+
+
+ Scaled
+ /Powertrain/If_Scaled
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/PedalMap.c b/tests/fixtures/demo/new/PedalMap.c
new file mode 100644
index 0000000..19a1814
--- /dev/null
+++ b/tests/fixtures/demo/new/PedalMap.c
@@ -0,0 +1,16 @@
+/*
+ * File: PedalMap.c
+ * Code generated for Simulink model 'PedalMap'.
+ * Model version : 4.11
+ */
+
+#include "PedalMap.h"
+#include "rtwtypes.h"
+
+/* Scale the raw pedal reading into a normalised request. */
+void PedalMap_step(void)
+{
+ rtY.Scaled = rtU.Raw * K_PedalGain + K_PedalOffset;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/new/SpeedCtrl.arxml b/tests/fixtures/demo/new/SpeedCtrl.arxml
new file mode 100644
index 0000000..0b94c4d
--- /dev/null
+++ b/tests/fixtures/demo/new/SpeedCtrl.arxml
@@ -0,0 +1,23 @@
+
+
+
+
+ Powertrain
+
+
+ SpeedCtrl
+
+
+ Torque
+ /Powertrain/If_Torque
+
+
+ SpeedRequest
+ /Powertrain/If_Speed
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/SpeedCtrl.c b/tests/fixtures/demo/new/SpeedCtrl.c
new file mode 100644
index 0000000..c07e32c
--- /dev/null
+++ b/tests/fixtures/demo/new/SpeedCtrl.c
@@ -0,0 +1,33 @@
+/*
+ * File: SpeedCtrl.c
+ *
+ * Code generated for Simulink model 'SpeedCtrl'.
+ *
+ * Model version : 2.32
+ * Simulink Coder version : 9.9 (R2023a)
+ * C/C++ source code generated on : Thu Aug 14 17:26:44 2026
+ *
+ * Target selection: autosar.tlc
+ */
+
+#include "SpeedCtrl.h"
+#include "rtwtypes.h"
+
+ExtU_SpeedCtrl_T rtU;
+ExtY_SpeedCtrl_T rtY;
+
+/* Blend three independent driver inputs into one speed request. */
+void SpeedCtrl_step(void)
+{
+ real_T gainTorque;
+ real_T gainSpeed;
+ real_T gainPedal;
+
+ gainPedal = rtU.Pedal * 2.00;
+ gainSpeed = rtU.Speed * 0.90;
+ gainTorque = rtU.Torque * 1.10;
+
+ rtY.SpeedRequest = gainTorque + gainSpeed + gainPedal;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/new/SpeedCtrl.h b/tests/fixtures/demo/new/SpeedCtrl.h
new file mode 100644
index 0000000..7b537ee
--- /dev/null
+++ b/tests/fixtures/demo/new/SpeedCtrl.h
@@ -0,0 +1,23 @@
+/*
+ * File: SpeedCtrl.h
+ * Code generated for Simulink model 'SpeedCtrl'.
+ */
+
+#ifndef RTW_HEADER_SpeedCtrl_h_
+#define RTW_HEADER_SpeedCtrl_h_
+
+#include "rtwtypes.h"
+
+typedef struct {
+ real_T Torque;
+ real_T Speed;
+ real_T Pedal;
+} ExtU_SpeedCtrl_T;
+
+typedef struct {
+ real_T SpeedRequest;
+} ExtY_SpeedCtrl_T;
+
+extern void SpeedCtrl_step(void);
+
+#endif
diff --git a/tests/fixtures/demo/new/StaleGen.a2l b/tests/fixtures/demo/new/StaleGen.a2l
new file mode 100644
index 0000000..2c40998
--- /dev/null
+++ b/tests/fixtures/demo/new/StaleGen.a2l
@@ -0,0 +1,11 @@
+ASAP2_VERSION 1 71
+/begin PROJECT StaleGen ""
+ /begin MODULE CAL ""
+ /begin CHARACTERISTIC K_Bias "output bias"
+ VALUE 0x9000 __UBYTE_Z 0 IDENTICAL 0 255
+ /end CHARACTERISTIC
+ /begin CHARACTERISTIC K_Limit "output clamp limit"
+ VALUE 0x9010 __UBYTE_Z 0 IDENTICAL 0 255
+ /end CHARACTERISTIC
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/new/StaleGen.arxml b/tests/fixtures/demo/new/StaleGen.arxml
new file mode 100644
index 0000000..a9928f4
--- /dev/null
+++ b/tests/fixtures/demo/new/StaleGen.arxml
@@ -0,0 +1,23 @@
+
+
+
+
+ Powertrain
+
+
+ StaleGen
+
+
+ In
+ /Powertrain/If_In
+
+
+ Status
+ /Powertrain/If_Status
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/StaleGen.c b/tests/fixtures/demo/new/StaleGen.c
new file mode 100644
index 0000000..64bd665
--- /dev/null
+++ b/tests/fixtures/demo/new/StaleGen.c
@@ -0,0 +1,16 @@
+/*
+ * File: StaleGen.c
+ * Code generated for Simulink model 'StaleGen'.
+ * Model version : 3.02
+ */
+
+#include "StaleGen.h"
+#include "rtwtypes.h"
+
+/* Pass the input straight through -- unchanged this regenerate. */
+void StaleGen_step(void)
+{
+ rtY.Out = rtU.In;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/new/TorqueLimiter.arxml b/tests/fixtures/demo/new/TorqueLimiter.arxml
new file mode 100644
index 0000000..6e915af
--- /dev/null
+++ b/tests/fixtures/demo/new/TorqueLimiter.arxml
@@ -0,0 +1,27 @@
+
+
+
+
+ Powertrain
+
+
+ TorqueLimiter
+
+
+ PedalPosition
+ /Powertrain/If_Pedal
+
+
+ MotorSpeed
+ /Powertrain/If_Speed
+
+
+ TorqueCmd
+ /Powertrain/If_Torque
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/TorqueLimiter.c b/tests/fixtures/demo/new/TorqueLimiter.c
new file mode 100644
index 0000000..97fe259
--- /dev/null
+++ b/tests/fixtures/demo/new/TorqueLimiter.c
@@ -0,0 +1,37 @@
+/*
+ * File: TorqueLimiter.c
+ *
+ * Code generated for Simulink model 'TorqueLimiter'.
+ *
+ * Model version : 1.152
+ * Simulink Coder version : 9.9 (R2023a)
+ * C/C++ source code generated on : Thu Aug 14 17:26:44 2026
+ *
+ * Target selection: autosar.tlc
+ */
+
+#include "TorqueLimiter.h"
+#include "rtwtypes.h"
+
+extern real_T TorqueLimiter_LookupTorque(real_T speed);
+
+/* Model step function -- runs every 10 ms. */
+void Rte_Runnable_TorqueLimiter_Step(void)
+{
+ real_T rtb_Request;
+ real_T rtb_Ceiling;
+
+ rtb_Request = rtU.PedalPosition * 300.0;
+ rtb_Ceiling = TorqueLimiter_LookupTorque(rtU.MotorSpeed);
+
+ /* Gain: apply the driveability scaling factor to the raw request */
+ rtb_Request = rtb_Request * 1.45;
+
+ if (rtb_Request > rtb_Ceiling) {
+ rtb_Request = rtb_Ceiling;
+ }
+
+ rtY.TorqueCmd = rtb_Request;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/new/models/Ctrl.c b/tests/fixtures/demo/new/models/Ctrl.c
new file mode 100644
index 0000000..a89f768
--- /dev/null
+++ b/tests/fixtures/demo/new/models/Ctrl.c
@@ -0,0 +1,10 @@
+/* Model step function */
+#include "Ctrl.h"
+
+void Ctrl_Step(void)
+{
+ Float32 u;
+ (void) Rte_Read_In1_Speed(&u);
+ (void) Rte_Write_Out1_Cmd(u * 3.0F);
+ (void) Rte_Write_Out2_Diag(1U);
+}
diff --git a/tests/fixtures/demo/new/models/Ctrl.h b/tests/fixtures/demo/new/models/Ctrl.h
new file mode 100644
index 0000000..9118c98
--- /dev/null
+++ b/tests/fixtures/demo/new/models/Ctrl.h
@@ -0,0 +1,5 @@
+#ifndef CTRL_H
+#define CTRL_H
+#include "rtwtypes.h"
+void Ctrl_Step(void);
+#endif
diff --git a/tests/fixtures/demo/new/models/Ctrl_component.arxml b/tests/fixtures/demo/new/models/Ctrl_component.arxml
new file mode 100644
index 0000000..2675647
--- /dev/null
+++ b/tests/fixtures/demo/new/models/Ctrl_component.arxml
@@ -0,0 +1,45 @@
+
+
+
+
+ Components
+
+
+ Ctrl
+
+
+ In1
+ /Interfaces/If_Speed
+
+
+ Out1
+ /Interfaces/If_Cmd
+
+
+ Out2
+ /Interfaces/If_Diag
+
+
+
+
+ IB
+
+
+ TE_Step
+ /Components/Ctrl/IB/Ctrl_Step
+ 0.02
+
+
+
+
+ Ctrl_Step
+ Ctrl_Step
+
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/models/Ctrl_types.h b/tests/fixtures/demo/new/models/Ctrl_types.h
new file mode 100644
index 0000000..2c62bad
--- /dev/null
+++ b/tests/fixtures/demo/new/models/Ctrl_types.h
@@ -0,0 +1,4 @@
+#ifndef CTRL_TYPES_H
+#define CTRL_TYPES_H
+typedef float Float32;
+#endif
diff --git a/tests/fixtures/demo/new/models/rtwtypes.h b/tests/fixtures/demo/new/models/rtwtypes.h
new file mode 100644
index 0000000..8362173
--- /dev/null
+++ b/tests/fixtures/demo/new/models/rtwtypes.h
@@ -0,0 +1,4 @@
+#ifndef RTWTYPES_H
+#define RTWTYPES_H
+typedef unsigned char boolean_T;
+#endif
diff --git a/tests/fixtures/demo/new/rules/a2l/cal.a2l b/tests/fixtures/demo/new/rules/a2l/cal.a2l
new file mode 100644
index 0000000..60ec067
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/a2l/cal.a2l
@@ -0,0 +1,12 @@
+/* generated by demo toolchain -- Tue Feb 17 2026 */
+ASAP2_VERSION 1 71
+/begin PROJECT Demo ""
+ /begin MODULE Ctrl ""
+ /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000
+ ECU_ADDRESS 0x40001000
+ /end MEASUREMENT
+ /begin MEASUREMENT VehSpd "vehicle speed" UWORD CM_VehSpd 1 100 0 300
+ ECU_ADDRESS 0x40001004
+ /end MEASUREMENT
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/new/rules/a2l/comment_only.a2l b/tests/fixtures/demo/new/rules/a2l/comment_only.a2l
new file mode 100644
index 0000000..1152b0f
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/a2l/comment_only.a2l
@@ -0,0 +1,8 @@
+/* generated Tue */
+ASAP2_VERSION 1 71
+/begin PROJECT Demo ""
+ /begin MODULE Ctrl ""
+ /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000
+ /end MEASUREMENT
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/new/rules/arxml/admindata.arxml b/tests/fixtures/demo/new/rules/arxml/admindata.arxml
new file mode 100644
index 0000000..01e9734
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/arxml/admindata.arxml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ 9.9
+ 2026-02-17T08:45:01
+
+
+
+
+
+ Interfaces
+
+
+
diff --git a/tests/fixtures/demo/new/rules/arxml/iface.arxml b/tests/fixtures/demo/new/rules/arxml/iface.arxml
new file mode 100644
index 0000000..d48f7a8
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/arxml/iface.arxml
@@ -0,0 +1,26 @@
+
+
+
+
+ Interfaces
+
+
+ If_Speed
+
+
+ Speed
+
+
+
+
+ If_Torque
+
+
+ Torque
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/rules/arxml/real_change.arxml b/tests/fixtures/demo/new/rules/arxml/real_change.arxml
new file mode 100644
index 0000000..60d177a
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/arxml/real_change.arxml
@@ -0,0 +1,13 @@
+
+
+
+
+ DataTypes
+
+
+ Velocity_T
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/rules/arxml/uuid_only.arxml b/tests/fixtures/demo/new/rules/arxml/uuid_only.arxml
new file mode 100644
index 0000000..ee1b464
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/arxml/uuid_only.arxml
@@ -0,0 +1,18 @@
+
+
+
+
+ ComponentTypes
+
+
+ Controller
+
+
+ Out1
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/new/rules/src/added.c b/tests/fixtures/demo/new/rules/src/added.c
new file mode 100644
index 0000000..6855653
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/src/added.c
@@ -0,0 +1,6 @@
+#include "added.h"
+
+void New_step(void)
+{
+ rtY.Out9 = 0.0;
+}
diff --git a/tests/fixtures/demo/new/rules/src/comment_only.c b/tests/fixtures/demo/new/rules/src/comment_only.c
new file mode 100644
index 0000000..4f93e59
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/src/comment_only.c
@@ -0,0 +1,15 @@
+/*
+ * File: comment_only.c
+ * Code generated for Simulink model 'Model'.
+ * Model version : 1.43
+ * Simulink Coder version : 9.8 (R2023a) 19-Nov-2022
+ * C/C++ source code generated on : Tue Feb 17 08:45:01 2026
+ */
+#include "comment_only.h"
+
+/* Model step function (regenerated) */
+void Model_step(void)
+{
+ /* Outport: '/Out1' */
+ rtY.Out1 = rtU.In1 * 2.0;
+}
diff --git a/tests/fixtures/demo/new/rules/src/real_change.c b/tests/fixtures/demo/new/rules/src/real_change.c
new file mode 100644
index 0000000..bc6bd34
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/src/real_change.c
@@ -0,0 +1,12 @@
+/* Generated on : Tue Feb 17 08:45:01 2026 */
+#include "real_change.h"
+
+void Calc_step(void)
+{
+ /* saturation limit */
+ if (rtU.In1 > 10) {
+ rtY.Out1 = 10;
+ } else {
+ rtY.Out1 = rtU.In1;
+ }
+}
diff --git a/tests/fixtures/demo/new/rules/src/rename_conflict.c b/tests/fixtures/demo/new/rules/src/rename_conflict.c
new file mode 100644
index 0000000..0fa12a1
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/src/rename_conflict.c
@@ -0,0 +1,10 @@
+#include "rename_conflict.h"
+
+void Conf_step(void)
+{
+ real_T rtb_B;
+
+ rtb_B = rtU.In1 * 2.0;
+ rtY.Out1 = rtb_B + 1.0;
+ rtY.Out2 = rtb_C + 2.0;
+}
diff --git a/tests/fixtures/demo/new/rules/src/rename_only.c b/tests/fixtures/demo/new/rules/src/rename_only.c
new file mode 100644
index 0000000..740171a
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/src/rename_only.c
@@ -0,0 +1,15 @@
+#include "rename_only.h"
+
+void Sub_step(void)
+{
+ real_T rtb_Sum_k2j;
+ real_T rtb_Gain_p0f;
+
+ rtb_Sum_k2j = rtU.In1 + rtU.In2;
+ rtb_Gain_p0f = rtb_Sum_k2j * 3.5;
+ if (rtb_Sum_k2j > 0.0) {
+ rtY.Out1 = rtb_Gain_p0f;
+ } else {
+ rtY.Out1 = rtb_Sum_k2j;
+ }
+}
diff --git a/tests/fixtures/demo/new/rules/src/same.h b/tests/fixtures/demo/new/rules/src/same.h
new file mode 100644
index 0000000..a3d7f02
--- /dev/null
+++ b/tests/fixtures/demo/new/rules/src/same.h
@@ -0,0 +1,6 @@
+#ifndef SAME_H
+#define SAME_H
+
+extern void Sub_step(void);
+
+#endif
diff --git a/tests/fixtures/demo/old/PedalMap.a2l b/tests/fixtures/demo/old/PedalMap.a2l
new file mode 100644
index 0000000..f8ee76d
--- /dev/null
+++ b/tests/fixtures/demo/old/PedalMap.a2l
@@ -0,0 +1,8 @@
+ASAP2_VERSION 1 71
+/begin PROJECT PedalMap ""
+ /begin MODULE CAL ""
+ /begin CHARACTERISTIC K_PedalGain "pedal scaling factor"
+ VALUE 0x8000 __UBYTE_Z 0 IDENTICAL 0 100
+ /end CHARACTERISTIC
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/old/PedalMap.arxml b/tests/fixtures/demo/old/PedalMap.arxml
new file mode 100644
index 0000000..65232b2
--- /dev/null
+++ b/tests/fixtures/demo/old/PedalMap.arxml
@@ -0,0 +1,19 @@
+
+
+
+
+ Powertrain
+
+
+ PedalMap
+
+
+ Raw
+ /Powertrain/If_Raw
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/PedalMap.c b/tests/fixtures/demo/old/PedalMap.c
new file mode 100644
index 0000000..9d1075f
--- /dev/null
+++ b/tests/fixtures/demo/old/PedalMap.c
@@ -0,0 +1,16 @@
+/*
+ * File: PedalMap.c
+ * Code generated for Simulink model 'PedalMap'.
+ * Model version : 4.07
+ */
+
+#include "PedalMap.h"
+#include "rtwtypes.h"
+
+/* Scale the raw pedal reading into a normalised request. */
+void PedalMap_step(void)
+{
+ rtY.Scaled = rtU.Raw * K_PedalGain;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/old/SpeedCtrl.arxml b/tests/fixtures/demo/old/SpeedCtrl.arxml
new file mode 100644
index 0000000..0b94c4d
--- /dev/null
+++ b/tests/fixtures/demo/old/SpeedCtrl.arxml
@@ -0,0 +1,23 @@
+
+
+
+
+ Powertrain
+
+
+ SpeedCtrl
+
+
+ Torque
+ /Powertrain/If_Torque
+
+
+ SpeedRequest
+ /Powertrain/If_Speed
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/SpeedCtrl.c b/tests/fixtures/demo/old/SpeedCtrl.c
new file mode 100644
index 0000000..e6401a1
--- /dev/null
+++ b/tests/fixtures/demo/old/SpeedCtrl.c
@@ -0,0 +1,33 @@
+/*
+ * File: SpeedCtrl.c
+ *
+ * Code generated for Simulink model 'SpeedCtrl'.
+ *
+ * Model version : 2.31
+ * Simulink Coder version : 9.9 (R2023a)
+ * C/C++ source code generated on : Mon Aug 11 08:02:15 2026
+ *
+ * Target selection: autosar.tlc
+ */
+
+#include "SpeedCtrl.h"
+#include "rtwtypes.h"
+
+ExtU_SpeedCtrl_T rtU;
+ExtY_SpeedCtrl_T rtY;
+
+/* Blend three independent driver inputs into one speed request. */
+void SpeedCtrl_step(void)
+{
+ real_T gainTorque;
+ real_T gainSpeed;
+ real_T gainPedal;
+
+ gainTorque = rtU.Torque * 1.10;
+ gainSpeed = rtU.Speed * 0.90;
+ gainPedal = rtU.Pedal * 2.00;
+
+ rtY.SpeedRequest = gainTorque + gainSpeed + gainPedal;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/old/SpeedCtrl.h b/tests/fixtures/demo/old/SpeedCtrl.h
new file mode 100644
index 0000000..7b537ee
--- /dev/null
+++ b/tests/fixtures/demo/old/SpeedCtrl.h
@@ -0,0 +1,23 @@
+/*
+ * File: SpeedCtrl.h
+ * Code generated for Simulink model 'SpeedCtrl'.
+ */
+
+#ifndef RTW_HEADER_SpeedCtrl_h_
+#define RTW_HEADER_SpeedCtrl_h_
+
+#include "rtwtypes.h"
+
+typedef struct {
+ real_T Torque;
+ real_T Speed;
+ real_T Pedal;
+} ExtU_SpeedCtrl_T;
+
+typedef struct {
+ real_T SpeedRequest;
+} ExtY_SpeedCtrl_T;
+
+extern void SpeedCtrl_step(void);
+
+#endif
diff --git a/tests/fixtures/demo/old/StaleGen.a2l b/tests/fixtures/demo/old/StaleGen.a2l
new file mode 100644
index 0000000..5b1c034
--- /dev/null
+++ b/tests/fixtures/demo/old/StaleGen.a2l
@@ -0,0 +1,8 @@
+ASAP2_VERSION 1 71
+/begin PROJECT StaleGen ""
+ /begin MODULE CAL ""
+ /begin CHARACTERISTIC K_Bias "output bias"
+ VALUE 0x9000 __UBYTE_Z 0 IDENTICAL 0 255
+ /end CHARACTERISTIC
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/old/StaleGen.arxml b/tests/fixtures/demo/old/StaleGen.arxml
new file mode 100644
index 0000000..6db041d
--- /dev/null
+++ b/tests/fixtures/demo/old/StaleGen.arxml
@@ -0,0 +1,19 @@
+
+
+
+
+ Powertrain
+
+
+ StaleGen
+
+
+ In
+ /Powertrain/If_In
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/StaleGen.c b/tests/fixtures/demo/old/StaleGen.c
new file mode 100644
index 0000000..64bd665
--- /dev/null
+++ b/tests/fixtures/demo/old/StaleGen.c
@@ -0,0 +1,16 @@
+/*
+ * File: StaleGen.c
+ * Code generated for Simulink model 'StaleGen'.
+ * Model version : 3.02
+ */
+
+#include "StaleGen.h"
+#include "rtwtypes.h"
+
+/* Pass the input straight through -- unchanged this regenerate. */
+void StaleGen_step(void)
+{
+ rtY.Out = rtU.In;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/old/TorqueLimiter.arxml b/tests/fixtures/demo/old/TorqueLimiter.arxml
new file mode 100644
index 0000000..6e915af
--- /dev/null
+++ b/tests/fixtures/demo/old/TorqueLimiter.arxml
@@ -0,0 +1,27 @@
+
+
+
+
+ Powertrain
+
+
+ TorqueLimiter
+
+
+ PedalPosition
+ /Powertrain/If_Pedal
+
+
+ MotorSpeed
+ /Powertrain/If_Speed
+
+
+ TorqueCmd
+ /Powertrain/If_Torque
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/TorqueLimiter.c b/tests/fixtures/demo/old/TorqueLimiter.c
new file mode 100644
index 0000000..f9becb8
--- /dev/null
+++ b/tests/fixtures/demo/old/TorqueLimiter.c
@@ -0,0 +1,37 @@
+/*
+ * File: TorqueLimiter.c
+ *
+ * Code generated for Simulink model 'TorqueLimiter'.
+ *
+ * Model version : 1.148
+ * Simulink Coder version : 9.9 (R2023a)
+ * C/C++ source code generated on : Mon Aug 11 09:14:22 2026
+ *
+ * Target selection: autosar.tlc
+ */
+
+#include "TorqueLimiter.h"
+#include "rtwtypes.h"
+
+extern real_T TorqueLimiter_LookupTorque(real_T speed);
+
+/* Model step function -- runs every 10 ms. */
+void Rte_Runnable_TorqueLimiter_Step(void)
+{
+ real_T rtb_Request;
+ real_T rtb_Ceiling;
+
+ rtb_Request = rtU.PedalPosition * 300.0;
+ rtb_Ceiling = TorqueLimiter_LookupTorque(rtU.MotorSpeed);
+
+ /* Gain: apply the driveability scaling factor to the raw request */
+ rtb_Request = rtb_Request * 1.25;
+
+ if (rtb_Request > rtb_Ceiling) {
+ rtb_Request = rtb_Ceiling;
+ }
+
+ rtY.TorqueCmd = rtb_Request;
+}
+
+/* [EOF] */
diff --git a/tests/fixtures/demo/old/models/Ctrl.c b/tests/fixtures/demo/old/models/Ctrl.c
new file mode 100644
index 0000000..490be36
--- /dev/null
+++ b/tests/fixtures/demo/old/models/Ctrl.c
@@ -0,0 +1,9 @@
+/* Model step function */
+#include "Ctrl.h"
+
+void Ctrl_Step(void)
+{
+ Float32 u;
+ (void) Rte_Read_In1_Speed(&u);
+ (void) Rte_Write_Out1_Cmd(u * 2.0F);
+}
diff --git a/tests/fixtures/demo/old/models/Ctrl.h b/tests/fixtures/demo/old/models/Ctrl.h
new file mode 100644
index 0000000..9118c98
--- /dev/null
+++ b/tests/fixtures/demo/old/models/Ctrl.h
@@ -0,0 +1,5 @@
+#ifndef CTRL_H
+#define CTRL_H
+#include "rtwtypes.h"
+void Ctrl_Step(void);
+#endif
diff --git a/tests/fixtures/demo/old/models/Ctrl_component.arxml b/tests/fixtures/demo/old/models/Ctrl_component.arxml
new file mode 100644
index 0000000..ac6153a
--- /dev/null
+++ b/tests/fixtures/demo/old/models/Ctrl_component.arxml
@@ -0,0 +1,41 @@
+
+
+
+
+ Components
+
+
+ Ctrl
+
+
+ In1
+ /Interfaces/If_Speed
+
+
+ Out1
+ /Interfaces/If_Cmd
+
+
+
+
+ IB
+
+
+ TE_Step
+ /Components/Ctrl/IB/Ctrl_Step
+ 0.01
+
+
+
+
+ Ctrl_Step
+ Ctrl_Step
+
+
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/models/Ctrl_types.h b/tests/fixtures/demo/old/models/Ctrl_types.h
new file mode 100644
index 0000000..2c62bad
--- /dev/null
+++ b/tests/fixtures/demo/old/models/Ctrl_types.h
@@ -0,0 +1,4 @@
+#ifndef CTRL_TYPES_H
+#define CTRL_TYPES_H
+typedef float Float32;
+#endif
diff --git a/tests/fixtures/demo/old/models/rtwtypes.h b/tests/fixtures/demo/old/models/rtwtypes.h
new file mode 100644
index 0000000..8362173
--- /dev/null
+++ b/tests/fixtures/demo/old/models/rtwtypes.h
@@ -0,0 +1,4 @@
+#ifndef RTWTYPES_H
+#define RTWTYPES_H
+typedef unsigned char boolean_T;
+#endif
diff --git a/tests/fixtures/demo/old/rules/a2l/cal.a2l b/tests/fixtures/demo/old/rules/a2l/cal.a2l
new file mode 100644
index 0000000..be9196c
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/a2l/cal.a2l
@@ -0,0 +1,13 @@
+/* generated by demo toolchain -- Mon Jan 05 2026 */
+ASAP2_VERSION 1 71
+/begin PROJECT Demo ""
+ /begin MODULE Ctrl ""
+ /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000
+ ECU_ADDRESS 0x40001000
+ /end MEASUREMENT
+ /begin CHARACTERISTIC K_Gain "controller gain" VALUE 0x80001000 __Scalar 100 CM_Gain 0 10
+ /begin IF_DATA XCP
+ /end IF_DATA
+ /end CHARACTERISTIC
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/old/rules/a2l/comment_only.a2l b/tests/fixtures/demo/old/rules/a2l/comment_only.a2l
new file mode 100644
index 0000000..6f9b37d
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/a2l/comment_only.a2l
@@ -0,0 +1,8 @@
+/* generated Mon */
+ASAP2_VERSION 1 71
+/begin PROJECT Demo ""
+ /begin MODULE Ctrl ""
+ /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000
+ /end MEASUREMENT
+ /end MODULE
+/end PROJECT
diff --git a/tests/fixtures/demo/old/rules/arxml/admindata.arxml b/tests/fixtures/demo/old/rules/arxml/admindata.arxml
new file mode 100644
index 0000000..0a3593f
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/arxml/admindata.arxml
@@ -0,0 +1,16 @@
+
+
+
+
+
+ 9.8
+ 2026-01-05T10:12:33
+
+
+
+
+
+ Interfaces
+
+
+
diff --git a/tests/fixtures/demo/old/rules/arxml/iface.arxml b/tests/fixtures/demo/old/rules/arxml/iface.arxml
new file mode 100644
index 0000000..0d2f4ad
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/arxml/iface.arxml
@@ -0,0 +1,26 @@
+
+
+
+
+ Interfaces
+
+
+ If_Speed
+
+
+ Speed
+
+
+
+
+ If_Diag
+
+
+ ReadDtc
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/rules/arxml/real_change.arxml b/tests/fixtures/demo/old/rules/arxml/real_change.arxml
new file mode 100644
index 0000000..6a8ea65
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/arxml/real_change.arxml
@@ -0,0 +1,13 @@
+
+
+
+
+ DataTypes
+
+
+ Speed_T
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/rules/arxml/uuid_only.arxml b/tests/fixtures/demo/old/rules/arxml/uuid_only.arxml
new file mode 100644
index 0000000..cb0fc1b
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/arxml/uuid_only.arxml
@@ -0,0 +1,18 @@
+
+
+
+
+ ComponentTypes
+
+
+ Controller
+
+
+ Out1
+
+
+
+
+
+
+
diff --git a/tests/fixtures/demo/old/rules/src/comment_only.c b/tests/fixtures/demo/old/rules/src/comment_only.c
new file mode 100644
index 0000000..329aa4a
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/src/comment_only.c
@@ -0,0 +1,15 @@
+/*
+ * File: comment_only.c
+ * Code generated for Simulink model 'Model'.
+ * Model version : 1.42
+ * Simulink Coder version : 9.8 (R2023a) 19-Nov-2022
+ * C/C++ source code generated on : Mon Jan 05 10:12:33 2026
+ */
+#include "comment_only.h"
+
+/* Model step function */
+void Model_step(void)
+{
+ /* Outport: '/Out1' */
+ rtY.Out1 = rtU.In1 * 2.0;
+}
diff --git a/tests/fixtures/demo/old/rules/src/deleted.h b/tests/fixtures/demo/old/rules/src/deleted.h
new file mode 100644
index 0000000..f0e6007
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/src/deleted.h
@@ -0,0 +1,6 @@
+#ifndef DELETED_H
+#define DELETED_H
+
+extern void Old_step(void);
+
+#endif
diff --git a/tests/fixtures/demo/old/rules/src/real_change.c b/tests/fixtures/demo/old/rules/src/real_change.c
new file mode 100644
index 0000000..2e796cb
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/src/real_change.c
@@ -0,0 +1,12 @@
+/* Generated on : Mon Jan 05 10:12:33 2026 */
+#include "real_change.h"
+
+void Calc_step(void)
+{
+ /* saturation limit */
+ if (rtU.In1 > 5) {
+ rtY.Out1 = 5;
+ } else {
+ rtY.Out1 = rtU.In1;
+ }
+}
diff --git a/tests/fixtures/demo/old/rules/src/rename_conflict.c b/tests/fixtures/demo/old/rules/src/rename_conflict.c
new file mode 100644
index 0000000..83b9caf
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/src/rename_conflict.c
@@ -0,0 +1,10 @@
+#include "rename_conflict.h"
+
+void Conf_step(void)
+{
+ real_T rtb_A;
+
+ rtb_A = rtU.In1 * 2.0;
+ rtY.Out1 = rtb_A + 1.0;
+ rtY.Out2 = rtb_A + 2.0;
+}
diff --git a/tests/fixtures/demo/old/rules/src/rename_only.c b/tests/fixtures/demo/old/rules/src/rename_only.c
new file mode 100644
index 0000000..6436f05
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/src/rename_only.c
@@ -0,0 +1,15 @@
+#include "rename_only.h"
+
+void Sub_step(void)
+{
+ real_T rtb_Sum1;
+ real_T rtb_Gain2;
+
+ rtb_Sum1 = rtU.In1 + rtU.In2;
+ rtb_Gain2 = rtb_Sum1 * 3.5;
+ if (rtb_Sum1 > 0.0) {
+ rtY.Out1 = rtb_Gain2;
+ } else {
+ rtY.Out1 = rtb_Sum1;
+ }
+}
diff --git a/tests/fixtures/demo/old/rules/src/same.h b/tests/fixtures/demo/old/rules/src/same.h
new file mode 100644
index 0000000..a3d7f02
--- /dev/null
+++ b/tests/fixtures/demo/old/rules/src/same.h
@@ -0,0 +1,6 @@
+#ifndef SAME_H
+#define SAME_H
+
+extern void Sub_step(void);
+
+#endif
diff --git a/tests/test_consistency.py b/tests/test_consistency.py
new file mode 100644
index 0000000..9f70495
--- /dev/null
+++ b/tests/test_consistency.py
@@ -0,0 +1,80 @@
+"""Cross-artifact consistency advisories.
+
+The rule runs one way: a real change to the interface (ARXML) or the
+calibration surface (A2L) must be reflected in the generated C. A code-only
+change is the ordinary case and is never flagged.
+"""
+
+import unittest
+
+from compare_tool import consistency
+
+
+def _results(**status_by_rel):
+ return {rel: {'status': st} for rel, st in status_by_rel.items()}
+
+
+def _adv(groups, results, shared=None):
+ return consistency.model_advisories(groups, results, shared)
+
+
+class TestModelAdvisories(unittest.TestCase):
+ def test_arxml_changed_c_not_is_flagged(self):
+ results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'real-change'})
+ adv = _adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results)
+ self.assertEqual(len(adv), 1)
+ self.assertEqual(adv[0][0], 'Ctrl')
+ self.assertEqual(adv[0][1], 'ARXML changed but the generated C did not')
+
+ def test_a2l_changed_c_not_is_flagged(self):
+ results = _results(**{'Ctrl.c': 'identical', 'Ctrl.a2l': 'real-change'})
+ adv = _adv({'Ctrl': ['Ctrl.c', 'Ctrl.a2l']}, results)
+ self.assertEqual(adv[0][1], 'A2L changed but the generated C did not')
+
+ def test_both_surfaces_changed_c_not_is_one_combined_flag(self):
+ results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'real-change',
+ 'Ctrl.a2l': 'added'})
+ adv = _adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml', 'Ctrl.a2l']}, results)
+ self.assertEqual(adv[0][1],
+ 'ARXML and A2L changed but the generated C did not')
+
+ def test_code_only_change_is_not_flagged(self):
+ # the corrected direction: C changed, the surfaces did not -> normal
+ results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'identical',
+ 'Ctrl.a2l': 'identical'})
+ self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml', 'Ctrl.a2l']},
+ results), [])
+
+ def test_both_changed_together_is_quiet(self):
+ results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'real-change'})
+ self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results), [])
+
+ def test_neither_changed_is_quiet(self):
+ results = _results(**{'Ctrl.c': 'identical',
+ 'Ctrl.arxml': 'ignorable-only'})
+ self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results), [])
+
+ def test_no_c_in_the_model_is_quiet(self):
+ # nothing generated to have followed the interface change
+ results = _results(**{'Ctrl.arxml': 'real-change'})
+ self.assertEqual(_adv({'Ctrl': ['Ctrl.arxml']}, results), [])
+
+ def test_noise_only_surface_change_is_not_real(self):
+ # an ARXML that only churned UUIDs (ignorable-only) did not really
+ # change, so a stale C is not a desync
+ results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'ignorable-only'})
+ self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results), [])
+
+ def test_shared_bucket_skipped(self):
+ results = _results(**{'util.c': 'identical', 'util.arxml': 'real-change'})
+ self.assertEqual(
+ _adv({'Shared / other': ['util.c', 'util.arxml']}, results,
+ 'Shared / other'), [])
+
+ def test_deleted_arxml_counts_as_changed(self):
+ results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'deleted'})
+ self.assertEqual(len(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results)), 1)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_demo.py b/tests/test_demo.py
new file mode 100644
index 0000000..3dffe68
--- /dev/null
+++ b/tests/test_demo.py
@@ -0,0 +1,129 @@
+"""The demo tree under fixtures/demo: one folder pair covering every noise rule
+and the three newest features in a single compare.
+
+`fixtures/demo/old` vs `fixtures/demo/new` is the pair a human runs (see
+fixtures/demo/README.md). Four top-level models make the newest features'
+point; `rules/` and `models/` are copies of the tool's own noise-rule and
+model-grouping fixtures, folded in so the same one compare also shows every
+ignorable kind (comment, uuid, timestamp, rename), an added and a deleted file,
+side by side with what is real. Copies, not moves -- `tests/fixtures/old`,
+`new`, `model_old` and `model_new` stay put, since other tests pin exact
+counts and paths against them.
+
+These tests lock what the merged demo claims, so it can never quietly stop
+demonstrating what it says it does."""
+
+import json
+import unittest
+from pathlib import Path
+
+from compare_tool import serialize
+from compare_tool.report import consistency_advisories
+from compare_tool.scanner import scan, summarize_a2l, summarize_swcs
+
+DEMO = Path(__file__).parent / 'fixtures' / 'demo'
+
+
+def _kinds(r):
+ return {h['kind'] for h in r['hunks']}
+
+
+class TestDemoTree(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.res = scan(str(DEMO / 'old'), str(DEMO / 'new'))
+
+ # --- feature 1: provably-safe statement reorder folds to noise ---
+
+ def test_reorder_folds_speedctrl_to_unimportant(self):
+ r = self.res['SpeedCtrl.c']
+ self.assertEqual(r['status'], 'ignorable-only')
+ self.assertIn('reorder', _kinds(r))
+ self.assertNotIn('real', _kinds(r))
+
+ # --- feature 4: cross-artifact consistency advisory ---
+
+ def test_consistency_flags_only_the_stale_model(self):
+ adv = consistency_advisories(self.res)
+ models = [m for m, _msg in adv]
+ # StaleGen's ARXML and A2L really changed while its C stayed identical.
+ # Nothing else is out of step, so it is the only model named.
+ self.assertEqual(models, ['StaleGen'])
+ self.assertEqual(adv[0][1],
+ 'ARXML and A2L changed but the generated C did not')
+
+ def test_stale_model_verdicts_drive_the_flag(self):
+ self.assertEqual(self.res['StaleGen.arxml']['status'], 'real-change')
+ self.assertEqual(self.res['StaleGen.a2l']['status'], 'real-change')
+ self.assertEqual(self.res['StaleGen.c']['status'], 'identical')
+
+ def test_code_only_change_is_not_flagged(self):
+ # TorqueLimiter's C changed (a gain) but its ARXML did not -- a logic
+ # edit touches no interface, so this is normal and must NOT be flagged
+ self.assertEqual(self.res['TorqueLimiter.c']['status'], 'real-change')
+ self.assertEqual(self.res['TorqueLimiter.arxml']['status'], 'identical')
+ self.assertNotIn('TorqueLimiter',
+ [m for m, _ in consistency_advisories(self.res)])
+
+ def test_surfaces_and_code_changing_together_is_quiet(self):
+ # PedalMap changed its C, its ARXML (a new port) and its A2L together
+ self.assertEqual(self.res['PedalMap.c']['status'], 'real-change')
+ self.assertEqual(self.res['PedalMap.arxml']['status'], 'real-change')
+ self.assertEqual(self.res['PedalMap.a2l']['status'], 'real-change')
+ self.assertNotIn('PedalMap',
+ [m for m, _ in consistency_advisories(self.res)])
+
+ def test_autosar_summary_sees_the_new_objects(self):
+ swc = summarize_swcs(self.res)
+ ports = [(rel, name) for rel, _swc, name, _desc in swc['ports']['added']]
+ self.assertIn(('PedalMap.arxml', 'Scaled'), ports)
+ added, _removed = summarize_a2l(self.res)
+ self.assertIn(('PedalMap.a2l', 'K_PedalOffset', 'CHARACTERISTIC'), added)
+
+ # --- feature 5: machine-readable output ---
+
+ def test_sarif_lists_only_actionable_files(self):
+ log = serialize.build_sarif(self.res)
+ uris = {r['locations'][0]['physicalLocation']['artifactLocation']['uri']
+ for r in log['runs'][0]['results']}
+ # the reordered file, the stale (identical) C, and any Unimportant /
+ # Comment file are NOT findings
+ for rel in ('SpeedCtrl.c', 'StaleGen.c', 'rules/arxml/uuid_only.arxml',
+ 'rules/src/comment_only.c', 'rules/src/rename_only.c'):
+ self.assertNotIn(rel, uris)
+ for rel in ('TorqueLimiter.c', 'PedalMap.c', 'PedalMap.arxml',
+ 'PedalMap.a2l', 'StaleGen.arxml', 'StaleGen.a2l',
+ 'rules/src/added.c', 'rules/src/deleted.h',
+ 'rules/src/real_change.c'):
+ self.assertIn(rel, uris)
+
+ # --- one compare, every noise kind ---
+
+ def test_every_ignorable_kind_is_represented(self):
+ seen = {h['kind'] for r in self.res.values() for h in r.get('hunks', [])}
+ for kind in ('comment', 'reorder', 'rename', 'uuid', 'timestamp'):
+ self.assertIn(kind, seen)
+
+ def test_added_and_deleted_are_represented(self):
+ statuses = {r['status'] for r in self.res.values()}
+ self.assertIn('added', statuses)
+ self.assertIn('deleted', statuses)
+
+ def test_model_grouping_still_separates_the_four_demo_models(self):
+ from compare_tool.report import _model_groups
+ groups = _model_groups(self.res)
+ for model in ('SpeedCtrl', 'StaleGen', 'TorqueLimiter', 'PedalMap'):
+ self.assertIn(model, groups)
+
+ def test_json_round_trips_and_carries_the_reorder(self):
+ counts = {k: 0 for k in ('identical', 'comment-only', 'ignorable-only',
+ 'real-change', 'added', 'deleted', 'error')}
+ text = serialize.dumps(self.res, counts, 'old', 'new', 1)
+ doc = json.loads(text)
+ speed = next(f for f in doc['files'] if f['path'] == 'SpeedCtrl.c')
+ self.assertEqual(speed['status'], 'ignorable-only')
+ self.assertIn('reorder', {h['kind'] for h in speed['hunks']})
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_diffpane_qt.py b/tests/test_diffpane_qt.py
index 54a8083..df7a6b6 100644
--- a/tests/test_diffpane_qt.py
+++ b/tests/test_diffpane_qt.py
@@ -1010,5 +1010,23 @@ def test_both_empty_is_blank(self):
self.assertEqual(dlg._start_dir(dlg.old_row), '')
+@unittest.skipUnless(HAVE_QT, 'PySide6 not installed')
+class TestSemanticSummary(unittest.TestCase):
+ """The AUTOSAR/A2L chips that ride on the file header line."""
+
+ def test_a2l_is_spelled_by_kind_with_no_prefix(self):
+ from compare_tool.qtviewer.diffpane import _semantic_summary
+ s = _semantic_summary({'a2l': {'added': [('K_Gain', 'CHARACTERISTIC')],
+ 'removed': [('EngSpd', 'MEASUREMENT')]}})
+ self.assertIn('+1 Characteristic', s)
+ self.assertIn('−1 Measurement', s)
+ self.assertNotIn('A2L', s)
+ self.assertNotIn('AUTOSAR', s)
+
+ def test_nothing_semantic_is_blank(self):
+ from compare_tool.qtviewer.diffpane import _semantic_summary
+ self.assertEqual(_semantic_summary({}), '')
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/test_engine.py b/tests/test_engine.py
index c51ad7a..c65426a 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -99,6 +99,39 @@ def test_variable_swap_is_real(self):
self.assertEqual(r['status'], 'real-change')
self.assertEqual(r['renames'], {})
+ def test_independent_reorder_is_noise(self):
+ # Embedded Coder emits the same independent assignments in a different
+ # order; the values are identical, so it is proven noise, not a change
+ old = "a = u + 1;\nb = v + 2;\nc = w + 3;\n"
+ new = "c = w + 3;\nb = v + 2;\na = u + 1;\n"
+ r = compare_pair(old, new, 'f.c')
+ self.assertEqual(r['status'], 'ignorable-only')
+ self.assertEqual(set(kinds(r)), {'reorder'})
+
+ def test_dependent_reorder_stays_real(self):
+ # moving 'y = t + 2' above the line that computes t changes the result,
+ # so the reorder is NOT safe and must stay real
+ old = "t = u + 1;\ny = t + 2;\n"
+ new = "y = t + 2;\nt = u + 1;\n"
+ r = compare_pair(old, new, 'f.c')
+ self.assertEqual(r['status'], 'real-change')
+
+ def test_reorder_beside_real_change_stays_real(self):
+ # fail-safe: a genuine RHS change (v+2 -> v+99) mixed into a reorder
+ # breaks the permutation, so the whole block stays real
+ old = "a = u + 1;\nb = v + 2;\nc = w + 3;\n"
+ new = "c = w + 3;\nb = v + 99;\na = u + 1;\n"
+ r = compare_pair(old, new, 'f.c')
+ self.assertEqual(r['status'], 'real-change')
+
+ def test_reorder_across_a_call_stays_real(self):
+ # a call between the reordered lines may have side effects; the block is
+ # no longer straight-line scalar, so it is not folded
+ old = "a = u + 1;\nb = step(a);\nc = w + 3;\n"
+ new = "c = w + 3;\nb = step(a);\na = u + 1;\n"
+ r = compare_pair(old, new, 'f.c')
+ self.assertEqual(r['status'], 'real-change')
+
def test_arxml_uuid_only(self):
old = '\nx\n\n'
new = '\nx\n\n'
@@ -492,8 +525,12 @@ def test_moved_hunks_cross_reference_lines(self):
self.assertEqual(froms[0], 6)
def test_single_line_move_stays_real(self):
- old = "a = 1;\nb = 2;\nc = 3;\n"
- new = "b = 2;\nc = 3;\na = 1;\n"
+ # a single moved line is not confidently a move (MIN_MOVED_LINES): a
+ # lone statement reappears by coincidence too often. Calls, so the
+ # reorder proof (scalar assignments only) does not apply and cannot
+ # fold this either -- a moved side-effecting call IS a real change.
+ old = "f();\ng();\nh();\n"
+ new = "g();\nh();\nf();\n"
r = compare_pair(old, new, 'f.c')
self.assertEqual(r['status'], 'real-change')
self.assertNotIn('moved', kinds(r))
diff --git a/tests/test_gitsource.py b/tests/test_gitsource.py
index 4ee6b97..5800f82 100644
--- a/tests/test_gitsource.py
+++ b/tests/test_gitsource.py
@@ -22,7 +22,16 @@ def _git(root, *args):
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
-@unittest.skipUnless(gitsource.git_available(), 'git is not installed')
+def _git_available():
+ try:
+ subprocess.run(['git', '--version'], stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL, timeout=10)
+ except (OSError, subprocess.SubprocessError):
+ return False
+ return True
+
+
+@unittest.skipUnless(_git_available(), 'git is not installed')
class _RepoCase(unittest.TestCase):
"""A three-commit repository: gen/ appears in c1, changes in c2, and c3
touches only a file outside it."""
diff --git a/tests/test_report.py b/tests/test_report.py
index 01a136f..f7d13eb 100644
--- a/tests/test_report.py
+++ b/tests/test_report.py
@@ -5,9 +5,10 @@
from pathlib import Path
from compare_tool.diff_engine import compare_pair
-from compare_tool.report import (_char_diff, _counts_html, _CSS, _group_hunks,
- _group_table, _groups_html, _model_groups,
- _THEME_JS, build_arxml_report, build_report)
+from compare_tool.report import (_autosar_chips, _char_diff, _counts_html,
+ _CSS, _group_hunks, _group_table, _groups_html,
+ _model_groups, _THEME_JS, build_arxml_report,
+ build_report)
from compare_tool.scanner import scan
FIX = Path(__file__).parent / 'fixtures'
@@ -1088,5 +1089,18 @@ def test_moved_group_not_hidden_by_unimportant_toggle(self):
self.assertNotIn('', self.out)
+class TestAutosarChips(unittest.TestCase):
+ def test_a2l_chips_spell_the_object_kind(self):
+ # a generic '+N A2L' hides which kind moved -- split it out
+ results = {'m.a2l': {'a2l': {'added': [('K_Gain', 'CHARACTERISTIC'),
+ ('EngSpd', 'MEASUREMENT')],
+ 'removed': []}}}
+ chips = _autosar_chips(['m.a2l'], results)
+ self.assertIn('Characteristic', chips)
+ self.assertIn('Measurement', chips)
+ self.assertNotIn('>A2L', chips)
+ self.assertNotIn(' A2L', chips)
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/test_rules.py b/tests/test_rules.py
index edf8aee..2e0ca10 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -587,5 +587,57 @@ def test_diff_one_side_missing_file(self):
('K_Gain', 'CHARACTERISTIC')])
+class TestReorderEquivalent(unittest.TestCase):
+ def test_independent_permutation(self):
+ self.assertTrue(c_rules.reorder_equivalent(
+ ['a = u + 1;', 'b = v + 2;', 'c = w + 3;'],
+ ['c = w + 3;', 'a = u + 1;', 'b = v + 2;']))
+
+ def test_true_dependence_order_kept_is_ok(self):
+ # t is written then read; the new order keeps that, only independent
+ # neighbours move around it
+ self.assertTrue(c_rules.reorder_equivalent(
+ ['t = u + 1;', 'y = t + 2;', 'z = w + 3;'],
+ ['z = w + 3;', 't = u + 1;', 'y = t + 2;']))
+
+ def test_true_dependence_flipped_is_not(self):
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['t = u + 1;', 'y = t + 2;'],
+ ['y = t + 2;', 't = u + 1;']))
+
+ def test_output_dependence_flipped_is_not(self):
+ # two writes to the same variable: their order is the result
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['x = 1;', 'x = 2;'],
+ ['x = 2;', 'x = 1;']))
+
+ def test_anti_dependence_flipped_is_not(self):
+ # read of x then overwrite of x (WAR): flipping changes what y sees
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['y = x + 1;', 'x = 5;'],
+ ['x = 5;', 'y = x + 1;']))
+
+ def test_changed_statement_is_not(self):
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['a = u + 1;', 'b = v + 2;'],
+ ['b = v + 9;', 'a = u + 1;']))
+
+ def test_call_is_not_safe(self):
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['a = u + 1;', 'b = step(a);'],
+ ['b = step(a);', 'a = u + 1;']))
+
+ def test_non_scalar_lhs_is_not_safe(self):
+ # a store through an array could alias another statement's read
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['arr[i] = u;', 'b = v + 2;'],
+ ['b = v + 2;', 'arr[i] = u;']))
+
+ def test_same_order_is_not_a_reorder(self):
+ self.assertFalse(c_rules.reorder_equivalent(
+ ['a = u + 1;', 'b = v + 2;'],
+ ['a = u + 1;', 'b = v + 2;']))
+
+
if __name__ == '__main__':
unittest.main()
diff --git a/tests/test_serialize.py b/tests/test_serialize.py
new file mode 100644
index 0000000..869a76a
--- /dev/null
+++ b/tests/test_serialize.py
@@ -0,0 +1,134 @@
+"""Machine-readable JSON / SARIF output."""
+
+import contextlib
+import io
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from compare_tool import serialize
+from compare_tool.main import main
+
+
+def _r(status, **extra):
+ base = {'status': status, 'binary': False, 'notes': [], 'renames': {},
+ 'hunks': []}
+ base.update(extra)
+ return base
+
+
+_RESULTS = {
+ 'a.c': _r('real-change',
+ hunks=[{'kind': 'real', 'old_range': [1, 2], 'new_range': [1, 2]},
+ {'kind': 'moved', 'old_range': [5, 7], 'new_range': [5, 5],
+ 'moved_to': 20}]),
+ 'b.c': _r('identical'),
+ 'c.arxml': _r('added', ifaces={'added': [['/Pkg/If', 'SENDER-RECEIVER-INTERFACE']],
+ 'removed': []}),
+ 'd.dat': _r('error', notes=['boom']),
+}
+_COUNTS = {'identical': 1, 'comment-only': 0, 'ignorable-only': 0,
+ 'real-change': 1, 'added': 1, 'deleted': 0, 'error': 1}
+
+
+class TestJson(unittest.TestCase):
+ def _doc(self, exit_code=2):
+ return serialize.build(_RESULTS, _COUNTS, 'old', 'new', exit_code,
+ advisories=[('Ctrl', 'C changed, ARXML did not')])
+
+ def test_round_trips_as_json(self):
+ text = serialize.dumps(_RESULTS, _COUNTS, 'old', 'new', 1)
+ doc = json.loads(text)
+ self.assertEqual(doc['schema'], serialize.SCHEMA)
+ self.assertEqual(doc['tool'], 'codegen-compare-tool')
+ self.assertEqual(doc['exit_code'], 1)
+
+ def test_every_file_present_and_sorted(self):
+ doc = self._doc()
+ paths = [f['path'] for f in doc['files']]
+ self.assertEqual(paths, sorted(_RESULTS))
+
+ def test_identical_file_carries_no_hunks_key(self):
+ doc = self._doc()
+ b = next(f for f in doc['files'] if f['path'] == 'b.c')
+ self.assertEqual(b['status'], 'identical')
+ self.assertNotIn('hunks', b)
+
+ def test_hunks_and_move_serialised(self):
+ doc = self._doc()
+ a = next(f for f in doc['files'] if f['path'] == 'a.c')
+ self.assertEqual([h['kind'] for h in a['hunks']], ['real', 'moved'])
+ self.assertEqual(a['hunks'][1]['moved_to'], 20)
+
+ def test_semantic_extra_passed_through(self):
+ doc = self._doc()
+ c = next(f for f in doc['files'] if f['path'] == 'c.arxml')
+ self.assertEqual(c['ifaces']['added'][0][1], 'SENDER-RECEIVER-INTERFACE')
+
+ def test_summary_and_advisories(self):
+ doc = self._doc()
+ self.assertEqual(doc['summary']['real-change'], 1)
+ self.assertEqual(doc['consistency'][0]['model'], 'Ctrl')
+
+
+class TestSarif(unittest.TestCase):
+ def test_only_actionable_files_are_findings(self):
+ log = serialize.build_sarif(_RESULTS)
+ results = log['runs'][0]['results']
+ uris = sorted(r['locations'][0]['physicalLocation']['artifactLocation']['uri']
+ for r in results)
+ # b.c (identical) is not a finding
+ self.assertEqual(uris, ['a.c', 'c.arxml', 'd.dat'])
+
+ def test_error_is_error_level_change_is_warning(self):
+ log = serialize.build_sarif(_RESULTS)
+ by_uri = {r['locations'][0]['physicalLocation']['artifactLocation']['uri']:
+ r['level'] for r in log['runs'][0]['results']}
+ self.assertEqual(by_uri['d.dat'], 'error')
+ self.assertEqual(by_uri['a.c'], 'warning')
+
+ def test_valid_sarif_envelope(self):
+ log = serialize.build_sarif(_RESULTS)
+ self.assertEqual(log['version'], '2.1.0')
+ self.assertIn('rules', log['runs'][0]['tool']['driver'])
+ json.dumps(log) # must be serialisable
+
+
+class TestCliWritesMachineOutput(unittest.TestCase):
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ root = Path(self.tmp.name)
+ self.old = root / 'old'
+ self.new = root / 'new'
+ self.old.mkdir()
+ self.new.mkdir()
+ (self.old / 'm.c').write_text('int x = 1;\n', encoding='utf-8')
+ (self.new / 'm.c').write_text('int x = 2;\n', encoding='utf-8')
+ self.report = root / 'r.html'
+ self.json = root / 'out.json'
+ self.sarif = root / 'out.sarif'
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def _run(self, *extra):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = main([str(self.old), str(self.new), '--report', str(self.report),
+ *extra])
+ return rc, buf.getvalue()
+
+ def test_json_and_sarif_written_with_matching_exit_code(self):
+ rc, out = self._run('--json', str(self.json), '--sarif', str(self.sarif))
+ self.assertEqual(rc, 1) # a real change
+ doc = json.loads(self.json.read_text(encoding='utf-8'))
+ self.assertEqual(doc['exit_code'], 1)
+ self.assertEqual(doc['summary']['real-change'], 1)
+ log = json.loads(self.sarif.read_text(encoding='utf-8'))
+ self.assertEqual(log['runs'][0]['results'][0]['ruleId'], 'real-change')
+ self.assertIn('JSON written', out)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/test_view_model.py b/tests/test_view_model.py
index 29b45d1..dd19f45 100644
--- a/tests/test_view_model.py
+++ b/tests/test_view_model.py
@@ -11,7 +11,8 @@
from compare_tool.qtviewer.summary_model import summary_sections
from compare_tool.report import _char_diff, _swc_note
from compare_tool.scanner import summarize
-from compare_tool.view_model import (MUTED, SWC_DISPLAY, Row, aligned_rows,
+from compare_tool.view_model import (A2L_KINDS, MUTED, SWC_DISPLAY, Row,
+ a2l_kind_label, aligned_rows,
char_span, hunk_row_starts, mute_rows,
row_with)
@@ -322,3 +323,12 @@ def test_the_terminal_summary_names_every_category(self):
text = '\n'.join(summary_lines(results, summarize(results)))
for cat in SWC_DISPLAY:
self.assertIn(cat.noun, text)
+
+
+class TestA2lKindLabel(unittest.TestCase):
+ def test_capitalised_for_a_chip(self):
+ self.assertEqual(a2l_kind_label('CHARACTERISTIC'), 'Characteristic')
+ self.assertEqual(a2l_kind_label('MEASUREMENT'), 'Measurement')
+
+ def test_kinds_are_the_a2l_object_kinds(self):
+ self.assertEqual(tuple(A2L_KINDS), ('CHARACTERISTIC', 'MEASUREMENT'))