Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ All notable changes to this project are documented here. Versions follow

## [Unreleased]

## [1.11.0] — 2026-08-22

### Added

- **Warn when a new RTE access needs a full architecture regenerate.** When one
model's code adds an RTE access point while another model's code is left
identical — the sign of a quick single-model regenerate — the report and the
terminal flag it: the new interface cannot be integrated until the
architecture is regenerated so the other models pick it up. Heads-up only: it
never changes a file's verdict or the exit code.
- **Show the consistency heads-up in the viewer.** The cautions the report and
the terminal already print now appear live in the viewer too, bottom-left
under the quick-changes panel.

### Fixed

- **Stop the interface-vs-code advisory from firing on library churn.** The
“ARXML changed but the generated C did not” heads-up now triggers when an
actual access point moves — a port, runnable, event or calibration object
added or removed — instead of on any change to the file. A regenerated ARXML
that only rewrote shared library packages no longer raises a false warning. A
file that could not be read that far, such as malformed XML, still does.

## [1.10.0] — 2026-08-16

### Added
Expand Down
2 changes: 1 addition & 1 deletion compare_tool/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""CodeGen Compare Tool - AUTOSAR MATLAB codegen diff with noise filtering."""

__version__ = "1.10.0"
__version__ = "1.11.0"
117 changes: 111 additions & 6 deletions compare_tool/consistency.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,26 @@
everyday cause is a stale or partial regenerate -- the model was re-exported but
the code was not.

"Really changed" is measured at the **access-point level, not the file level**.
The trigger is a parsed port-interface / SWC port·runnable·event added or
removed (ARXML), or a calibration object added or removed (A2L) -- never the
mere fact that the file's bytes differ. Base types, compu-methods and other
shared library packages get rewritten on every export without touching a single
access point; keying the advisory on the file's verdict flagged that churn as a
desync, which is exactly the false alarm this level of granularity removes.

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.

A second, cross-*model* advisory rides along here. When one model's generated C
gains an RTE access point (``+ Rte_Write_...``) while a *peer* model's C stayed
byte-identical, the batch was a single-model quick regen, not a full one: a full
regenerate rewrites at least a timestamp banner in every model, so an identical
peer is proof it was left untouched. A new RTE access widens the model's
interface, and the RTE layer plus the peer SWCs have to be regenerated before
the code will integrate -- so this ``+RTE`` cannot be shipped on its own.

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.
Expand All @@ -32,20 +48,64 @@
_SURFACES = (('arxml', 'ARXML'), ('a2l', 'A2L'))


def _iface_changed(r):
"""True when an ARXML file's interface surface really moved -- a
port-interface or an SWC port/runnable/event was added, removed or
retargeted. This is the ONLY ARXML change the code has to follow; library
packages (base types, compu-methods, units) churn on every export without
touching an access point, so keying on the file's verdict would flag them.

``ifaces`` is stored whenever the file PARSED, even with an empty diff, so
its presence is the marker for "the semantic pass actually ran". An empty
diff is therefore a proof of noise and stays quiet; an ABSENT key on a
changed file means the pass could not run at all -- binary content, or XML
that would not parse -- and proves nothing, so it counts as changed. That is
the fail-safe direction: unprovable is never noise. ``swc`` is stored only
when non-empty, so its presence is already a real move."""
d = r.get('ifaces')
if d is None:
return r['status'] in _CHANGED
return bool(d['added'] or d['removed'] or r.get('swc'))


def _a2l_changed(r):
"""True when an A2L file added or removed a calibration object. A changed
value or record layout is not a new symbol, so it needs no code; ``a2l`` is
stored only when an object was added or removed, so an absent key on a text
file means the scan looked and found nothing to follow.

A BINARY a2l is the one file the scan never looked at, and an unexamined
change proves nothing -- same fail-safe direction as :func:`_iface_changed`.
(The scanner only marks ``binary`` on the two-sided compare path; a binary
a2l that was added or deleted outright is not distinguishable here.)"""
return bool(r.get('a2l')) or (r['status'] in _CHANGED and r.get('binary', False))


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."""
file of that family in the compare at all. ``changed`` is where the
access-point granularity lives: for ``c`` it is any reported code change
(that is the follow we are checking for); for ``arxml`` / ``a2l`` it is a
real access-point / object move, read from the parsed semantic diff -- not
the file's verdict, so library churn does not raise the advisory."""
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
r = results[rel]
if fam == 'c':
if r['status'] in _CHANGED:
changed['c'] = True
elif fam == 'arxml':
if _iface_changed(r):
changed['arxml'] = True
elif fam == 'a2l':
if _a2l_changed(r):
changed['a2l'] = True
return present, changed


Expand All @@ -57,8 +117,12 @@ def model_advisories(groups, results, shared_group=None):
``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.
code-only change (C changed, the surfaces did not) is never flagged.

Both kinds of advisory come back in ONE list sorted by model name: the two
are gathered by separate passes, and concatenating them would order the
report and the CLI by which rule fired rather than by which model the
reviewer is looking for.
"""
out = []
for model in sorted(groups):
Expand All @@ -73,4 +137,45 @@ def model_advisories(groups, results, shared_group=None):
if surfaces:
out.append((model, '{} changed but the generated C did not'
.format(' and '.join(surfaces))))
out.extend(_rte_regen_advisories(groups, results, shared_group))
# a model can never earn both (the +RTE rule needs its C to have changed,
# the surface rule needs it not to have), so sorting by name alone is stable
out.sort(key=lambda row: row[0])
return out


def _c_status(rels, results):
"""Statuses of a model's C-family files (``.c`` and ``.h``)."""
return [results[rel]['status'] for rel in rels if ruleset_for(rel) == 'c']


def _rte_regen_advisories(groups, results, shared_group):
"""``[(model, message)]`` for models that gained an RTE access point while a
peer model's C stayed byte-identical -- the tell of a single-model quick
regen. A ``+RTE`` cannot be integrated until the RTE layer and the peers are
regenerated too, so it is flagged even when the model's own files are all
self-consistent. Advisory only; see the module docstring.
"""
# a model whose C is entirely identical was not regenerated in this batch:
# a real regenerate rewrites at least a timestamp banner (ignorable-only),
# so identical -- not merely noisy -- is the exact proof it was skipped.
identical = set()
rte_added = set()
for model in groups:
if shared_group is not None and model == shared_group:
continue
cs = _c_status(groups[model], results)
if cs and all(s == 'identical' for s in cs):
identical.add(model)
for rel in groups[model]:
d = results[rel].get('rte')
if d and d.get('added'):
rte_added.add(model)
break
if not identical:
return [] # no skipped peer, so nothing here is evidence of a quick regen
# a model carrying a +RTE always has a changed .c (that is where the access
# point was found), so it can never be one of the identical peers itself
return [(model, 'gained an RTE access while a peer model stayed identical '
'-- regenerate the architecture before integrating')
for model in sorted(rte_added)]
86 changes: 86 additions & 0 deletions compare_tool/qtviewer/advisories.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""Consistency advisories under the quick-changes panel: the same cross-artifact
and cross-model heads-up the HTML report and the CLI print, shown live in the
viewer's bottom-left.

Display only, exactly like the report's block -- it names the model and the
caution, never folds a file, moves a count or changes the exit code. The text
comes from :func:`compare_tool.report.consistency_advisories`, so all three
surfaces say the same thing (see CLAUDE.md, "one seam per shared decision"); the
colours are theme roles, matching the report's ``if-chg`` marker, so a literal
here cannot make the viewer and the report disagree.
"""

from html import escape

from PySide6.QtCore import Qt
from PySide6.QtWidgets import QFrame, QLabel, QScrollArea, QVBoxLayout

from .. import theme


class AdvisoryPanel(QFrame):
"""Pinned strip at the very bottom of the left column. Hidden outright when
there is nothing to say, so a clean compare spends no height on it."""

def __init__(self):
super().__init__()
self.setObjectName('advisorypanel')
self._advisories = []

self._header = QLabel()
self._header.setObjectName('advisoryhead')

self._body = QLabel()
self._body.setWordWrap(True)
self._body.setAlignment(Qt.AlignTop)
# the messages carry file/model names a reviewer may want to copy into a
# ticket, and selection never triggers navigation
self._body.setTextInteractionFlags(Qt.TextSelectableByMouse)

# bounded height: a folder full of stale models must not eat the tree
# above it, so past a few rows the strip scrolls instead of growing
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setMaximumHeight(120)
scroll.setWidget(self._body)

lay = QVBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 8)
lay.setSpacing(4)
lay.addWidget(self._header)
lay.addWidget(scroll)
self.setVisible(False)

def set_advisories(self, advisories):
"""``advisories`` is the ``[(model, message)]`` list from
:func:`consistency_advisories`, computed from the RAW scan -- never the
folded view, so a category the reviewer collapsed cannot hide a desync."""
self._advisories = list(advisories)
if not self._advisories:
self.setVisible(False)
return
n = len(self._advisories)
self._header.setText('⚠ Consistency — {} heads-up{}'.format(
n, '' if n == 1 else 's'))
# apply_theme owns both the header style and the body render, so the
# first show is painted in the current theme without a separate init call
self.apply_theme()
self.setVisible(True)

def apply_theme(self):
"""Colours are stamped per label, so a theme switch has to repaint them
from the advisories the panel was last given."""
self._header.setStyleSheet(
'color:{}; font-weight:bold;'.format(theme.c('mv-fg')))
self._render()

def _render(self):
rows = ['<div style="margin:2px 0;">'
'<b style="color:{c}">&#9888; {m}</b> &mdash; '
'<span style="color:{d}">{msg}</span></div>'.format(
c=theme.c('mv-fg'), d=theme.c('fg-dim'),
m=escape(model), msg=escape(msg))
for model, msg in self._advisories]
self._body.setText(''.join(rows))
30 changes: 28 additions & 2 deletions compare_tool/qtviewer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
from .. import gitsource, review, theme, zipsource
from ..diff_engine import RULES
from ..main import default_report_name
from ..report import build_arxml_report, build_report
from ..report import build_arxml_report, build_report, consistency_advisories
from ..scanner import apply_fold, summarize
from .advisories import AdvisoryPanel
from .dialogs import show_about, show_release_notes, show_user_guide
from .diffpane import DiffPane
from .icons import ACCENT, app_icon, icon, std_icon
Expand Down Expand Up @@ -207,11 +208,22 @@ def __init__(self, old=None, new=None, exclude=(), arxml_only=False,
left.setStretchFactor(1, 1)
left.setSizes([560, 240])

# cross-artifact / cross-model heads-up, pinned below the rollup so it is
# the last thing in the left column -- the same list the report and the
# CLI print, read from the raw scan. Hidden until it has something to say.
self.advisories = AdvisoryPanel()
left_col = QWidget()
lc = QVBoxLayout(left_col)
lc.setContentsMargins(0, 0, 0, 0)
lc.setSpacing(0)
lc.addWidget(left, 1)
lc.addWidget(self.advisories)

self.diff = DiffPane()
self.diff.unitChanged.connect(self._on_unit_changed)

split = QSplitter(Qt.Horizontal)
split.addWidget(left)
split.addWidget(left_col)
split.addWidget(self.diff)
split.setStretchFactor(0, 0)
split.setStretchFactor(1, 1)
Expand Down Expand Up @@ -313,6 +325,7 @@ def _set_theme(self, name):
self._set_state(*self._state)
self._apply_icons()
self.summary.apply_theme()
self.advisories.apply_theme()
self.diff.apply_theme()
self._refresh_tree_keep_selection() # verdict colours are per item
self.diff.restore_reading_position(at)
Expand Down Expand Up @@ -920,6 +933,7 @@ def _start_scan(self):
self.banner.setVisible(False)
self.tree.clear()
self.summary.set_results({})
self.advisories.set_advisories(())
self.diff.clear()
self._raw_results = {}
self.results = {}
Expand Down Expand Up @@ -955,6 +969,9 @@ def _on_done(self, results):
# the rollup reports the scan itself, never the folded view: a hidden
# category must not make the model look untouched
self.summary.set_results(results)
# same rule for the advisories: read from the raw scan, so a collapsed
# category can never hide a desync heads-up
self.advisories.set_advisories(consistency_advisories(results))
self.progress.setRange(0, 1)
self.progress.setValue(1)
self.progress.setVisible(False)
Expand Down Expand Up @@ -1359,6 +1376,15 @@ def _on_select(self):
color:{chrome-checked-fg}; }}
QToolBar#main QToolButton:checked:hover {{ background:{chrome-checked-hover}; }}
QFrame#reviewbar {{ background:{chrome-bar-bg}; border-top:1px solid {border}; }}
/* consistency heads-up pinned at the bottom of the left column: a band of its
own, set off from the quick-changes rollup above it by a top border */
QFrame#advisorypanel {{ background:{chrome-bar-bg}; border-top:1px solid {border}; }}
/* the scroll area AND its viewport: the viewport is a child widget that fills
itself with the Base colour, which paints a lighter block under the header
instead of letting the band show through */
QFrame#advisorypanel QScrollArea,
QFrame#advisorypanel QScrollArea > QWidget > QWidget {{ background:transparent; }}
QLabel#advisoryhead {{ font-size:12px; }}
QFrame#reviewbar QPlainTextEdit {{ background:{code-bg}; border:1px solid {border};
border-radius:6px; padding:4px 6px; color:{fg}; }}
QFrame#reviewbar QPlainTextEdit:focus {{ border:1px solid {accent-2}; }}
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ 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)
├── consistency.py # cross-artifact/-model advisories: a model whose ARXML/A2L really changed but whose generated C did not follow, plus a +RTE while a peer model's C stayed identical (single-model quick regen) (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
Expand Down
Loading