From 2cc9fb20a53f195852a5f7ee24c6d22b92f68ad4 Mon Sep 17 00:00:00 2001 From: longvo920 Date: Fri, 11 Sep 2026 22:10:36 +0700 Subject: [PATCH] feat: add --skip-var-renames quick check Fold C/C++ hunks that are only bindings differing by variable names, labelled `assumed-rename` instead of `real`. Unlike every other rule in the engine this one does not prove the difference is noise -- a rewiring has the same shape -- so it is opt-in, off by default, and announced in the terminal summary, the report, the JSON and the viewer title. A binding is one statement that names an object and at most copies a single other object or literal into it; either side may be a plain lvalue path (a.b, p->q, buf[2]), and a bare declaration counts. Still real: a changed literal or type, an ALL_CAPS macro/enum swap, a variable swap, and any hunk holding a line that is not a binding. Also drop the extra hint from report file headers: the header answers "must I read this file", and the verdict badge is that answer. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 ++ README.md | 5 + compare_tool/c_rules.py | 146 ++++++++++++++++ compare_tool/diff_engine.py | 47 +++++- compare_tool/main.py | 38 ++++- compare_tool/qtviewer/app.py | 19 ++- compare_tool/qtviewer/dialogs.py | 5 + compare_tool/qtviewer/worker.py | 7 +- compare_tool/report.py | 57 +++---- compare_tool/scanner.py | 23 ++- compare_tool/serialize.py | 7 + docs/architecture.md | 22 ++- docs/usage.md | 40 +++++ docs/vi/README.md | 5 + docs/vi/architecture.md | 14 +- docs/vi/usage.md | 58 +++++++ tests/test_report.py | 15 +- tests/test_skip_var_renames.py | 282 +++++++++++++++++++++++++++++++ 18 files changed, 734 insertions(+), 67 deletions(-) create mode 100644 tests/test_skip_var_renames.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 647fd01..0855828 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 61c54b8..8cdcd6e 100644 --- a/README.md +++ b/README.md @@ -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). --- diff --git a/compare_tool/c_rules.py b/compare_tool/c_rules.py index eed337a..e1abf42 100644 --- a/compare_tool/c_rules.py +++ b/compare_tool/c_rules.py @@ -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 diff --git a/compare_tool/diff_engine.py b/compare_tool/diff_engine.py index 749c672..2d88c4e 100644 --- a/compare_tool/diff_engine.py +++ b/compare_tool/diff_engine.py @@ -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' @@ -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).""" @@ -305,7 +319,8 @@ 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} @@ -313,7 +328,13 @@ def compare_pair(old_text, new_text, path, user_rules=()): ``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 @@ -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 @@ -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: diff --git a/compare_tool/main.py b/compare_tool/main.py index 3877e09..e9da0d9 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -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 @@ -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 @@ -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'])) @@ -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, ' @@ -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. @@ -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...') @@ -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 diff --git a/compare_tool/qtviewer/app.py b/compare_tool/qtviewer/app.py index fffa03f..bb044f3 100644 --- a/compare_tool/qtviewer/app.py +++ b/compare_tool/qtviewer/app.py @@ -93,7 +93,7 @@ def focusOutEvent(self, event): class MainWindow(QMainWindow): def __init__(self, old=None, new=None, exclude=(), arxml_only=False, - theme_name=theme.DEFAULT, user_rules=()): + theme_name=theme.DEFAULT, user_rules=(), skip_var_renames=False): super().__init__() self._theme = theme.set_current(theme_name) self._state = ('idle', 'Ready') @@ -105,6 +105,10 @@ def __init__(self, old=None, new=None, exclude=(), arxml_only=False, # extra --rules noise patterns; the viewer and the CLI share the engine, # so both must apply them or the same folders would read two ways self.user_rules = tuple(user_rules) + # --skip-var-renames: the unproven quick check. Baked into the scan + # like --rules is, because it decides verdicts -- it is not a view + # toggle the reviewer can flip back on over the same results. + self.skip_var_renames = bool(skip_var_renames) self._raw_results = {} # verdicts straight from the scan self.results = {} # ... after the current compare rules self.worker = None @@ -981,14 +985,18 @@ def _start_scan(self): # enough for a pair of them, and both roots are already named over the # diff panes -- with the full path in their tooltip name = Path(self.new).name or str(self.new) - self.setWindowTitle('AUTOSAR CodeGen Compare — {}'.format(name)) + # the quick check rides in the title because it has no undo inside this + # window: it changed what the scan judged, so the reviewer has to see + # which mode produced the tree they are reading + quick = ' — QUICK CHECK: variable renames skipped' if self.skip_var_renames else '' + self.setWindowTitle('AUTOSAR CodeGen Compare — {}{}'.format(name, quick)) self._load_reviews() self.counts_label.setText('') self._set_state('busy', 'Scanning…') # the scan itself is rule-free; the rules are applied to its results, # so flipping a category never costs a second walk of the disk self.worker = ScanWorker(self.old, self.new, self.exclude, self.include, - self.user_rules) + self.user_rules, self.skip_var_renames) self.worker.progressed.connect(self._on_progress) self.worker.done.connect(self._on_done) self.worker.failed.connect(self._on_fail) @@ -1636,7 +1644,7 @@ def _taskbar_identity(): def run_viewer(old=None, new=None, exclude=(), arxml_only=False, - theme_name=theme.DEFAULT, user_rules=()): + theme_name=theme.DEFAULT, user_rules=(), skip_var_renames=False): app = QApplication.instance() owns = app is None if owns: @@ -1646,7 +1654,8 @@ def run_viewer(old=None, new=None, exclude=(), arxml_only=False, apply_theme(app) app.setApplicationName('CodeGen Compare') app.setWindowIcon(app_icon()) - win = MainWindow(old, new, exclude, arxml_only, theme_name, user_rules) + win = MainWindow(old, new, exclude, arxml_only, theme_name, user_rules, + skip_var_renames) win.show() return app.exec() if owns else 0 diff --git a/compare_tool/qtviewer/dialogs.py b/compare_tool/qtviewer/dialogs.py index c202ed7..a7893ac 100644 --- a/compare_tool/qtviewer/dialogs.py +++ b/compare_tool/qtviewer/dialogs.py @@ -74,6 +74,11 @@ the tree, and still appears as Comment in an exported report - Tick back on to bring the colour back - Real changes can never be quietened +- If the title bar says **QUICK CHECK: variable renames skipped**, this window + was started with `--skip-var-renames`: that scan folded variable renames it + could not prove were noise, so a rewired signal can be sitting under + Unimportant. It is a sweep, not a review -- re-run without the flag before + signing anything off ## 5. Read the diff diff --git a/compare_tool/qtviewer/worker.py b/compare_tool/qtviewer/worker.py index d7a5d14..6bb696b 100644 --- a/compare_tool/qtviewer/worker.py +++ b/compare_tool/qtviewer/worker.py @@ -13,13 +13,15 @@ class ScanWorker(QThread): done = Signal(dict) # results failed = Signal(str) # loud failure -> red banner - def __init__(self, old, new, exclude=(), include=(), user_rules=()): + def __init__(self, old, new, exclude=(), include=(), user_rules=(), + skip_var_renames=False): super().__init__() self.old = old self.new = new self.exclude = tuple(exclude) self.include = tuple(include) self.user_rules = tuple(user_rules) + self.skip_var_renames = bool(skip_var_renames) def run(self): try: @@ -29,7 +31,8 @@ def run(self): # like the built-in rules do, so they are baked into the scan here. results = scan(self.old, self.new, progress=self._progress, exclude=self.exclude, include=self.include, - user_rules=self.user_rules) + user_rules=self.user_rules, + skip_var_renames=self.skip_var_renames) self.done.emit(results) except Exception as e: # scan is internally fail-safe, but a crash here must still be diff --git a/compare_tool/report.py b/compare_tool/report.py index 7719423..2536500 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -52,6 +52,7 @@ .errbox div { padding: 1px 0; } .errbox code { background: var(--err-code-bg); color: var(--err-fg); } .hint { color: var(--fg-faint); font-size: 11px; margin: -14px 0 18px; } +.qcnote { color: var(--fg-faint); font-size: 11px; margin: 26px 0 6px; } body.hide-real .sec-real, body.hide-ign .sec-ign, body.hide-add .sec-add, body.hide-del .sec-del { display: none; } ul.files { margin: 4px 0 14px; padding-left: 22px; font-size: 13px; } @@ -1152,26 +1153,14 @@ def _content_table(lines, cls, language=None): # Anything not listed here still shows, with its first letter raised: a kind # added to a rules module may look plain, it may never go missing. _KIND_LABEL = {'uuid': 'UUID', 'sw-version': 'SW version', - 'line-endings': 'Line endings'} + 'line-endings': 'Line endings', + 'assumed-rename': 'Assumed rename'} def _kind_label(kind): return _KIND_LABEL.get(kind, kind[:1].upper() + kind[1:]) -def _kinds_of(r): - """Short ignorable-kind summary for a file, e.g. 'Comment, Rename ×3'.""" - kinds = {h['kind'] for h in r['hunks'] if h['kind'] != 'real'} | set(r['notes']) - labels = set() - # only the counted spelling replaces the plain one -- a rename hunk with no - # pair recorded still has to say 'Rename' - if r['renames']: - kinds.discard('rename') - labels.add('Rename ×{}'.format(len(r['renames']))) - labels |= {_kind_label(k) for k in kinds} - return ', '.join(sorted(labels)) - - def _autosar_section(results, anchors): """Top-of-report rollup of every AUTOSAR-level change across all files: port-interfaces, software components, ports, runnables, events, RTE @@ -1296,20 +1285,6 @@ def _notes(r): return _iface_note(r) + _swc_note(r) + _rte_note(r) + _a2l_note(r) -def _affected_extra(names, limit=3): - """The functions/blocks a file's real changes land in, for its header -- - the seed of an Impact Analysis "Affected Module" column. Capped so a - heavily churned file does not spill its whole symbol table into the - summary line; the diff below still names every one.""" - if not names: - return '' - shown = [_esc(n) for n in names[:limit]] - text = 'Affected: ' + ', '.join(shown) - if len(names) > limit: - text += ' (+{})'.format(len(names) - limit) - return text - - def _file_open(anchor, rel, status, extra='', expanded=False, reviewed=False): label, tag = _LABEL[status] sec = _TREE[status][2] @@ -1342,6 +1317,22 @@ def _error_banner(results): 'does not cover them.'.format(len(errs), ''.join(lines))) +def _quick_check_note(results): + """One line recording that this run ran with ``--skip-var-renames``. + + Sits between the folder tree and the detailed changes: the mode is the + reader's own choice, not a failure, so it reads as a caption rather than a + banner -- but it is never absent, and it is the last thing read before the + diffs, because what it folded is not provably noise and never appears in + them. Empty string when the mode folded nothing.""" + n = sum(1 for r in results.values() + if any(h['kind'] == 'assumed-rename' for h in r.get('hunks', []))) + if not n: + return '' + return ('
⚠ Variable renames ignored in {} file(s) ' + '(--skip-var-renames).
'.format(n)) + + def _file_section(rel, results, old_root, new_root, anchors, rv, max_rows=0): """One collapsible detail section for a non-identical file. @@ -1369,12 +1360,13 @@ def _file_section(rel, results, old_root, new_root, anchors, rv, max_rows=0): notes = rv.annotate(rel, r, old_lines, new_lines) lang = syntax.language_for(rel) labels = None - extra = '' if not r['binary']: labels = (funcname.enclosing(old_lines, lang), funcname.enclosing(new_lines, lang)) - extra = _affected_extra(funcname.affected(labels[0], labels[1], hunks)) - parts.append(_file_open(anchors[rel], rel, 'real-change', extra, + # only the verdict badge: the header answers "must I read this file", + # and which symbols / which noise kinds are in it is what the diff + # below is for + parts.append(_file_open(anchors[rel], rel, 'real-change', expanded=True, reviewed=rel in rv.files)) parts.append(_notes(r)) if r['binary']: @@ -1386,7 +1378,7 @@ def _file_section(rel, results, old_root, new_root, anchors, rv, max_rows=0): parts.append(_groups_html(old_lines, new_lines, hunks, notes, lang, labels, max_rows)) elif status in ('comment-only', 'ignorable-only'): - parts.append(_file_open(anchors[rel], rel, status, _esc(_kinds_of(r)))) + parts.append(_file_open(anchors[rel], rel, status)) if not r['hunks']: parts.append('
Line endings / BOM only; ' 'no content difference.
') @@ -1702,6 +1694,7 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None, parts.append('

No real changes. All differences are ignorable ' '(comments / renames / UUIDs / timestamps / whitespace).

') + parts.append(_quick_check_note(results)) if detail_files: parts.append('

Detailed changes

') parts.append('
' diff --git a/compare_tool/scanner.py b/compare_tool/scanner.py index feaadca..c67a869 100644 --- a/compare_tool/scanner.py +++ b/compare_tool/scanner.py @@ -107,7 +107,8 @@ def on_error(err): return out -def compare_file(old_root, new_root, rel, user_rules=()): +def compare_file(old_root, new_root, rel, user_rules=(), + skip_var_renames=False): """Full comparison result for one relative path present in both trees.""" old_p = Path(old_root) / rel new_p = Path(new_root) / rel @@ -121,7 +122,7 @@ def compare_file(old_root, new_root, rel, user_rules=()): # bytes differed but normalized text equal: EOL style or BOM only return {'status': 'ignorable-only', 'hunks': [], 'renames': {}, 'notes': ['line-endings'], 'binary': False} - result = compare_pair(old_text, new_text, rel, user_rules) + result = compare_pair(old_text, new_text, rel, user_rules, skip_var_renames) result['binary'] = False # semantic summaries: only real changes can move the AUTOSAR surface # (ignorable-only means the shadows are equal, hence same content) @@ -242,7 +243,8 @@ def _candidate(root, rel): return filepair.Candidate(rel, ext, digest, lines) -def _link_moves(results, old_root, new_root, user_rules=()): +def _link_moves(results, old_root, new_root, user_rules=(), + skip_var_renames=False): """Cross-reference added files with the deleted ones they came from. The two entries KEEP their `added` / `deleted` verdicts and their place in @@ -259,7 +261,8 @@ def _link_moves(results, old_root, new_root, user_rules=()): for a_rel, (d_rel, sim) in filepair.find_moves(added, deleted).items(): try: pair = compare_pair(read_text(Path(old_root) / d_rel), - read_text(Path(new_root) / a_rel), a_rel, user_rules) + read_text(Path(new_root) / a_rel), a_rel, user_rules, + skip_var_renames) except (OSError, UnicodeError): continue results[a_rel]['moved_from'] = d_rel @@ -273,7 +276,7 @@ def _link_moves(results, old_root, new_root, user_rules=()): def scan(old_root, new_root, progress=None, exclude=(), include=(), fold=(), - user_rules=()): + user_rules=(), skip_var_renames=False): """Compare two trees. Returns {rel_path: result} sorted by path. result: {status, hunks, renames, notes, binary[, ifaces]}. status 'error' = the path could not be listed or compared (see notes). @@ -283,7 +286,10 @@ def scan(old_root, new_root, progress=None, exclude=(), include=(), fold=(), fold: noise statuses that should not be reported separately -- those files come back as 'identical' (see fold_status). user_rules: extra noise patterns from a --rules file, applied on top of the - built-in ones (see compare_tool.userrules).""" + built-in ones (see compare_tool.userrules). + skip_var_renames: the opt-in --skip-var-renames quick check -- C/C++ hunks + that are only bindings differing by variable names become 'assumed-rename' + instead of real, WITHOUT proof (see compare_tool.diff_engine).""" fold = tuple(fold) old_errors, new_errors = [], [] old_files = list_files(old_root, old_errors) @@ -311,7 +317,8 @@ def under_failed(rel, errs): for idx, rel in enumerate(all_paths): try: if rel in old_files and rel in new_files: - results[rel] = compare_file(old_root, new_root, rel, user_rules) + results[rel] = compare_file(old_root, new_root, rel, user_rules, + skip_var_renames) elif rel in new_files: if under_failed(rel, old_errors): results[rel] = _error_result( @@ -341,7 +348,7 @@ def under_failed(rel, errs): # after every verdict is settled: folding cannot reach 'added'/'deleted', # so the candidate set is the same either way, and pairing must never be # what decides a verdict - _link_moves(results, old_root, new_root, user_rules) + _link_moves(results, old_root, new_root, user_rules, skip_var_renames) return results diff --git a/compare_tool/serialize.py b/compare_tool/serialize.py index f756938..723b68a 100644 --- a/compare_tool/serialize.py +++ b/compare_tool/serialize.py @@ -98,6 +98,13 @@ def build(results, counts, old_root, new_root, exit_code, 'files': [_file_entry(rel, results[rel]) for rel in sorted(results)], 'consistency': [{'model': m, 'message': msg} for m, msg in advisories], } + # a run made with --skip-var-renames folded differences it could not prove + # were noise, so its summary and exit code are weaker than they look. A + # consumer gating on this file has to be able to see that without walking + # every hunk. + if any(h['kind'] == 'assumed-rename' + for r in results.values() for h in (r.get('hunks') or [])): + doc['quick_check'] = 'skip-var-renames' if old_label: doc['baseline_label'] = old_label if new_label: diff --git a/docs/architecture.md b/docs/architecture.md index 2256e73..02192f2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -114,6 +114,11 @@ decides what you see, and three peeling steps (rename, autogen-name, reorder) each remove only what they can justify. ARXML and A2L take the same two passes; only the *shadow* — what each strips — differs. +There is one opt-in exception to "prove it is noise", drawn dashed below: +`--skip-var-renames` peels hunks that are only variable-name swaps in bindings, +without a proof that the swap preserves behaviour. It is off unless asked for, +and what it peels is labelled `assumed-rename`, never `rename`. + ```mermaid flowchart TD START["scanner pairs a file by path"]:::io --> DISP{"present on…"} @@ -130,6 +135,8 @@ flowchart TD SHA --> P2["PASS 2 · diff the shadows
patience matcher (linediff.hunks)
→ candidate real hunks"]:::pass P2 --> F1["peel · 1-to-1 rename map
verified by re-diff"]:::filter F1 --> F2["peel · autogen-name noise
rtb_ · _DSTATE · tmp_N swaps"]:::filter + F2 -. "--skip-var-renames only" .-> FQ["peel · assumed rename
bindings differing by name
NOT proven"]:::filter + FQ -.-> F3 F2 --> F3["peel · safe reorder
dependence-preserving permutation"]:::filter F3 --> REM{"candidate hunks
still left?"} @@ -276,8 +283,19 @@ 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`, `reorder`, `uuid`, -`timestamp`, `sw-version`, `description`, `whitespace`, `mixed`. +`kind` is one of `real`, `moved`, `comment`, `rename`, `assumed-rename`, +`reorder`, `uuid`, `timestamp`, `sw-version`, `description`, `whitespace`, +`mixed`. + +`assumed-rename` is the only kind applied **without** proof, and it appears +only when the caller passed `skip_var_renames` (`--skip-var-renames`). It folds +a hunk whose every line is a binding — a statement that names one object and +at most copies another into it (`a = b;`, `rtY.Out = rtU.Pedal;`, `real_T x;`, +`boolean_T f = FALSE;`) — differing only by identifier names, which a rewiring +also looks like. It is +therefore a deliberate false-negative mode: it keeps its own kind so no surface +can spell it `rename`, and the report, the terminal summary, the JSON and the +viewer title each say the run used it. `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 diff --git a/docs/usage.md b/docs/usage.md index 3bfb489..2384f2b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -34,6 +34,7 @@ Either side can be a `.zip` instead of a folder — an Azure DevOps build artifa | `--current-name NAME` | Same for the CURRENT side. Either flag only changes the header text — the folder path stays in the tooltip, so a compare is still traceable to where the files were read from | | `--theme dark\|light` | Colour scheme the report and the viewer open with (default `dark`). The report carries **both** and has its own switch, so this only sets what the reader sees first | | `--rules RULES.json` | Extra noise patterns applied on top of the built-in rules — see [Custom noise rules](#custom-noise-rules) | +| `--skip-var-renames` | **Quick check, unsafe.** Fold away C/C++ hunks that are only variable renames, without proving they are noise — see [Quick check: skipping variable renames](#quick-check-skipping-variable-renames) | | `--max-diff-lines N` | Cap the diff embedded per file at about N lines (0 = no cap, the default). Stops a whole-tree regenerate from rendering one file into a report so large the browser hangs; a capped file keeps its verdict and the exit code, and the cut is loud | | `--qt`, `--viewer` | Open the side-by-side viewer on folders named on the command line, instead of comparing them in the terminal. Needs the `viewer` extra | | `--version` | Print the version and exit | @@ -128,6 +129,7 @@ Turning on `Review mode` adds a note box and a `Review` column to the tree — g | `timestamp` | `` blocks, `` | .arxml .xml | | `sw-version` | `` version stamps (bumped on every regenerate). Anchored, so `` and the like are untouched | .arxml .xml | | `description` | ``, ``, `` — the prose an Identifiable carries (schema 4.2 and 4.4 alike). `` and `` are **not** included: the first is semantic, the second can carry tool payload | .arxml .xml | +| `assumed-rename` | Assignments and declarations differing only by variable names, folded **without proof** — only with `--skip-var-renames`, never by default | .c .h .cpp .hpp | | `whitespace` | Indentation, trailing spaces, blank lines | all | | `line-endings` | CRLF vs LF, BOM | all | @@ -149,6 +151,44 @@ Regenerating a model routinely emits the same independent assignments — output Two straight-line schedules that agree on the order of every dependent pair are guaranteed to compute the same result, so folding the reorder is behaviour-preserving, not a guess. If any of those three conditions fails — a call sneaks in between the lines, a right-hand side actually changed, a dependent pair got flipped — the whole block stays a real change. The rule errs toward calling a block real rather than toward hiding one; when in doubt, it shows you the diff. +### Quick check: skipping variable renames + +Every rule above proves its case before folding anything away. `--skip-var-renames` does not, and it is the only part of the tool that works this way — so it is off by default and has to be asked for by name. + +It is for one job: sweeping a fresh regenerate for what is **not** a rename, when you already expect a wave of renaming and want to see the rest. It is not for signing a review off. + +With the flag on, a C/C++ hunk is folded as `assumed-rename` when every line on both sides is a **binding** — one statement that names an object and at most copies a single other object or literal into it — and the two sides pair up line for line, differing only by identifier names, with the declared type unchanged. + +A binding never *computes*. Either side of the `=` may be a plain lvalue path — a name, a `.field`, a `->field`, a `[index]` — which is how Embedded Coder reaches its ports, DWork state and buffers: + +```c +a = b; → x = b; +acc_cmd = rtU.Pedal; → drv_cmd = rtU.Pedal; +rtY.Out = filt_in; → rtY.Out = filt_val; +buf[2] = rtDW->State; → buf[2] = rtDW->Level; +real_T filt_in; → real_T filt_val; +boolean_T flag = FALSE; → boolean_T other = FALSE; +``` + +**What it costs you.** A rewiring has exactly the same shape as a rename, so it is folded too. `a = speed;` becoming `a = torque;` is a genuine signal change, and this mode will not report it. That is the trade the flag exists to make; a run that used it can be missing a real change. + +**What still counts as real, even here:** + +- a changed literal — `a = 0;` → `x = 1;`; +- a changed type — `sint32 a = 0;` → `uint8 x = 0;`; +- an ALL_CAPS macro or enum constant on either side — `flag = FALSE;` → `flag = TRUE;`, `mode = IDLE;` → `mode = DRIVE;`. C reserves all-caps for constants declared elsewhere, so swapping one is a value change, not a rename; +- a variable swap — `a`↔`b` — which is a real change however consistent it looks; +- any hunk holding a line that is not a binding: an expression (`a = b + c;`), a call, a cast, an address-of or a dereference, a prototype, a multi-declarator list. One such line leaves the **whole** hunk real, so a rename sitting next to a real edit is still shown. + +**How it announces itself.** Folding without proof is only acceptable if it is impossible to miss: + +- the terminal summary prints a `QUICK CHECK` warning above the counts; +- the HTML report carries a one-line note at its foot, and a revealed hunk's placeholder row names the kind (`assumed-rename`) rather than calling it a proven rename; +- the JSON output carries `"quick_check": "skip-var-renames"`; +- the viewer's title bar says `QUICK CHECK: variable renames skipped`. + +The flag only ever touches C/C++ bindings, so it does nothing at all with `--arxml-only`. + ### Comment is its own category A file whose only differences are comments gets reported as **Comment**, kept separate from **Unimportant** (which covers UUIDs, timestamps, SW-VERSION, descriptions, renames and whitespace) — because "someone rewrote the comment banner" and "an identifier got renamed" are different enough findings that they shouldn't share a bucket. Each gets its own count in the CLI summary and its own tree marker in the viewer. A file that mixes comment changes *with* other noise stays classified as Unimportant, since the narrower Comment label wouldn't be accurate for it. The viewer has a separate rule toggle for each, and the HTML report gives `Unimportant` its own badge while never rendering comment lines at all. diff --git a/docs/vi/README.md b/docs/vi/README.md index ec00a85..da58969 100644 --- a/docs/vi/README.md +++ b/docs/vi/README.md @@ -122,6 +122,11 @@ Tự động nhận diện các thay đổi phổ biến do code generator tạo Những thay đổi không thể giải thích một cách an toàn vẫn được giữ lại như **real changes**. +Có đúng một ngoại lệ phải tự bật: `--skip-var-renames` gộp các chỗ đổi tên biến +mà tool không chứng minh được là noise, để quét nhanh một bản regenerate xem có +gì *không phải* đổi tên. Đổi lại, nó bỏ sót những thay đổi thật có cùng hình +dạng; mọi bề mặt đều báo rõ lần chạy có bật cờ, và mặc định cờ luôn tắt. + Xem [What Counts as Noise](usage.md#cái-gì-bị-tính-là-noise). --- diff --git a/docs/vi/architecture.md b/docs/vi/architecture.md index 92af0cb..5255f51 100644 --- a/docs/vi/architecture.md +++ b/docs/vi/architecture.md @@ -222,8 +222,18 @@ Mọi thứ ở phía sau — summary của CLI, HTML report, cây của viewer, ``` Range đánh số từ 0, hở đầu cuối (end-exclusive), tính trên dòng **thô** của mỗi bên. -`kind` là một trong `real`, `moved`, `comment`, `rename`, `uuid`, `timestamp`, -`sw-version`, `description`, `whitespace`, `mixed`. +`kind` là một trong `real`, `moved`, `comment`, `rename`, `assumed-rename`, +`reorder`, `uuid`, `timestamp`, `sw-version`, `description`, `whitespace`, +`mixed`. + +`assumed-rename` là kind duy nhất được gán **không kèm chứng minh**, và chỉ +xuất hiện khi caller truyền `skip_var_renames` (`--skip-var-renames`). Nó gộp +một hunk mà mọi dòng đều là binding — câu lệnh chỉ gọi tên một object và +nhiều nhất là chép một object khác vào đó (`a = b;`, `rtY.Out = rtU.Pedal;`, +`real_T x;`, `boolean_T f = FALSE;`) — chỉ khác nhau ở tên định danh, mà một +thay đổi đấu nối lại cũng trông y hệt vậy. Nói cách khác đây là chế độ cố ý chấp nhận báo thiếu: nó giữ kind riêng để +không bề mặt nào đọc thành `rename`, và report, summary trên terminal, JSON lẫn +tiêu đề viewer đều nói rõ lần chạy đó có bật cờ. Phần ngữ nghĩa chỉ được tính ở chỗ nó có thể có nghĩa: file có shadow bằng nhau thì nội dung như nhau, nên không thể làm xê dịch bề mặt AUTOSAR. diff --git a/docs/vi/usage.md b/docs/vi/usage.md index a5ddfbb..1c047f5 100644 --- a/docs/vi/usage.md +++ b/docs/vi/usage.md @@ -44,6 +44,7 @@ giờ âm thầm rơi xuống so sánh một thư mục rỗng. | `--current-name NAME` | Tương tự cho phía CURRENT. Cả hai cờ chỉ đổi chữ trên header — đường dẫn thư mục vẫn nằm ở tooltip, nên vẫn truy được file đã đọc từ đâu | | `--theme dark\|light` | Bảng màu lúc mở của report và viewer (mặc định `dark`). Report mang sẵn **cả hai** và có nút đổi riêng, nên cờ này chỉ quyết định người đọc thấy màu nào trước | | `--rules RULES.json` | Thêm pattern noise chạy trên nền các rule sẵn có — xem [Custom noise rules](#custom-noise-rules) | +| `--skip-var-renames` | **Quick check, không an toàn.** Gộp các hunk C/C++ chỉ đổi tên biến mà *không* chứng minh được đó là noise — xem [Quick check: bỏ qua đổi tên biến](#quick-check-bỏ-qua-đổi-tên-biến) | | `--max-diff-lines N` | Giới hạn diff nhúng mỗi file khoảng N dòng (0 = không giới hạn, mặc định). Chặn một lần regenerate cả cây render một file thành report to đến mức treo browser; file bị cắt vẫn giữ verdict và exit code, và chỗ cắt được báo rõ | | `--qt`, `--viewer` | Mở viewer trên hai thư mục truyền ở command line, thay vì so sánh trong terminal. Cần extra `viewer` | | `--version` | In phiên bản rồi thoát | @@ -177,6 +178,7 @@ trên cây vẫn nằm trong file export với verdict thật của nó. | `timestamp` | Block ``, `` | .arxml .xml | | `sw-version` | Version stamp `` (tăng mỗi lần regenerate). Regex có anchor, nên `` và các thẻ tương tự không bị đụng | .arxml .xml | | `description` | ``, ``, `` — các thẻ chứa mô tả bằng chữ, không ảnh hưởng hành vi (áp dụng cho cả schema 4.2 và 4.4). `` và `` **không** được lọc: `` ảnh hưởng cách phần tử được hiểu, còn `` có thể chứa dữ liệu do tool khác ghi vào | .arxml .xml | +| `assumed-rename` | Lệnh gán và khai báo chỉ khác nhau ở tên biến, gộp **không kèm chứng minh** — chỉ xuất hiện khi bật `--skip-var-renames`, mặc định không bao giờ | .c .h .cpp .hpp | | `whitespace` | Thụt đầu dòng, khoảng trắng cuối dòng, dòng trống | tất cả | | `line-endings` | CRLF vs LF, BOM | tất cả | @@ -228,6 +230,62 @@ vào giữa, vế phải của một phép gán thật sự đổi, hay một c đảo thứ tự — thì cả block vẫn tính là thay đổi thật. Khi không chắc, tool luôn chọn hiện diff ra chứ không giấu đi. +### Quick check: bỏ qua đổi tên biến + +Mọi rule ở trên đều chứng minh được trước khi gộp một khác biệt đi. +`--skip-var-renames` thì không, và đây là phần duy nhất của tool làm vậy — nên +nó mặc định tắt và phải gọi đích danh mới chạy. + +Nó phục vụ đúng một việc: quét nhanh một bản regenerate để tìm những gì **không +phải** đổi tên, khi bạn đã biết trước sẽ có một loạt biến bị đổi tên và chỉ muốn +xem phần còn lại. Nó không dùng để ký duyệt review. + +Khi bật cờ, một hunk C/C++ được gộp thành `assumed-rename` nếu mọi dòng ở cả hai +phía đều là **binding** — một câu lệnh chỉ gọi tên một object và nhiều nhất là +chép một object hoặc literal khác vào đó — hai phía ghép được 1-1 theo dòng, +chỉ khác nhau ở tên định danh, và kiểu khai báo không đổi. + +Binding không bao giờ *tính toán*. Hai vế của dấu `=` đều được phép là lvalue +path thuần — một cái tên, `.field`, `->field`, `[index]` — đúng cách Embedded +Coder truy cập port, DWork state và buffer: + +```c +a = b; → x = b; +acc_cmd = rtU.Pedal; → drv_cmd = rtU.Pedal; +rtY.Out = filt_in; → rtY.Out = filt_val; +buf[2] = rtDW->State; → buf[2] = rtDW->Level; +real_T filt_in; → real_T filt_val; +boolean_T flag = FALSE; → boolean_T other = FALSE; +``` + +**Cái giá phải trả.** Một thay đổi đấu nối lại (rewiring) có đúng hình dạng đó, +nên cũng bị gộp luôn. `a = speed;` đổi thành `a = torque;` là thay đổi tín hiệu +thật, và chế độ này sẽ không báo. Đó chính là sự đánh đổi mà cờ này sinh ra để +chấp nhận: một lần chạy có dùng cờ có thể đang thiếu một thay đổi thật. + +**Cái gì vẫn tính là thay đổi thật, kể cả khi bật cờ:** + +- literal đổi giá trị — `a = 0;` → `x = 1;`; +- kiểu dữ liệu đổi — `sint32 a = 0;` → `uint8 x = 0;`; +- macro hoặc enum constant viết hoa toàn bộ ở một trong hai phía — `flag = FALSE;` + → `flag = TRUE;`, `mode = IDLE;` → `mode = DRIVE;`. C dành tên viết hoa cho + hằng khai báo ở chỗ khác, nên tráo một cái là đổi giá trị chứ không phải đổi tên; +- hoán đổi hai biến — `a`↔`b` — dù trông nhất quán đến đâu vẫn là thay đổi thật; +- bất kỳ hunk nào có một dòng không phải binding: biểu thức (`a = b + c;`), lời + gọi hàm, ép kiểu, lấy địa chỉ hay dereference, khai báo prototype, khai báo + nhiều biến trên một dòng. Chỉ một dòng như vậy là **cả hunk** giữ nguyên trạng + thái thật, nên một chỗ đổi tên nằm cạnh một sửa đổi thật vẫn được hiện ra. + +**Cách nó tự báo.** Gộp mà không chứng minh chỉ chấp nhận được nếu không thể bỏ sót: + +- summary trên terminal in cảnh báo `QUICK CHECK` ngay phía trên phần đếm; +- HTML report có một dòng note ở cuối trang, và dòng placeholder của hunk bị + gộp ghi đúng kind `assumed-rename` chứ không gọi nó là rename đã chứng minh; +- output JSON mang thêm `"quick_check": "skip-var-renames"`; +- thanh tiêu đề của viewer ghi `QUICK CHECK: variable renames skipped`. + +Cờ này chỉ đụng tới binding C/C++, nên đi kèm `--arxml-only` thì không có tác dụng gì. + ### Comment là hạng mục riêng File mà khác biệt *chỉ* nằm ở comment được báo là **Comment**, tách riêng khỏi diff --git a/tests/test_report.py b/tests/test_report.py index 7452c85..f32d0ea 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -104,26 +104,25 @@ def test_report_carries_no_redundant_hunk_composition_text(self): sect = next(s for s in page.split('
')[0] - # the header may now carry an "Affected: " hint (which functions - # changed -- not a recount of the rows), so the check is on the - # composition wording itself, not the hcount span it once rode in on header = sect.split('
')[0] self.assertNotIn('hunk', header) self.assertNotIn('hunklabel', sect) self.assertNotIn('comment + real', sect) def test_report_captions_the_enclosing_function(self): - # the real hunk in NoiseDemo.c sits inside Calc_step; the group gets - # a caption naming it, and the file header lists it as Affected + # the real hunk in NoiseDemo.c sits inside Calc_step, and the group + # gets a caption naming it. The file header does NOT repeat it: a + # header answers "must I read this file", and the answer is the verdict results = scan(DEMO / 'old', DEMO / 'new') page = build_report(results, DEMO / 'old', DEMO / 'new') sect = next(s for s in page.split('
')[0] header = sect.split('
')[0] - self.assertIn('Affected: Calc_step', header) - self.assertIn('class="fnhdr"', sect) - self.assertIn('Calc_step', sect.split('')[0] for p in sect.split('class="fnhdr"')[1:]] + self.assertTrue(any('Calc_step' in c for c in captions), captions) def test_report_shows_minor_hunks_in_modified_files(self): results = scan(DEMO / 'old', DEMO / 'new') diff --git a/tests/test_skip_var_renames.py b/tests/test_skip_var_renames.py new file mode 100644 index 0000000..0333203 --- /dev/null +++ b/tests/test_skip_var_renames.py @@ -0,0 +1,282 @@ +"""The --skip-var-renames quick check. + +The one mode that folds a difference WITHOUT proving it is noise, so these +tests carry two burdens the other rule tests do not: what it folds (bindings +that differ only by variable names), and what it still refuses to fold even +here -- a changed literal, a changed type, an ALL_CAPS macro/enum swap, a +variable swap, and any hunk holding a line that is not a plain binding. + +Everything it does fold must stay visible as ``assumed-rename`` and must be +announced -- in the terminal summary, in the report and in the JSON -- so a +green verdict from this mode can never be mistaken for a green verdict from +the proven rules. +""" + +import io +import json +import unittest +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from tempfile import TemporaryDirectory + +from compare_tool import c_rules +from compare_tool.diff_engine import compare_pair +from compare_tool.main import _parser, main, summary_lines +from compare_tool.report import build_report +from compare_tool.scanner import scan, summarize +from compare_tool.serialize import build + +# The renamed name is deliberately still referenced further down, so the proven +# file-wide rename map refuses the file (it only accepts a name that vanished). +# That is what leaves the hunk real without the flag -- and therefore what the +# quick check is being asked to fold. +BODY = 'void f(void)\n{\n %s\n keep(a);\n keep(mode);\n}\n' + + +def kinds(r): + return [h['kind'] for h in r['hunks']] + + +def pair(old_stmt, new_stmt, skip=True): + return compare_pair(BODY % old_stmt, BODY % new_stmt, 'f.c', (), skip) + + +class TestBindingParts(unittest.TestCase): + def test_plain_assignment(self): + self.assertEqual(c_rules.binding_parts('a = b;'), ((), ('a',), ('b',))) + + def test_declaration_with_initializer(self): + self.assertEqual(c_rules.binding_parts('boolean_T flag = FALSE;'), + (('boolean_T',), ('flag',), ('FALSE',))) + + def test_bare_declaration_has_no_value(self): + self.assertEqual(c_rules.binding_parts('real_T filt_in;'), + (('real_T',), ('filt_in',), ())) + + def test_signed_literal(self): + self.assertEqual(c_rules.binding_parts('sint32 x = -1;'), + (('sint32',), ('x',), ('-', '1'))) + + def test_member_and_index_paths_on_both_sides(self): + # how Embedded Coder actually reaches a port, a DWork or a buffer + self.assertEqual(c_rules.binding_parts('rtY.Out = rtU.Pedal;'), + ((), ('rtY', '.', 'Out'), ('rtU', '.', 'Pedal'))) + self.assertIsNotNone(c_rules.binding_parts('buf[2] = rtDW->State;')) + self.assertIsNotNone(c_rules.binding_parts('p->q = r[i];')) + + def test_what_is_not_a_binding(self): + for line in ('a = b + c;', # an expression + 'a = foo(b);', # a call + 'a = (real_T)b;', # a cast + 'a = &b;', # an address-of + 'x = *p;', # a dereference + 'a = b', # no terminator + 'if (a == b) {', # a comparison + 'a = b; c = d;', # two statements + 'real_T a, b;', # more than one declarator + 'void f(void);', # a prototype + 'a;', # a statement, not a declaration + 'return a;', # 'return' is not a type + 'break;'): + self.assertIsNone(c_rules.binding_parts(line), line) + + +class TestAssumedRenameMap(unittest.TestCase): + def test_name_swap_is_mapped(self): + self.assertEqual(c_rules.assumed_rename_map(['a = b;'], ['x = b;']), + {'a': 'x'}) + + def test_both_sides_of_the_binding_may_move(self): + self.assertEqual(c_rules.assumed_rename_map(['a = b;'], ['x = y;']), + {'a': 'x', 'b': 'y'}) + + def test_a_changed_literal_is_not_a_rename(self): + self.assertIsNone(c_rules.assumed_rename_map(['a = 0;'], ['x = 1;'])) + + def test_a_changed_type_is_not_a_rename(self): + self.assertIsNone(c_rules.assumed_rename_map(['sint32 a = 0;'], + ['uint8 x = 0;'])) + self.assertIsNone(c_rules.assumed_rename_map(['real_T a;'], ['uint8 a_;'])) + + def test_a_bare_declaration_rename_is_mapped(self): + self.assertEqual(c_rules.assumed_rename_map(['real_T filt_in;'], + ['real_T filt_val;']), + {'filt_in': 'filt_val'}) + + def test_a_path_rename_is_mapped(self): + self.assertEqual(c_rules.assumed_rename_map(['a = rtU.Pedal;'], + ['x = rtU.Pedal;']), + {'a': 'x'}) + + def test_a_changed_index_literal_is_not_a_rename(self): + self.assertIsNone(c_rules.assumed_rename_map(['buf[0] = b;'], + ['buf[1] = b;'])) + + def test_an_all_caps_constant_is_not_a_rename(self): + # FALSE -> TRUE and IDLE -> DRIVE are value changes wearing the shape + self.assertIsNone(c_rules.assumed_rename_map(['a = FALSE;'], ['a = TRUE;'])) + self.assertIsNone(c_rules.assumed_rename_map(['m = IDLE;'], ['m = DRIVE;'])) + + def test_a_variable_swap_is_not_a_rename(self): + self.assertIsNone(c_rules.assumed_rename_map(['a = c;', 'b = d;'], + ['b = c;', 'a = d;'])) + + def test_one_non_binding_line_refuses_the_whole_hunk(self): + self.assertIsNone(c_rules.assumed_rename_map(['a = b;', 'q = foo();'], + ['x = b;', 'q = bar();'])) + + def test_sides_that_do_not_pair_one_to_one(self): + self.assertIsNone(c_rules.assumed_rename_map(['a = b;'], + ['x = b;', 'y = c;'])) + self.assertIsNone(c_rules.assumed_rename_map([], [])) + + +class TestComparePair(unittest.TestCase): + def test_the_flag_is_off_by_default(self): + r = compare_pair(BODY % 'a = b;', BODY % 'x = b;', 'f.c') + self.assertEqual(r['status'], 'real-change') + self.assertIn('real', kinds(r)) + + def test_an_assignment_rename_is_folded(self): + r = pair('a = b;', 'x = b;') + self.assertEqual(r['status'], 'ignorable-only') + self.assertEqual(set(kinds(r)), {'assumed-rename'}) + + def test_a_declaration_rename_is_folded(self): + r = pair('boolean_T a = FALSE;', 'boolean_T x = FALSE;') + self.assertEqual(r['status'], 'ignorable-only') + self.assertEqual(set(kinds(r)), {'assumed-rename'}) + + def test_a_port_read_rename_is_folded(self): + # the everyday Embedded Coder shape the mode exists for + r = pair('a = rtU.Pedal;', 'x = rtU.Pedal;') + self.assertEqual(r['status'], 'ignorable-only') + self.assertEqual(set(kinds(r)), {'assumed-rename'}) + + def test_a_bare_declaration_rename_is_folded(self): + # `a` is still referenced below, so the proven map refuses it and the + # label is the quick check's own -- a name that vanished would be a + # plain `rename` and would not exercise this mode at all + r = pair('real_T a;', 'real_T a_val;') + self.assertEqual(r['status'], 'ignorable-only') + self.assertEqual(set(kinds(r)), {'assumed-rename'}) + + def test_the_documented_false_negative(self): + # stated plainly because it is the price of the mode: a rewiring has + # the same shape as a rename and is folded too, on a plain name and on + # a port field alike + self.assertEqual(pair('a = speed;', 'a = torque;')['status'], + 'ignorable-only') + self.assertEqual(pair('a = rtU.Pedal;', 'a = rtU.Brake;')['status'], + 'ignorable-only') + + def test_what_stays_real_even_in_quick_check(self): + for old, new in (('boolean_T a = FALSE;', 'boolean_T a = TRUE;'), + ('sint32 a = 0;', 'sint32 x = 1;'), + ('sint32 a = 0;', 'uint8 x = 0;'), + ('mode = IDLE;', 'mode = DRIVE;'), + ('a = b + c;', 'x = b + c;'), + ('a = foo(b);', 'x = foo(b);'), + ('real_T a;', 'uint8 a_;'), + ('buf[0] = b;', 'buf[1] = b;')): + r = pair(old, new) + self.assertEqual(r['status'], 'real-change', (old, new)) + + def test_a_real_change_beside_a_rename_keeps_the_hunk_real(self): + r = pair('a = b;\n q = foo(1);', 'x = b;\n q = foo(2);') + self.assertEqual(r['status'], 'real-change') + self.assertIn('real', kinds(r)) + + def test_arxml_is_untouched_by_the_flag(self): + old = '\na\n\n' + new = '\nx\n\n' + self.assertEqual(compare_pair(old, new, 'f.arxml', (), True)['status'], + 'real-change') + + +class TestAnnouncement(unittest.TestCase): + """Folded is never hidden: every surface has to say the mode was on.""" + + def setUp(self): + self.results = {'f.c': pair('a = b;', 'x = b;')} + self.results['f.c']['binary'] = False + + def test_the_terminal_summary_warns(self): + text = '\n'.join(summary_lines(self.results, summarize(self.results))) + self.assertIn('QUICK CHECK', text) + self.assertIn('--skip-var-renames', text) + + def test_a_clean_run_does_not_warn(self): + results = {'f.c': compare_pair(BODY % 'a = b;', BODY % 'a = b;', 'f.c')} + results['f.c']['binary'] = False + text = '\n'.join(summary_lines(results, summarize(results))) + self.assertNotIn('QUICK CHECK', text) + + def test_the_report_carries_a_footer_note(self): + html = build_report(self.results, 'old', 'new') + self.assertIn('Variable renames ignored', html) + self.assertIn('--skip-var-renames', html) + # a caption, not a banner: below the tree, above the diffs + # (rindex, because the CSS rule for the class comes first) + where = html.rindex('qcnote') + self.assertGreater(where, html.index('Folder tree')) + self.assertLess(where, html.index('Detailed changes')) + + def test_the_report_has_no_note_without_the_mode(self): + results = {'f.c': compare_pair(BODY % 'a = b;', BODY % 'x = b;', 'f.c')} + results['f.c']['binary'] = False + self.assertNotIn('Variable renames ignored', + build_report(results, 'old', 'new')) + + def test_the_json_records_the_mode(self): + doc = build(self.results, summarize(self.results), 'old', 'new', 0) + self.assertEqual(doc['quick_check'], 'skip-var-renames') + + def test_the_json_is_unmarked_without_the_mode(self): + results = {'f.c': compare_pair(BODY % 'a = b;', BODY % 'x = b;', 'f.c')} + doc = build(results, summarize(results), 'old', 'new', 1) + self.assertNotIn('quick_check', doc) + + +class TestCli(unittest.TestCase): + def test_the_flag_defaults_off(self): + self.assertFalse(_parser().parse_args(['old', 'new']).skip_var_renames) + + def test_the_flag_parses(self): + args = _parser().parse_args(['old', 'new', '--skip-var-renames']) + self.assertTrue(args.skip_var_renames) + + def _tree(self, tmp): + old, new = Path(tmp) / 'old', Path(tmp) / 'new' + old.mkdir() + new.mkdir() + (old / 'f.c').write_text(BODY % 'a = b;', encoding='utf-8') + (new / 'f.c').write_text(BODY % 'x = b;', encoding='utf-8') + return old, new + + def test_end_to_end_the_exit_code_drops_to_zero(self): + with TemporaryDirectory() as tmp: + old, new = self._tree(tmp) + out, js = Path(tmp) / 'r.html', Path(tmp) / 'r.json' + argv = [str(old), str(new), '--report', str(out), '--json', str(js)] + buf = io.StringIO() + with redirect_stdout(buf), redirect_stderr(io.StringIO()): + self.assertEqual(main(argv), 1) # real change: gate trips + self.assertEqual(main(argv + ['--skip-var-renames']), 0) + self.assertIn('QUICK CHECK', buf.getvalue()) + self.assertIn('Variable renames ignored', + out.read_text(encoding='utf-8')) + doc = json.loads(js.read_text(encoding='utf-8')) + self.assertEqual(doc['quick_check'], 'skip-var-renames') + self.assertEqual(doc['exit_code'], 0) + + def test_scan_takes_the_flag(self): + with TemporaryDirectory() as tmp: + old, new = self._tree(tmp) + self.assertEqual(scan(old, new)['f.c']['status'], 'real-change') + self.assertEqual(scan(old, new, skip_var_renames=True)['f.c']['status'], + 'ignorable-only') + + +if __name__ == '__main__': + unittest.main()