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
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.9.0"
__version__ = "1.10.0"
125 changes: 125 additions & 0 deletions compare_tool/c_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"""

import re
from collections import Counter
from difflib import SequenceMatcher

from . import linediff
Expand Down Expand Up @@ -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
Expand Down
76 changes: 76 additions & 0 deletions compare_tool/consistency.py
Original file line number Diff line number Diff line change
@@ -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
16 changes: 15 additions & 1 deletion compare_tool/diff_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 0 additions & 10 deletions compare_tool/gitsource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading