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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project are documented here. Versions follow

## [Unreleased]

### Added

- **Skip variable renames with `--skip-var-renames`.** Hides the changes that
are only a variable name swap, so a regenerate full of renames shows you what
else moved. A quick check, not a review: a rewired signal has the same shape
and is hidden too, and the report says when the flag was used.

### Changed

- Simplify the report layout.

## [1.13.0] — 2026-09-06

### Added
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ Automatically identifies common code-generation churn:

Changes that cannot be safely explained remain visible as real changes.

One opt-in exception: `--skip-var-renames` folds variable renames it cannot
prove are noise, so a fresh regenerate can be swept for what is *not* a rename.
It trades away real changes that look the same, announces itself everywhere it
was used, and is never on by default.

See [What Counts as Noise](docs/usage.md#what-counts-as-noise).

---
Expand Down
146 changes: 146 additions & 0 deletions compare_tool/c_rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,152 @@ def autogen_noise_map(old_lines, new_lines, old_ids=None, new_ids=None):
return mapping


# --- assumed variable renames (--skip-var-renames quick check) ---
#
# Every other rule in this module proves its case before folding a difference
# away. This one does not, and says so: `a = b;` becoming `x = y;` is equally
# consistent with "two signals were renamed" and with "the block was rewired",
# and nothing in the file says which. It exists for the one job where that
# trade pays -- sweeping a regenerate for what is NOT a rename -- so it is
# opt-in, off by default, and everything it folds is labelled `assumed-rename`
# rather than `rename` on every surface.
#
# The shape is kept narrow: a whole hunk of bindings, paired 1-1, differing
# only by names, with the declared type unchanged. A binding names objects and
# copies one into another; it never computes. Anything with an operator, a
# call, a cast or a changed literal is not a binding and stays a real change.


def _path_len(toks):
"""Length of the plain lvalue path starting at ``toks[0]``, else 0.

A path is an identifier followed by any number of ``.field``, ``->field``
and ``[index]`` steps, the index itself a name or an integer. That is the
shape Embedded Coder writes its ports and state through -- ``rtU.Pedal``,
``rtDW->Filter_DSTATE``, ``buf[2]`` -- and it still only *names* one object:
no operator, no call, so nothing is computed on the way.
"""
if not toks or not is_identifier(toks[0]):
return 0
i = 1
while i < len(toks):
if toks[i] == '.' and i + 1 < len(toks) and is_identifier(toks[i + 1]):
i += 2
elif (toks[i] == '-' and i + 2 < len(toks) and toks[i + 1] == '>'
and is_identifier(toks[i + 2])):
i += 3 # '->' tokenizes as two single characters
elif (toks[i] == '[' and i + 2 < len(toks) and toks[i + 2] == ']'
and (is_identifier(toks[i + 1]) or toks[i + 1].isdigit())):
i += 3
else:
break
return i


# Keywords that may precede a declared name. Deliberately a whitelist rather
# than "any keyword": `return a;` is `return` followed by a name and would
# otherwise parse as a declaration of `a`, and so would `else x;`.
DECL_KEYWORDS = frozenset("""
auto char const double enum extern float inline int long register restrict
short signed static struct union unsigned void volatile _Bool _Complex
""".split())


def _is_decl_token(tok):
"""True for a token that may sit in front of a declared name: a type name,
a qualifier (``static``, ``const``, ``unsigned``) or a pointer star."""
return tok == '*' or is_identifier(tok) or tok in DECL_KEYWORDS


def binding_parts(line):
"""``(declaration_tokens, target_tokens, value_tokens)`` for a binding, or
``None`` for anything else.

A binding is one statement that names an object and, optionally, copies a
single named object or literal into it:

- ``a = b;`` / ``rtY.Out = rtU.Pedal;`` / ``buf[2] = rtDW->State;``
- ``real_T x;`` -- a bare declaration, which carries a name and a type and
no value at all
- ``boolean_T flag = FALSE;`` / ``sint32 n = -1;``

Not a binding: an expression, a call, a cast, two statements on a line, a
multi-declarator list, a comparison. Those carry meaning a name swap cannot
account for.
"""
toks = tokenize(line.strip())
if len(toks) < 2 or toks[-1] != ';':
return None
body = toks[:-1]
if body.count('=') > 1:
return None # '==' tokenizes as two '=', so a comparison is out
if '=' in body:
cut = body.index('=')
lhs, rhs = body[:cut], body[cut + 1:]
else:
lhs, rhs = body, []

if lhs and _path_len(lhs) == len(lhs):
decl, target = (), tuple(lhs) # a store into something declared already
elif lhs and is_identifier(lhs[-1]) and all(_is_decl_token(t) for t in lhs[:-1]):
# a declaration introduces a BARE name; `real_T a.b;` is not C, so a
# path target always means the object was declared somewhere else
decl, target = tuple(lhs[:-1]), (lhs[-1],)
else:
return None

if not rhs:
# no value: only a declaration can say nothing. A bare `a;` is a
# statement whose point is elsewhere (a macro, a volatile read).
return (decl, target, ()) if decl else None
v = rhs[1:] if rhs[0] in ('-', '+') else rhs
if not v:
return None
if _path_len(v) != len(v) and len(v) != 1:
return None # one named object, or one literal -- never an expression
return decl, target, tuple(rhs)


def _is_assumed_rename_pair(a, b):
"""A name swap the quick check is willing to take for a rename.

ALL_CAPS is refused on both sides: C reserves it for macros and enum
constants, so ``mode = IDLE;`` becoming ``mode = DRIVE;`` is a value change
wearing the shape of a rename, and ``flag = FALSE;`` -> ``flag = TRUE;`` is
the same thing on a declaration. A changed numeric literal never reaches
here at all -- it is not an identifier, so the line pair is rejected.
"""
if a == b or not (is_identifier(a) and is_identifier(b)):
return False
return not (ALL_CAPS_RE.match(a) or ALL_CAPS_RE.match(b))


def assumed_rename_map(old_lines, new_lines):
"""Name map for a hunk that is nothing but bindings differing by names, or
None.

Unlike :func:`autogen_noise_map` the names do not have to look generated --
that is what makes this the unproven quick check. It still refuses a hunk
whose sides do not pair 1-1, one that holds a line which is not a binding,
one whose declared type or literal changed, and one whose map contains a
cycle (an ``a`` <-> ``b`` swap is a real change, never a rename).
"""
if not old_lines or len(old_lines) != len(new_lines):
return None
mapping = {}
for la, lb in zip(old_lines, new_lines):
pa, pb = binding_parts(la), binding_parts(lb)
if pa is None or pb is None:
return None
if pa[0] != pb[0]:
return None # the declared TYPE changed -- sint32 -> uint8 truncates
if not _collect_line_pair_renames(la, lb, mapping, _is_assumed_rename_pair):
return None
if not mapping or _map_has_cycle(mapping):
return None
return mapping


# --- straight-line reorder (Embedded Coder reschedules independent stmts) ---
#
# Regenerating a model routinely emits the same independent assignments in a
Expand Down
47 changes: 43 additions & 4 deletions compare_tool/diff_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
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, reorder, uuid, timestamp, sw-version,
description, whitespace, mixed}
kind in {real, moved, comment, rename, assumed-rename, reorder, uuid,
timestamp, sw-version, description, whitespace, mixed}

'assumed-rename' only ever appears when the caller asked for it
(``skip_var_renames``): it is the one label this engine applies WITHOUT
proving the difference is noise. See c_rules.assumed_rename_map.

Moved blocks: a pure-delete hunk whose non-blank shadow content reappears
verbatim as exactly one pure-insert hunk (and vice versa) is labeled 'moved'
Expand Down Expand Up @@ -235,6 +239,16 @@ def _is_autogen_hunk(h, old_sh_lines, new_sh_lines, old_ids, new_ids):
return c_rules.autogen_noise_map(a, b, old_ids, new_ids) is not None


def _is_assumed_rename_hunk(h, old_sh_lines, new_sh_lines):
"""True when a shadow hunk is only bindings whose lines differ by variable
names -- the ``--skip-var-renames`` quick check. Unproven by
design (see :func:`compare_tool.c_rules.assumed_rename_map`); reached only
when the caller opted in."""
i1, i2, j1, j2 = h
return c_rules.assumed_rename_map(_nonblank(old_sh_lines[i1:i2]),
_nonblank(new_sh_lines[j1:j2])) is not None


def _slices_equal(h, old_variant_lines, new_variant_lines):
"""Compare a hunk's line slices under some normalization variant,
ignoring blank lines (handles pure insert/delete of comment lines)."""
Expand Down Expand Up @@ -305,15 +319,22 @@ def _build_variants(old_text, new_text, ruleset, rename_map, ext='', user_rules=
return variants


def compare_pair(old_text, new_text, path, user_rules=()):
def compare_pair(old_text, new_text, path, user_rules=(),
skip_var_renames=False):
"""Compare two file contents. Returns dict:
{status, hunks, renames, notes}
status in {identical, comment-only, ignorable-only, real-change}

``user_rules`` are extra noise patterns from a ``--rules`` file
(:mod:`compare_tool.userrules`). They run ON TOP of the built-in Embedded
Coder rules -- an empty tuple, the default, leaves every existing verdict
exactly as it was."""
exactly as it was.

``skip_var_renames`` is the opt-in quick check (``--skip-var-renames``): a
C/C++ hunk that is nothing but bindings differing by variable names is
labelled ``assumed-rename`` instead of ``real``, WITHOUT proof that the
swap preserves behaviour. It can therefore hide a rewiring, which is why it
is off by default and why every hunk it folds keeps a label of its own."""
result = {'status': 'identical', 'hunks': [], 'renames': {}, 'notes': []}
if old_text == new_text:
return result
Expand Down Expand Up @@ -403,6 +424,21 @@ def compare_pair(old_text, new_text, path, user_rules=()):
kept.append(h)
candidates = kept

# Opt-in quick check (--skip-var-renames): hunks that are only bindings
# differing by variable names. NOT proven noise -- a rewiring has
# the same shape -- so it runs last among the name rules, after the proven
# ones have taken what they can explain, and what it takes keeps its own
# 'assumed-rename' label everywhere it is shown or counted.
assumed_rename_hunks = []
if skip_var_renames and ruleset in ('c', 'cpp') and candidates:
kept = []
for h in candidates:
if _is_assumed_rename_hunk(h, final_old_shadow_lines, new_shadow_lines):
assumed_rename_hunks.append(h)
else:
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
Expand Down Expand Up @@ -454,6 +490,9 @@ def compare_pair(old_text, new_text, path, user_rules=()):
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 and assumed_rename_hunks
and _overlaps(h, assumed_rename_hunks)):
kind = 'assumed-rename' # --skip-var-renames: assumed, not proven
if kind is None:
kind = 'mixed' # ignorable but caused by >1 rule combined
for name, ov, nv in variants:
Expand Down
38 changes: 34 additions & 4 deletions compare_tool/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,8 @@ def default_report_name(arxml_only):

def run_compare(old_root, new_root, out, arxml_only=False, exclude=(),
progress=None, reviews=None, theme_name=theme.DEFAULT,
old_label=None, new_label=None, max_diff_lines=0, user_rules=()):
old_label=None, new_label=None, max_diff_lines=0, user_rules=(),
skip_var_renames=False):
"""Scan two trees and write the HTML report.

``old_label`` / ``new_label`` name the two sides in the report header when
Expand All @@ -78,7 +79,8 @@ def run_compare(old_root, new_root, out, arxml_only=False, exclude=(),
include = tuple('*' + ext for ext, rs in RULES.items()
if rs in ('arxml', 'a2l')) if arxml_only else ()
results = scan(old_root, new_root, progress=progress, exclude=exclude,
include=include, user_rules=user_rules)
include=include, user_rules=user_rules,
skip_var_renames=skip_var_renames)
counts = summarize(results)
if arxml_only:
# ALWAYS written: "no changes" must be an explicit statement, never
Expand All @@ -104,6 +106,14 @@ def summary_lines(results, counts):
lines.append('Summary: {real-change} modified, {comment-only} comment-only, '
'{ignorable-only} unimportant, {added} added, {deleted} deleted, '
'{identical} identical, {error} error(s)'.format(**counts))
n_assumed = sum(1 for r in results.values()
if any(h['kind'] == 'assumed-rename' for h in r['hunks']))
if n_assumed:
lines.append('!! QUICK CHECK (--skip-var-renames): variable renames were '
'SKIPPED in {} file(s) without proof that they are noise. A '
'rewiring has the same shape, so a real change can be '
'missing from the counts above -- re-run without the flag '
'before signing anything off.'.format(n_assumed))
if counts['error']:
lines.append('!! COMPARE INCOMPLETE: {} path(s) could NOT be compared -- '
'treat them as potentially changed:'.format(counts['error']))
Expand Down Expand Up @@ -247,6 +257,21 @@ def _parser():
'file\'s line count, is skipped with a warning -- a '
'filter can never hide a real change. Applies to both '
'the report and the viewer')
ap.add_argument('--skip-var-renames', action='store_true',
help='QUICK CHECK, UNSAFE: fold away any C/C++ hunk that is '
'only bindings differing by variable names -- an '
'assignment or a declaration that names one object and '
'at most copies one into it (a = b, rtY.Out = rtU.In, '
'real_T x, boolean_T a = FALSE) -- WITHOUT proving '
'the swap is noise. Built for one job -- sweeping a '
'regenerate for what is NOT a rename -- and it pays '
'for that with false negatives: a rewiring '
'(a = speed becoming a = torque) has exactly the same '
'shape and is folded too. Changed literals, ALL_CAPS '
'macro/enum constants and variable swaps still count '
'as real. Everything folded is labelled Assumed rename '
'and the report and the terminal both carry a warning; '
'do not sign a review off on a run that used this')
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, '
Expand Down Expand Up @@ -362,7 +387,8 @@ def _run(ap, args, zip_temp):
try:
return run_viewer(old_dir, new_dir, exclude=args.exclude,
arxml_only=args.arxml_only, theme_name=args.theme,
user_rules=user_rules)
user_rules=user_rules,
skip_var_renames=args.skip_var_renames)
except ImportError as e:
# a stdlib-only install (the .pyz, a locked-down box) has no Qt.
# Say so plainly instead of dumping a traceback.
Expand Down Expand Up @@ -396,6 +422,9 @@ def _run(ap, args, zip_temp):
if args.arxml_only:
print('note: --review has no effect with --arxml-only (that report '
'lists files, not individual changes)', file=sys.stderr)
if args.skip_var_renames and args.arxml_only:
print('note: --skip-var-renames has no effect with --arxml-only (it '
'only ever folds C/C++ bindings)', file=sys.stderr)

out = Path(args.report)
print('Scanning...')
Expand All @@ -411,7 +440,8 @@ def progress(done, total, rel):
old_label=args.baseline_name or old_zip,
new_label=args.current_name or new_zip,
max_diff_lines=args.max_diff_lines,
user_rules=user_rules)
user_rules=user_rules,
skip_var_renames=args.skip_var_renames)
except ReportWriteError as e:
# what WAS scanned still goes to the terminal -- the compare itself may
# have been fine, it is only the record that is missing
Expand Down
Loading