From 182eb4e52545209e34308605fb8fda4fa031615c Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 21:19:08 +0700 Subject: [PATCH 1/9] refactor: drop unused gitsource.git_available() No production caller -- only a test's skipUnless used it, and the git button gates on repo_root() being None instead. Inline the availability check into the test so the helper does not linger as dead API. --- compare_tool/gitsource.py | 10 ---------- tests/test_gitsource.py | 11 ++++++++++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/compare_tool/gitsource.py b/compare_tool/gitsource.py index c728c6a..e778174 100644 --- a/compare_tool/gitsource.py +++ b/compare_tool/gitsource.py @@ -63,16 +63,6 @@ def _run(root, args, timeout=30, capture_to=None): return p.stdout.decode('utf-8', 'replace') -def git_available(): - try: - subprocess.run(['git', '--version'], stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, timeout=10, - creationflags=_NO_WINDOW) - except (OSError, subprocess.SubprocessError): - return False - return True - - def repo_root(path): """The work tree containing `path`, or None if it is not in a repository. diff --git a/tests/test_gitsource.py b/tests/test_gitsource.py index 4ee6b97..5800f82 100644 --- a/tests/test_gitsource.py +++ b/tests/test_gitsource.py @@ -22,7 +22,16 @@ def _git(root, *args): stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) -@unittest.skipUnless(gitsource.git_available(), 'git is not installed') +def _git_available(): + try: + subprocess.run(['git', '--version'], stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=10) + except (OSError, subprocess.SubprocessError): + return False + return True + + +@unittest.skipUnless(_git_available(), 'git is not installed') class _RepoCase(unittest.TestCase): """A three-commit repository: gen/ appears in c1, changes in c2, and c3 touches only a file outside it.""" From 1adf55a2cae864eed6f596144a75c8b2566b9914 Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 21:42:53 +0700 Subject: [PATCH 2/9] feat(diff): fold provably-safe statement reorders in generated C Embedded Coder routinely re-emits the same independent assignments in a different order (output ports, temporaries). The text reads it as a change, but the block computes identical values -- the last residual codegen churn the text-based rules cannot see past, because it is a reschedule, not a rename, comment or whole-block move. Decide it on meaning, not spelling: when the whole surviving change set is a dependence-preserving permutation of a straight-line block of side-effect-free scalar assignments, it is proven noise (kind 'reorder', ignorable). The scalar- LHS + no-call restriction makes distinct names denote distinct objects, so name-keyed read/write sets are the exact data dependence; two schedules that agree on the order of every dependent pair are linear extensions of the same DAG and compute the same result. Any unsafe line, multiset mismatch or flipped dependent pair leaves every hunk real -- fail-safe, all or nothing. --- CHANGELOG.md | 8 +++ compare_tool/c_rules.py | 125 ++++++++++++++++++++++++++++++++++++ compare_tool/diff_engine.py | 16 ++++- docs/architecture.md | 12 +++- docs/usage.md | 21 ++++++ tests/test_engine.py | 41 +++++++++++- tests/test_rules.py | 52 +++++++++++++++ 7 files changed, 270 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 35765c8..90052a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project are documented here. Versions follow ## [Unreleased] +### 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. + ## [1.9.0] — 2026-08-14 Diff and review C++, Python, YAML and JSON files alongside the AUTOSAR output. diff --git a/compare_tool/c_rules.py b/compare_tool/c_rules.py index 87baf8c..eed337a 100644 --- a/compare_tool/c_rules.py +++ b/compare_tool/c_rules.py @@ -6,6 +6,7 @@ """ import re +from collections import Counter from difflib import SequenceMatcher from . import linediff @@ -435,6 +436,130 @@ def autogen_noise_map(old_lines, new_lines, old_ids=None, new_ids=None): return mapping +# --- straight-line reorder (Embedded Coder reschedules independent stmts) --- +# +# Regenerating a model routinely emits the same independent assignments in a +# different order (output ports, temporaries), which the raw and shadow text +# both read as a change even though the block computes identical values. This +# is the one residual churn the text rules cannot see past: it is not a rename, +# not a comment, not a whole-block move -- it is a *reschedule*. Proving it safe +# needs the data dependence between statements, so it is decided here on the +# meaning of the lines, not their spelling. + +_ASSIGN_RE = re.compile(r'^([A-Za-z_]\w*)\s*=\s*(.*)$') +# an identifier glued to a '(' is a call; a cast '(real_T)x' has the '(' after +# an operator or nothing, so it is not matched and stays allowed +_CALL_RE = re.compile(r'[A-Za-z_]\w*\s*\(') + + +def _parse_scalar_stmt(line): + """``(canonical_content, writes, reads)`` for a side-effect-free scalar + assignment ``ident = expr;``, or ``None`` for anything else. + + ``None`` is the conservative answer, and the caller turns any ``None`` in a + block into "this is a real change". A declaration carrying a type, a call, a + store through an array / pointer / field, a control-flow line, or two + statements on one line all return ``None``. + + The restriction is what makes the dependence exact. Every accepted statement + writes a plain scalar identifier and no accepted RHS contains a call, so no + store can alias another statement's read: two distinct names denote two + distinct objects. Read/write sets keyed by name are then the true data + dependence, not an approximation of it. + """ + s = line.strip() + if not s.endswith(';'): + return None + body = s[:-1] + if ';' in body: + return None # more than one statement on the line + m = _ASSIGN_RE.match(body) + if not m: + return None + lhs, rhs = m.group(1), m.group(2) + if not rhs or rhs[0] == '=': + return None # '==' comparison, or an empty RHS -- not an assignment + if _CALL_RE.search(rhs): + return None # a call may have side effects; moving it is not safe + if lhs in C_KEYWORDS: + return None + content = canonical_generated(body) + reads = frozenset(t for t in tokenize(rhs) if is_identifier(t)) + return content, frozenset((lhs,)), reads + + +def reorder_equivalent(old_lines, new_lines): + """True when two straight-line blocks hold the SAME statements in a + dependence-preserving different order -- Embedded Coder rescheduling + independent assignments, which computes exactly the same values. + + Proven, not guessed. Every line on both sides must be a safe scalar + assignment (see :func:`_parse_scalar_stmt`); the two sides must be a + permutation of one statement multiset; and every pair of statements that + share a variable with at least one *writing* it must keep their relative + order. Two schedules of a straight-line block that agree on the order of + every dependent pair are both linear extensions of the same dependence DAG, + so they compute identical results. Any unsafe line, any multiset mismatch, + or any flipped dependent pair returns False and the block stays real. + + Residual assumption, stated plainly: a ``volatile`` scalar read is invisible + here (it looks like a plain identifier), so two reads of the same volatile + object could in principle be reordered. Embedded Coder does not emit that, + and any *write* to the shared name keeps the pair ordered regardless. This + is the same class of thing the whole tool cannot see (it never expands a + macro), and it errs toward calling a block real, never toward hiding one. + """ + if not (2 <= len(old_lines) == len(new_lines) <= 200): + return False + old = [_parse_scalar_stmt(l) for l in old_lines] + new = [_parse_scalar_stmt(l) for l in new_lines] + if any(s is None for s in old) or any(s is None for s in new): + return False + old_keys = [s[0] for s in old] + new_keys = [s[0] for s in new] + if len(set(old_keys)) != len(old_keys): + return False # a repeated statement makes the old<->new pairing ambiguous + if Counter(old_keys) != Counter(new_keys): + return False # not a permutation: a statement was added / removed / changed + if old_keys == new_keys: + return False # nothing was actually reordered -- not this rule's case + new_pos = {k: i for i, k in enumerate(new_keys)} + for a in range(len(old)): + _ka, wa, ra = old[a] + for b in range(a + 1, len(old)): + kb, wb, rb = old[b] + # a precedes b in the old order; they are dependent when they share + # a variable and at least one of them writes it (true / anti / output + # dependence). A dependent pair must keep its order in the new one. + if (wa & rb) or (wa & wb) or (wb & ra): + if new_pos[old_keys[a]] > new_pos[kb]: + return False + return True + + +def is_safe_reorder(old_shadow_lines, new_shadow_lines, hunks): + """True when the whole set of surviving change ``hunks`` is one + dependence-preserving reorder of a single straight-line block. + + The block spans the first changed shadow line to the last, on each side, and + includes the unchanged statements between them -- so the dependence check is + complete: a hunk statement never crosses the unchanged block boundary, and + any real change among the hunks lands a foreign statement inside the span + and fails the permutation test (see :func:`reorder_equivalent`). All or + nothing on purpose: one genuine change mixed in leaves every hunk real, + which is the safe direction. + """ + if not hunks: + return False + o1 = min(h[0] for h in hunks) + o2 = max(h[1] for h in hunks) + n1 = min(h[2] for h in hunks) + n2 = max(h[3] for h in hunks) + old_block = [l for l in old_shadow_lines[o1:o2] if l.strip()] + new_block = [l for l in new_shadow_lines[n1:n2] if l.strip()] + return reorder_equivalent(old_block, new_block) + + # --- RTE access-point summary (AUTOSAR blockset codegen) --- # Standard RTE API verbs (AUTOSAR_SWS_RTE). Unknown verbs are simply not diff --git a/compare_tool/diff_engine.py b/compare_tool/diff_engine.py index 1e159e5..86a60d6 100644 --- a/compare_tool/diff_engine.py +++ b/compare_tool/diff_engine.py @@ -6,7 +6,7 @@ by testing single normalization rules one at a time. Hunk dict: {kind, old_range: [i1, i2), new_range: [j1, j2)} (0-based lines) -kind in {real, moved, comment, rename, uuid, timestamp, sw-version, +kind in {real, moved, comment, rename, reorder, uuid, timestamp, sw-version, description, whitespace, mixed} Moved blocks: a pure-delete hunk whose non-blank shadow content reappears @@ -355,6 +355,18 @@ def compare_pair(old_text, new_text, path): kept.append(h) candidates = kept + # MATLAB codegen reschedules independent statements (output assignments, + # temporaries): the raw and shadow text both read as a change, but the block + # computes the same values. When the whole surviving change set is one + # dependence-preserving permutation of a straight-line block, it is proven + # noise (c_rules.reorder_equivalent) -- fail-safe: a real change mixed in, or + # any line that is not a safe scalar assignment, leaves every hunk real. + reorder_hunks = [] + if ruleset == 'c' and candidates and c_rules.is_safe_reorder( + final_old_shadow_lines, new_shadow_lines, candidates): + reorder_hunks = list(candidates) + candidates = [] + real_hunks = candidates moved_del, moved_ins = _detect_moves(candidates, final_old_shadow_lines, new_shadow_lines, ruleset) @@ -391,6 +403,8 @@ def compare_pair(old_text, new_text, path): break if kind is None and autogen_hunks and _overlaps(h, autogen_hunks): kind = 'rename' # autogen-name swap (rtb_/mangle/temp) + if kind is None and reorder_hunks and _overlaps(h, reorder_hunks): + kind = 'reorder' # independent statements rescheduled if kind is None: kind = 'mixed' # ignorable but caused by >1 rule combined for name, ov, nv in variants: diff --git a/docs/architecture.md b/docs/architecture.md index d98f3ca..e338ee5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -242,8 +242,16 @@ consumes one dict per compared path: ``` Ranges are 0-based, end-exclusive, into the **raw** lines of each side. -`kind` is one of `real`, `moved`, `comment`, `rename`, `uuid`, `timestamp`, -`sw-version`, `description`, `whitespace`, `mixed`. +`kind` is one of `real`, `moved`, `comment`, `rename`, `reorder`, `uuid`, +`timestamp`, `sw-version`, `description`, `whitespace`, `mixed`. + +`reorder` is the one ignorable kind decided on *meaning* rather than spelling: +when the whole surviving change set is a dependence-preserving permutation of a +straight-line block of scalar assignments, it computes the same values and is +proven noise (`c_rules.reorder_equivalent`). Like the autogen-rename kind it is +detected on the shadow hunks and applied by overlap, not through +`_build_variants`; any real change mixed in, or any line that is not a safe +scalar assignment, leaves every hunk real. The semantic extras are computed only where they can matter: a shadow-equal file has the same content, so it cannot have moved the AUTOSAR surface. diff --git a/docs/usage.md b/docs/usage.md index 44aaea2..167793d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -160,6 +160,7 @@ appears in the file with its real verdict. |---|---|---| | `comment` | C/C++/A2L comments (`//`, `/* */`), XML comments (``), `#` line comments (Python, YAML). Python docstrings and JSON are **not** folded — a triple-quoted string is code, and JSON has no comments | .c .h .cpp .hpp .arxml .a2l .py .yaml .yml | | `rename` | Consistent 1-to-1 variable renaming (MATLAB auto-generated names). Anything the mapping can't fully explain stays a real change | .c .h | +| `reorder` | Independent statements emitted in a different order (Embedded Coder rescheduling). Only folded when the block is straight-line scalar assignments **and** the new order preserves every data dependence — otherwise it stays a real change | .c .h | | `uuid` | `UUID="..."` attributes | .arxml .xml | | `timestamp` | `` blocks, `` | .arxml .xml | | `sw-version` | `` version stamps (bumped on every regenerate). Anchored, so `` and the like are untouched | .arxml .xml | @@ -190,6 +191,26 @@ so are `rtb_AND_…` → `rtb_OR_…` (a different block drives that buffer) and `Sub_…_step` → `Sub_…_Init` (a different entry point). Digits glued to a block name (`rtb_Switch1` vs `rtb_Switch2`) are part of the name, not a mangle tail. +### Reorder + +Regenerating a model routinely emits the same independent assignments in a +different order — output ports, temporaries — which the text reads as a change +even though the block computes identical values. A `reorder` fold recognises +this, but only where it can be **proven**, never guessed: + +- every line on both sides is a side-effect-free scalar assignment + (`ident = expr;` — no call, no store through an array/pointer/field, no + control flow, no declaration with a type); +- the two sides hold the same statements, just permuted; +- the new order preserves **every data dependence** — whenever two statements + share a variable and one writes it, their relative order is unchanged. + +Two straight-line schedules that agree on the order of every dependent pair +compute the same result, so the reorder is behaviour-preserving. Anything that +does not meet all three — a call between the lines, a changed right-hand side, a +flipped dependent pair — leaves the whole block a real change. It errs toward +calling a block real, never toward hiding one. + ### Comment is its own category A file whose differences are *only* comments is reported as **Comment**, diff --git a/tests/test_engine.py b/tests/test_engine.py index c51ad7a..c65426a 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -99,6 +99,39 @@ def test_variable_swap_is_real(self): self.assertEqual(r['status'], 'real-change') self.assertEqual(r['renames'], {}) + def test_independent_reorder_is_noise(self): + # Embedded Coder emits the same independent assignments in a different + # order; the values are identical, so it is proven noise, not a change + old = "a = u + 1;\nb = v + 2;\nc = w + 3;\n" + new = "c = w + 3;\nb = v + 2;\na = u + 1;\n" + r = compare_pair(old, new, 'f.c') + self.assertEqual(r['status'], 'ignorable-only') + self.assertEqual(set(kinds(r)), {'reorder'}) + + def test_dependent_reorder_stays_real(self): + # moving 'y = t + 2' above the line that computes t changes the result, + # so the reorder is NOT safe and must stay real + old = "t = u + 1;\ny = t + 2;\n" + new = "y = t + 2;\nt = u + 1;\n" + r = compare_pair(old, new, 'f.c') + self.assertEqual(r['status'], 'real-change') + + def test_reorder_beside_real_change_stays_real(self): + # fail-safe: a genuine RHS change (v+2 -> v+99) mixed into a reorder + # breaks the permutation, so the whole block stays real + old = "a = u + 1;\nb = v + 2;\nc = w + 3;\n" + new = "c = w + 3;\nb = v + 99;\na = u + 1;\n" + r = compare_pair(old, new, 'f.c') + self.assertEqual(r['status'], 'real-change') + + def test_reorder_across_a_call_stays_real(self): + # a call between the reordered lines may have side effects; the block is + # no longer straight-line scalar, so it is not folded + old = "a = u + 1;\nb = step(a);\nc = w + 3;\n" + new = "c = w + 3;\nb = step(a);\na = u + 1;\n" + r = compare_pair(old, new, 'f.c') + self.assertEqual(r['status'], 'real-change') + def test_arxml_uuid_only(self): old = '\nx\n\n' new = '\nx\n\n' @@ -492,8 +525,12 @@ def test_moved_hunks_cross_reference_lines(self): self.assertEqual(froms[0], 6) def test_single_line_move_stays_real(self): - old = "a = 1;\nb = 2;\nc = 3;\n" - new = "b = 2;\nc = 3;\na = 1;\n" + # a single moved line is not confidently a move (MIN_MOVED_LINES): a + # lone statement reappears by coincidence too often. Calls, so the + # reorder proof (scalar assignments only) does not apply and cannot + # fold this either -- a moved side-effecting call IS a real change. + old = "f();\ng();\nh();\n" + new = "g();\nh();\nf();\n" r = compare_pair(old, new, 'f.c') self.assertEqual(r['status'], 'real-change') self.assertNotIn('moved', kinds(r)) diff --git a/tests/test_rules.py b/tests/test_rules.py index edf8aee..2e0ca10 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -587,5 +587,57 @@ def test_diff_one_side_missing_file(self): ('K_Gain', 'CHARACTERISTIC')]) +class TestReorderEquivalent(unittest.TestCase): + def test_independent_permutation(self): + self.assertTrue(c_rules.reorder_equivalent( + ['a = u + 1;', 'b = v + 2;', 'c = w + 3;'], + ['c = w + 3;', 'a = u + 1;', 'b = v + 2;'])) + + def test_true_dependence_order_kept_is_ok(self): + # t is written then read; the new order keeps that, only independent + # neighbours move around it + self.assertTrue(c_rules.reorder_equivalent( + ['t = u + 1;', 'y = t + 2;', 'z = w + 3;'], + ['z = w + 3;', 't = u + 1;', 'y = t + 2;'])) + + def test_true_dependence_flipped_is_not(self): + self.assertFalse(c_rules.reorder_equivalent( + ['t = u + 1;', 'y = t + 2;'], + ['y = t + 2;', 't = u + 1;'])) + + def test_output_dependence_flipped_is_not(self): + # two writes to the same variable: their order is the result + self.assertFalse(c_rules.reorder_equivalent( + ['x = 1;', 'x = 2;'], + ['x = 2;', 'x = 1;'])) + + def test_anti_dependence_flipped_is_not(self): + # read of x then overwrite of x (WAR): flipping changes what y sees + self.assertFalse(c_rules.reorder_equivalent( + ['y = x + 1;', 'x = 5;'], + ['x = 5;', 'y = x + 1;'])) + + def test_changed_statement_is_not(self): + self.assertFalse(c_rules.reorder_equivalent( + ['a = u + 1;', 'b = v + 2;'], + ['b = v + 9;', 'a = u + 1;'])) + + def test_call_is_not_safe(self): + self.assertFalse(c_rules.reorder_equivalent( + ['a = u + 1;', 'b = step(a);'], + ['b = step(a);', 'a = u + 1;'])) + + def test_non_scalar_lhs_is_not_safe(self): + # a store through an array could alias another statement's read + self.assertFalse(c_rules.reorder_equivalent( + ['arr[i] = u;', 'b = v + 2;'], + ['b = v + 2;', 'arr[i] = u;'])) + + def test_same_order_is_not_a_reorder(self): + self.assertFalse(c_rules.reorder_equivalent( + ['a = u + 1;', 'b = v + 2;'], + ['a = u + 1;', 'b = v + 2;'])) + + if __name__ == '__main__': unittest.main() From c58269826c17b83c2a57fe3d86d327fc6f1bf150 Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 21:48:09 +0700 Subject: [PATCH 3/9] feat(report): flag ARXML/C regenerated out of step A model's ARXML (its contract) and its generated C (its behaviour) come from one regenerate and are expected to change together. When one is Modified and the other Identical, each file is individually fine and the inconsistency lives strictly between them -- the one failure a file-by-file view structurally cannot point at, and the everyday sign of a partial or stale regenerate. New consistency.py computes it from verdicts the tool already stands behind, so it invents nothing. Surfaced in the report (a 'Consistency check' section) and the terminal summary. Advisory ONLY: absence of a partner change can be legitimate (a hand-written file, an ARXML-only edit), so it never folds a file, moves a count or changes the exit code. A2L is not paired -- a recal touches no code. Only the C<->ARXML pair, only when both are present in the compare. --- CHANGELOG.md | 5 +++ compare_tool/consistency.py | 74 +++++++++++++++++++++++++++++++++++++ compare_tool/main.py | 10 ++++- compare_tool/report.py | 32 +++++++++++++++- docs/architecture.md | 1 + docs/usage.md | 15 ++++++++ tests/test_consistency.py | 65 ++++++++++++++++++++++++++++++++ 7 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 compare_tool/consistency.py create mode 100644 tests/test_consistency.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 90052a6..147d224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ All notable changes to this project are documented here. Versions follow 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 when a model's ARXML and C did not regenerate together.** The report + and the terminal now point out a model whose generated C changed while its + ARXML did not, or the reverse — the usual sign of a partial or stale + regenerate. It is a heads-up only: it never changes a file's verdict or the + exit code. ## [1.9.0] — 2026-08-14 diff --git a/compare_tool/consistency.py b/compare_tool/consistency.py new file mode 100644 index 0000000..795e2fb --- /dev/null +++ b/compare_tool/consistency.py @@ -0,0 +1,74 @@ +"""Cross-artifact consistency advisories. + +A model's ARXML (its contract) and its generated C (its behaviour) are produced +by the same regenerate and are expected to move together. When one is +regenerated and the other is not, the folder holds a *mix* that the per-file +diff cannot point at: each file is individually fine — Modified, or Identical — +and the inconsistency lives strictly *between* them. That is the one thing a +file-by-file view structurally cannot show, and the everyday cause is a partial +or stale regenerate. + +This is an **advisory, never a verdict**. Absence of a partner change can be +perfectly legitimate — a hand-written file kept beside generated ones, an ARXML +edited on its own, a symbol defined in another folder — so it must not fold a +file, move a count, or change the exit code. It says "worth a look", not +"wrong". The claim the rest of the tool makes ("you can ignore what I hid") is +never put at risk by a guess, because this makes no claim about noise at all: it +only reports which artifact families of a model carry a change the tool already +stands behind. + +Only the C <-> ARXML pair is checked. A2L (calibration) legitimately changes on +its own — a recal touches no code — so pairing it here would cry wolf. + +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') + + +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 generated C and ARXML did not + change together. + + ``groups`` is ``{model: [rel, ...]}`` (the report's model grouping); + ``shared_group`` names the catch-all bucket to skip, since it is not one + model. Only models that have BOTH a C and an ARXML file in the compare are + judged — with only one family present there is no partner to be out of step + with. 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'] and present['arxml']): + continue + if changed['c'] and not changed['arxml']: + out.append((model, 'generated C changed but its ARXML did not — ' + 'check the model was fully regenerated')) + elif changed['arxml'] and not changed['c']: + out.append((model, 'ARXML changed but its generated C did not — ' + 'the code may not have been regenerated')) + return out diff --git a/compare_tool/main.py b/compare_tool/main.py index 5ff5cf6..09e644d 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -17,7 +17,7 @@ from . import review, theme, zipsource from .diff_engine import RULES -from .report import build_arxml_report, build_report +from .report import build_arxml_report, build_report, consistency_advisories from .view_model import SWC_DISPLAY, iface_kind, swc_item from .scanner import (scan, summarize, summarize_a2l, summarize_ifaces, summarize_rte, summarize_swcs) @@ -168,6 +168,14 @@ def summary_lines(results, counts): lines.append(' + {} ({}) in {}'.format(n, kind, rel)) for rel, n, kind in a2l_removed: lines.append(' - {} ({}) in {}'.format(n, kind, rel)) + + # cross-artifact heads-up: a model whose ARXML and C did not change + # together. Advisory only -- it never moves a count or the exit code + advisories = consistency_advisories(results) + if advisories: + lines.append('Consistency check:') + for model, msg in advisories: + lines.append(' !! {}: {}'.format(model, msg)) return lines diff --git a/compare_tool/report.py b/compare_tool/report.py index 6b812ac..75d563d 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -11,7 +11,7 @@ import re from pathlib import Path -from . import filepair, funcname, review, syntax, theme +from . import consistency, filepair, funcname, review, syntax, theme from .diff_engine import ruleset_for from .scanner import (looks_binary, read_text, summarize, summarize_a2l, summarize_ifaces, summarize_rte, summarize_swcs) @@ -987,6 +987,34 @@ def _overview_table(groups, results, model_anchors): '{}'.format(''.join(rows))) +def consistency_advisories(results): + """Cross-artifact advisories for a scan (see :mod:`compare_tool.consistency`). + + Public so the CLI summary and the report render the SAME list from the SAME + model grouping -- the seam is here because the grouping is. ``[]`` when the + layout has no models (the flat fallback), which is also when there is no + model whose artifacts could be out of step.""" + groups = _model_groups(results) + if not groups: + return [] + return consistency.model_advisories(groups, results, SHARED_GROUP) + + +def _consistency_html(advisories): + """The cross-artifact advisory block, or '' when there is nothing to say. + Rendered as a caution, not an error: it never counts toward the verdict.""" + if not advisories: + return '' + rows = ['
⚠ {} — {}
' + .format(_esc(model), _esc(msg)) for model, msg in advisories] + return ('

Consistency check

' + '
A model\'s ARXML and generated C are expected ' + 'to regenerate together. These changed on their own — a heads-up, ' + 'not a verdict: a file kept elsewhere or an ARXML-only edit can be ' + 'perfectly fine.
{}
' + .format(''.join(rows))) + + def _agg_status(node, results): """Folder status = most significant child status.""" best = 'identical' @@ -1599,6 +1627,8 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None, ''.format(**counts) + rev_group + '') if groups: parts.append(_overview_table(groups, results, model_anchors)) + parts.append(_consistency_html( + consistency.model_advisories(groups, results, SHARED_GROUP))) parts.append(_autosar_section(results, anchors)) if results: diff --git a/docs/architecture.md b/docs/architecture.md index e338ee5..a50f0b1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,6 +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 and generated C did not change together (heads-up only, never a verdict) ├── review.py # reviewer notes and sign-offs, keyed by change content so they survive a rescan ├── gitsource.py # read-only `git archive` of a commit into a temp folder, so a commit can be the OLD side ├── zipsource.py # read-only unpack of a .zip artifact into a temp folder, so a zip can be either side diff --git a/docs/usage.md b/docs/usage.md index 167793d..2774644 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -262,6 +262,21 @@ Files are grouped by **Simulink model** using the Embedded Coder AUTOSAR naming convention (`X.c`, `X.h`, `X.arxml`, `Rte_X.h`, `X_data.c`, the modular ARXML set, …). Files that match no model land in a final **Shared / other** group. +## Consistency check + +A model's ARXML (its contract) and its generated C (its behaviour) come out of +the same regenerate, so they are expected to change together. When one moved and +the other did not — the C is Modified but its ARXML is Identical, or the reverse +— the report and the terminal say so, per model. It is the one thing a +file-by-file view cannot show: each file is individually fine, and the mismatch +lives *between* them. The usual cause is a partial or stale regenerate. + +This is a **heads-up, not a verdict**. It never folds a file, moves a count, or +changes the exit code, because a partner change can be legitimately absent — a +hand-written file kept beside generated ones, or an ARXML edited on its own. +A2L is deliberately not paired here: a recalibration touches no code, so +flagging it would cry wolf. + ## HTML report Self-contained file, one per compare: badge toggles, folder tree, filter box, diff --git a/tests/test_consistency.py b/tests/test_consistency.py new file mode 100644 index 0000000..63f4380 --- /dev/null +++ b/tests/test_consistency.py @@ -0,0 +1,65 @@ +"""Cross-artifact consistency advisories.""" + +import unittest + +from compare_tool import consistency + + +def _results(**status_by_rel): + return {rel: {'status': st} for rel, st in status_by_rel.items()} + + +class TestModelAdvisories(unittest.TestCase): + def test_c_changed_arxml_not(self): + results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'identical'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} + adv = consistency.model_advisories(groups, results) + self.assertEqual(len(adv), 1) + self.assertEqual(adv[0][0], 'Ctrl') + self.assertIn('generated C changed but its ARXML did not', adv[0][1]) + + def test_arxml_changed_c_not(self): + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'real-change'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} + adv = consistency.model_advisories(groups, results) + self.assertEqual(len(adv), 1) + self.assertIn('ARXML changed but its generated C did not', adv[0][1]) + + def test_both_changed_is_quiet(self): + results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'added'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} + self.assertEqual(consistency.model_advisories(groups, results), []) + + def test_neither_changed_is_quiet(self): + results = _results(**{'Ctrl.c': 'identical', + 'Ctrl.arxml': 'ignorable-only'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} + self.assertEqual(consistency.model_advisories(groups, results), []) + + def test_one_family_only_is_quiet(self): + # no ARXML in the model: nothing to be out of step with + results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.h': 'identical'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.h']} + self.assertEqual(consistency.model_advisories(groups, results), []) + + def test_a2l_change_alone_is_not_flagged(self): + # calibration legitimately changes on its own; C and ARXML both quiet + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'identical', + 'Ctrl.a2l': 'real-change'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml', 'Ctrl.a2l']} + self.assertEqual(consistency.model_advisories(groups, results), []) + + def test_shared_bucket_skipped(self): + results = _results(**{'util.c': 'real-change', 'util.arxml': 'identical'}) + groups = {'Shared / other': ['util.c', 'util.arxml']} + self.assertEqual( + consistency.model_advisories(groups, results, 'Shared / other'), []) + + def test_deleted_c_counts_as_changed(self): + results = _results(**{'Ctrl.c': 'deleted', 'Ctrl.arxml': 'identical'}) + groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} + self.assertEqual(len(consistency.model_advisories(groups, results)), 1) + + +if __name__ == '__main__': + unittest.main() From 5d7217970188377b65994bf08f0950bf48a44b49 Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 21:52:56 +0700 Subject: [PATCH 4/9] feat(cli): emit JSON and SARIF for pipelines The HTML report is for a human and the exit code is for a gate; neither lets a build server read WHAT changed. New serialize.py writes the scan as data under two shapes: --json is the whole record (per-file verdict, hunks, renames, AUTOSAR extras, summary, consistency advisories) under a versioned schema, so a consumer pins 'schema' and is insulated from an internal refactor; --sarif is a SARIF 2.1.0 log of only the files needing action (modified/added/deleted/error), for GitHub or Azure DevOps code scanning to annotate inline. Both additive to the HTML report and independently usable. The JSON carries the same exit_code the process returns (one _exit_code seam), so file and $? agree. A failed machine-output write exits 2, same as a missing report -- a pipeline that asked for the file must not proceed as if it got one. --- CHANGELOG.md | 5 ++ compare_tool/main.py | 56 +++++++++++-- compare_tool/serialize.py | 167 ++++++++++++++++++++++++++++++++++++++ docs/architecture.md | 1 + docs/usage.md | 27 ++++++ tests/test_serialize.py | 134 ++++++++++++++++++++++++++++++ 6 files changed, 385 insertions(+), 5 deletions(-) create mode 100644 compare_tool/serialize.py create mode 100644 tests/test_serialize.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 147d224..bff23ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ All notable changes to this project are documented here. Versions follow ARXML did not, or the reverse — the usual sign of a partial or stale regenerate. It is a heads-up only: it never changes a file's verdict or the exit code. +- **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 --git a/compare_tool/main.py b/compare_tool/main.py index 09e644d..05b5e5a 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -15,7 +15,7 @@ import tempfile from pathlib import Path -from . import review, theme, zipsource +from . import review, serialize, theme, zipsource from .diff_engine import RULES from .report import build_arxml_report, build_report, consistency_advisories from .view_model import SWC_DISPLAY, iface_kind, swc_item @@ -234,6 +234,15 @@ def _parser(): 'Reviewed badge that hides the changes already signed ' 'off. Not loaded unless named: a report must not pick ' 'up someone else\'s sign-off by accident') + ap.add_argument('--json', metavar='OUT.json', default=None, + help='also write the full scan as schema-versioned JSON for ' + 'a pipeline to read -- per-file verdict, hunks, renames, ' + 'AUTOSAR extras, the run summary, the consistency ' + 'advisories and the exit code') + ap.add_argument('--sarif', metavar='OUT.sarif', default=None, + help='also write a SARIF 2.1.0 log of the files that need ' + 'action (modified / added / deleted / error), so GitHub ' + 'or Azure DevOps code scanning can annotate them inline') ap.add_argument('--exit-zero', action='store_true', help='always exit 0 even when real changes exist ' '(report-only mode for CI pipelines); compare ' @@ -385,15 +394,52 @@ def progress(done, total, rel): else: print('Report written: {}'.format(out.resolve())) + code = _exit_code(counts, args.exit_zero) + # the machine outputs carry the SAME exit code the process returns, so a + # pipeline reading the JSON and a pipeline reading $? cannot disagree + if args.json or args.sarif: + try: + _write_machine(args, results, counts, old_root, new_root, code, + args.baseline_name or old_zip, + args.current_name or new_zip) + except OSError as e: + # a machine output a pipeline asked for is a record; failing to + # write it leaves the run without the file it will gate on + print('!! MACHINE OUTPUT NOT WRITTEN -- {}'.format(_write_hint( + Path(args.json or args.sarif), e)), file=sys.stderr) + return 2 + return code + + +def _exit_code(counts, exit_zero): + """The process exit code for a completed scan. + + 2 whenever a path could not be compared -- an incomplete compare must never + look green, and ``--exit-zero`` cannot mask it. Otherwise 1 when real + differences exist (the CI gate), 0 when they do not or ``--exit-zero``. + """ if counts['error']: - # fail-safe: an incomplete compare must never look green, even with - # --exit-zero -- an uncompared file could hide a real change return 2 - if args.exit_zero: + if exit_zero: return 0 - # exit code 1 when real differences exist (CI gate) return 1 if counts['real-change'] or counts['added'] or counts['deleted'] else 0 +def _write_machine(args, results, counts, old_root, new_root, code, + old_label, new_label): + """Write the JSON and/or SARIF outputs the CLI was asked for. Raises + ``OSError`` on a failed write, which the caller turns into exit 2.""" + if args.json: + advisories = consistency_advisories(results) + text = serialize.dumps(results, counts, old_root, new_root, code, + old_label, new_label, advisories) + Path(args.json).write_text(text, encoding='utf-8') + print('JSON written: {}'.format(Path(args.json).resolve())) + if args.sarif: + Path(args.sarif).write_text(serialize.dumps_sarif(results), + encoding='utf-8') + print('SARIF written: {}'.format(Path(args.sarif).resolve())) + + if __name__ == '__main__': sys.exit(main()) diff --git a/compare_tool/serialize.py b/compare_tool/serialize.py new file mode 100644 index 0000000..f756938 --- /dev/null +++ b/compare_tool/serialize.py @@ -0,0 +1,167 @@ +"""Machine-readable output of a scan: JSON and SARIF. + +The HTML report is for a human and the exit code is for a gate; neither lets a +pipeline read *what* changed. A build server that wants to annotate a pull +request, feed a dashboard, or drive its own policy needs the verdicts as data. + +The result dict is already the tool's contract (see the architecture doc), so +this serialises it under an explicit, versioned schema rather than leaking the +in-memory shape -- a consumer pins ``schema`` and is insulated from an internal +refactor. Every value is JSON-safe: ranges are lists, the semantic extras are +lists of arrays, and nothing here holds a set or a tuple by the time it is +dumped. + +Two shapes, one source: + +* **JSON** -- the whole scan: per-file status, hunks, renames, notes, move + pairing and the AUTOSAR semantic extras, plus the run summary, the exit code + and the cross-artifact advisories. The complete record, for a consumer that + wants everything. +* **SARIF 2.1.0** -- only the files a reviewer must act on (real-change, added, + deleted, error), each a result with a level, so GitHub / Azure DevOps code + scanning can annotate them inline. Noise and identical files are not + findings and are left out. + +Stdlib only (``json``), no Qt: it ships in the zipapp. +""" + +import datetime +import json + +from . import __version__ + +SCHEMA = 1 + +# how a verdict maps to a SARIF result level. Only these four are emitted as +# findings; identical and the noise verdicts are not something to act on. +_SARIF_LEVEL = { + 'error': 'error', # a path that could not be compared -- loudest + 'real-change': 'warning', + 'added': 'warning', + 'deleted': 'warning', +} +_SARIF_RULE_NAME = { + 'error': 'CompareIncomplete', + 'real-change': 'Modified', + 'added': 'Added', + 'deleted': 'Deleted', +} + + +def _hunk(h): + out = {'kind': h['kind'], 'old_range': list(h['old_range']), + 'new_range': list(h['new_range'])} + for k in ('moved_to', 'moved_from'): + if k in h: + out[k] = h[k] + return out + + +def _file_entry(rel, r): + """One file's record. Kept flat and explicit so the schema is a contract, + not whatever the engine happens to store.""" + entry = {'path': rel, 'status': r['status'], 'binary': r.get('binary', False)} + if r.get('notes'): + entry['notes'] = list(r['notes']) + if r.get('renames'): + entry['renames'] = dict(r['renames']) + hunks = r.get('hunks') or [] + if hunks: + entry['hunks'] = [_hunk(h) for h in hunks] + for k in ('moved_from', 'moved_to', 'move_status', 'move_similarity'): + if k in r: + entry[k] = r[k] + # semantic extras are already lists of arrays (json-safe); pass through + for k in ('ifaces', 'swc', 'rte', 'a2l'): + if k in r: + entry[k] = r[k] + return entry + + +def build(results, counts, old_root, new_root, exit_code, + old_label=None, new_label=None, advisories=()): + """The JSON document for a scan, as a plain dict ready for :func:`json.dumps`. + + ``exit_code`` is passed in rather than recomputed so the file and the + process agree by construction. ``advisories`` is the cross-artifact list + from :func:`compare_tool.report.consistency_advisories`. + """ + doc = { + 'schema': SCHEMA, + 'tool': 'codegen-compare-tool', + 'version': __version__, + 'generated': datetime.datetime.now().isoformat(timespec='seconds'), + 'baseline': str(old_root), + 'current': str(new_root), + 'summary': dict(counts), + 'exit_code': exit_code, + 'files': [_file_entry(rel, results[rel]) for rel in sorted(results)], + 'consistency': [{'model': m, 'message': msg} for m, msg in advisories], + } + if old_label: + doc['baseline_label'] = old_label + if new_label: + doc['current_label'] = new_label + return doc + + +def dumps(results, counts, old_root, new_root, exit_code, + old_label=None, new_label=None, advisories=()): + return json.dumps(build(results, counts, old_root, new_root, exit_code, + old_label, new_label, advisories), + indent=2, ensure_ascii=False) + + +def _sarif_result(rel, r): + status = r['status'] + if status == 'real-change': + if r.get('binary'): + text = 'Modified (binary)' + else: + n_real = sum(1 for h in r.get('hunks', []) if h['kind'] == 'real') + n_moved = sum(1 for h in r.get('hunks', []) if h['kind'] == 'moved') + text = 'Modified: {} hunk(s){}'.format( + n_real, ', {} moved'.format(n_moved) if n_moved else '') + elif status == 'error': + text = 'NOT compared -- treat as potentially changed: {}'.format( + '; '.join(r.get('notes', [])) or 'unknown') + else: + text = _SARIF_RULE_NAME[status] + return { + 'ruleId': status, + 'level': _SARIF_LEVEL[status], + 'message': {'text': text}, + 'locations': [{'physicalLocation': { + 'artifactLocation': {'uri': rel}}}], + } + + +def build_sarif(results): + """A SARIF 2.1.0 log with one result per file that needs action. + + Identical and noise-only files are not findings, so they are absent -- a + code-scanning surface should light up only what a reviewer has to look at. + """ + rules = [{'id': status, + 'name': _SARIF_RULE_NAME[status], + 'shortDescription': {'text': _SARIF_RULE_NAME[status]}} + for status in _SARIF_LEVEL] + findings = [_sarif_result(rel, results[rel]) for rel in sorted(results) + if results[rel]['status'] in _SARIF_LEVEL] + return { + 'version': '2.1.0', + '$schema': 'https://json.schemastore.org/sarif-2.1.0.json', + 'runs': [{ + 'tool': {'driver': { + 'name': 'codegen-compare-tool', + 'version': __version__, + 'informationUri': 'https://github.com/longvo92/codegen-compare-tool', + 'rules': rules, + }}, + 'results': findings, + }], + } + + +def dumps_sarif(results): + return json.dumps(build_sarif(results), indent=2, ensure_ascii=False) diff --git a/docs/architecture.md b/docs/architecture.md index a50f0b1..f844bb5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -99,6 +99,7 @@ compare_tool/ ├── 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 and generated C did not change together (heads-up only, never a verdict) +├── serialize.py # machine-readable output of a scan: schema-versioned JSON (the whole record) and SARIF 2.1.0 (the files needing action) for a pipeline ├── review.py # reviewer notes and sign-offs, keyed by change content so they survive a rescan ├── gitsource.py # read-only `git archive` of a commit into a temp folder, so a commit can be the OLD side ├── zipsource.py # read-only unpack of a .zip artifact into a temp folder, so a zip can be either side diff --git a/docs/usage.md b/docs/usage.md index 2774644..3c07c37 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -332,6 +332,33 @@ python -m compare_tool "$OLD_DIR" "$NEW_DIR" \ See [azure-pipelines.yml](../azure-pipelines.yml) for a working example (OLD checked out via `git worktree`, NEW is the working tree). +### Machine-readable output + +The HTML report is for a human and the exit code is for a gate. For a build that +wants to read *what* changed — annotate a pull request, feed a dashboard, drive +its own policy — write the result as data: + +```bash +python -m compare_tool old_dir new_dir --json result.json --sarif result.sarif +``` + +Both are additive: the HTML report is still written. Either can be given alone. + +- `--json` writes the whole scan under a versioned `schema`: every file's + verdict, its hunks, renames and AUTOSAR extras, the run summary, the + consistency advisories, and the same `exit_code` the process returns (so the + file and `$?` cannot disagree). Pin `schema` and an internal refactor will not + move the shape under you. +- `--sarif` writes a [SARIF 2.1.0](https://sarifweb.azurewebsites.net/) log of + only the files that need action — modified, added, deleted, error — each with + a level (`error` for a path that could not be compared, `warning` otherwise). + Upload it to GitHub code scanning or Azure DevOps to see the changes annotated + inline on the pull request. Identical and noise-only files are not findings + and are left out. + +A write that fails is loud: like a missing HTML report, it exits `2` — a +pipeline that asked for the file must not proceed as if it got one. + ## Single-file build ```powershell diff --git a/tests/test_serialize.py b/tests/test_serialize.py new file mode 100644 index 0000000..869a76a --- /dev/null +++ b/tests/test_serialize.py @@ -0,0 +1,134 @@ +"""Machine-readable JSON / SARIF output.""" + +import contextlib +import io +import json +import tempfile +import unittest +from pathlib import Path + +from compare_tool import serialize +from compare_tool.main import main + + +def _r(status, **extra): + base = {'status': status, 'binary': False, 'notes': [], 'renames': {}, + 'hunks': []} + base.update(extra) + return base + + +_RESULTS = { + 'a.c': _r('real-change', + hunks=[{'kind': 'real', 'old_range': [1, 2], 'new_range': [1, 2]}, + {'kind': 'moved', 'old_range': [5, 7], 'new_range': [5, 5], + 'moved_to': 20}]), + 'b.c': _r('identical'), + 'c.arxml': _r('added', ifaces={'added': [['/Pkg/If', 'SENDER-RECEIVER-INTERFACE']], + 'removed': []}), + 'd.dat': _r('error', notes=['boom']), +} +_COUNTS = {'identical': 1, 'comment-only': 0, 'ignorable-only': 0, + 'real-change': 1, 'added': 1, 'deleted': 0, 'error': 1} + + +class TestJson(unittest.TestCase): + def _doc(self, exit_code=2): + return serialize.build(_RESULTS, _COUNTS, 'old', 'new', exit_code, + advisories=[('Ctrl', 'C changed, ARXML did not')]) + + def test_round_trips_as_json(self): + text = serialize.dumps(_RESULTS, _COUNTS, 'old', 'new', 1) + doc = json.loads(text) + self.assertEqual(doc['schema'], serialize.SCHEMA) + self.assertEqual(doc['tool'], 'codegen-compare-tool') + self.assertEqual(doc['exit_code'], 1) + + def test_every_file_present_and_sorted(self): + doc = self._doc() + paths = [f['path'] for f in doc['files']] + self.assertEqual(paths, sorted(_RESULTS)) + + def test_identical_file_carries_no_hunks_key(self): + doc = self._doc() + b = next(f for f in doc['files'] if f['path'] == 'b.c') + self.assertEqual(b['status'], 'identical') + self.assertNotIn('hunks', b) + + def test_hunks_and_move_serialised(self): + doc = self._doc() + a = next(f for f in doc['files'] if f['path'] == 'a.c') + self.assertEqual([h['kind'] for h in a['hunks']], ['real', 'moved']) + self.assertEqual(a['hunks'][1]['moved_to'], 20) + + def test_semantic_extra_passed_through(self): + doc = self._doc() + c = next(f for f in doc['files'] if f['path'] == 'c.arxml') + self.assertEqual(c['ifaces']['added'][0][1], 'SENDER-RECEIVER-INTERFACE') + + def test_summary_and_advisories(self): + doc = self._doc() + self.assertEqual(doc['summary']['real-change'], 1) + self.assertEqual(doc['consistency'][0]['model'], 'Ctrl') + + +class TestSarif(unittest.TestCase): + def test_only_actionable_files_are_findings(self): + log = serialize.build_sarif(_RESULTS) + results = log['runs'][0]['results'] + uris = sorted(r['locations'][0]['physicalLocation']['artifactLocation']['uri'] + for r in results) + # b.c (identical) is not a finding + self.assertEqual(uris, ['a.c', 'c.arxml', 'd.dat']) + + def test_error_is_error_level_change_is_warning(self): + log = serialize.build_sarif(_RESULTS) + by_uri = {r['locations'][0]['physicalLocation']['artifactLocation']['uri']: + r['level'] for r in log['runs'][0]['results']} + self.assertEqual(by_uri['d.dat'], 'error') + self.assertEqual(by_uri['a.c'], 'warning') + + def test_valid_sarif_envelope(self): + log = serialize.build_sarif(_RESULTS) + self.assertEqual(log['version'], '2.1.0') + self.assertIn('rules', log['runs'][0]['tool']['driver']) + json.dumps(log) # must be serialisable + + +class TestCliWritesMachineOutput(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + root = Path(self.tmp.name) + self.old = root / 'old' + self.new = root / 'new' + self.old.mkdir() + self.new.mkdir() + (self.old / 'm.c').write_text('int x = 1;\n', encoding='utf-8') + (self.new / 'm.c').write_text('int x = 2;\n', encoding='utf-8') + self.report = root / 'r.html' + self.json = root / 'out.json' + self.sarif = root / 'out.sarif' + + def tearDown(self): + self.tmp.cleanup() + + def _run(self, *extra): + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + rc = main([str(self.old), str(self.new), '--report', str(self.report), + *extra]) + return rc, buf.getvalue() + + def test_json_and_sarif_written_with_matching_exit_code(self): + rc, out = self._run('--json', str(self.json), '--sarif', str(self.sarif)) + self.assertEqual(rc, 1) # a real change + doc = json.loads(self.json.read_text(encoding='utf-8')) + self.assertEqual(doc['exit_code'], 1) + self.assertEqual(doc['summary']['real-change'], 1) + log = json.loads(self.sarif.read_text(encoding='utf-8')) + self.assertEqual(log['runs'][0]['results'][0]['ruleId'], 'real-change') + self.assertIn('JSON written', out) + + +if __name__ == '__main__': + unittest.main() From 4b5742157439ab2c7432584b1c4acb08a2554d32 Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 22:04:54 +0700 Subject: [PATCH 5/9] test: add a demo tree for the reorder, consistency and JSON/SARIF features fixtures/demo is a before/after pair a human runs to see the three new features at once, and test_demo.py asserts each claim so the demo cannot drift: - SpeedCtrl -- three independent gains re-emitted in a different order fold to Unimportant (reorder), proving the values are unchanged. - TorqueLimiter -- C regenerated (gain 1.25 -> 1.45) while the ARXML stayed identical, so the consistency check flags it; nothing else does. - PedalMap -- C and ARXML changed together (a new Scaled port) so no flag, and a new A2L characteristic is summarised without ever triggering one. README.md gives the run command and what to look for; the SARIF omits the Unimportant SpeedCtrl.c, so only the four actionable files are findings. --- tests/fixtures/demo/README.md | 26 +++++++ tests/fixtures/demo/new/PedalMap.a2l | 11 +++ tests/fixtures/demo/new/PedalMap.arxml | 23 ++++++ tests/fixtures/demo/new/PedalMap.c | 16 ++++ tests/fixtures/demo/new/SpeedCtrl.arxml | 23 ++++++ tests/fixtures/demo/new/SpeedCtrl.c | 33 ++++++++ tests/fixtures/demo/new/SpeedCtrl.h | 23 ++++++ tests/fixtures/demo/new/TorqueLimiter.arxml | 27 +++++++ tests/fixtures/demo/new/TorqueLimiter.c | 37 +++++++++ tests/fixtures/demo/old/PedalMap.a2l | 8 ++ tests/fixtures/demo/old/PedalMap.arxml | 19 +++++ tests/fixtures/demo/old/PedalMap.c | 16 ++++ tests/fixtures/demo/old/SpeedCtrl.arxml | 23 ++++++ tests/fixtures/demo/old/SpeedCtrl.c | 33 ++++++++ tests/fixtures/demo/old/SpeedCtrl.h | 23 ++++++ tests/fixtures/demo/old/TorqueLimiter.arxml | 27 +++++++ tests/fixtures/demo/old/TorqueLimiter.c | 37 +++++++++ tests/test_demo.py | 86 +++++++++++++++++++++ 18 files changed, 491 insertions(+) create mode 100644 tests/fixtures/demo/README.md create mode 100644 tests/fixtures/demo/new/PedalMap.a2l create mode 100644 tests/fixtures/demo/new/PedalMap.arxml create mode 100644 tests/fixtures/demo/new/PedalMap.c create mode 100644 tests/fixtures/demo/new/SpeedCtrl.arxml create mode 100644 tests/fixtures/demo/new/SpeedCtrl.c create mode 100644 tests/fixtures/demo/new/SpeedCtrl.h create mode 100644 tests/fixtures/demo/new/TorqueLimiter.arxml create mode 100644 tests/fixtures/demo/new/TorqueLimiter.c create mode 100644 tests/fixtures/demo/old/PedalMap.a2l create mode 100644 tests/fixtures/demo/old/PedalMap.arxml create mode 100644 tests/fixtures/demo/old/PedalMap.c create mode 100644 tests/fixtures/demo/old/SpeedCtrl.arxml create mode 100644 tests/fixtures/demo/old/SpeedCtrl.c create mode 100644 tests/fixtures/demo/old/SpeedCtrl.h create mode 100644 tests/fixtures/demo/old/TorqueLimiter.arxml create mode 100644 tests/fixtures/demo/old/TorqueLimiter.c create mode 100644 tests/test_demo.py diff --git a/tests/fixtures/demo/README.md b/tests/fixtures/demo/README.md new file mode 100644 index 0000000..38c506b --- /dev/null +++ b/tests/fixtures/demo/README.md @@ -0,0 +1,26 @@ +# Demo tree + +A small before/after pair that shows the three newest features. Run it and look +at the report and the terminal: + +```bash +python -m compare_tool tests/fixtures/demo/old tests/fixtures/demo/new \ + --report demo.html --json demo.json --sarif demo.sarif +``` + +Three models, each making one point: + +| Model | Files | What it shows | +|---|---|---| +| **SpeedCtrl** | `.c` `.h` `.arxml` | **Reordered statements are noise.** `SpeedCtrl.c` emits the same three independent gains in a different order (and a new timestamp). It is filed under **Unimportant**, not Modified — the values are identical, and the tool proves it before hiding it. | +| **TorqueLimiter** | `.c` `.arxml` | **Cross-artifact consistency.** The C changed (a gain went 1.25 → 1.45) but the ARXML is untouched — the report and the terminal flag *"generated C changed but its ARXML did not"*, the usual sign of a partial regenerate. | +| **PedalMap** | `.c` `.arxml` `.a2l` | **The healthy case, plus machine output.** The C and the ARXML changed together (a new `Scaled` port), so no consistency flag. A new A2L characteristic (`K_PedalOffset`) is summarised but never triggers a flag on its own. | + +What the outputs carry: + +- **`demo.html`** — the human report: a *Consistency check* section names TorqueLimiter, and `SpeedCtrl.c` sits under Unimportant with its rows greyed until you click. +- **`demo.json`** — the whole scan under a versioned schema, including the same exit code the process returns. +- **`demo.sarif`** — only the files that need action (TorqueLimiter.c, PedalMap.c, PedalMap.arxml, PedalMap.a2l). `SpeedCtrl.c` is Unimportant, so it is *not* a finding. + +`test_demo.py` asserts every one of these claims, so the demo cannot drift out of +step with what it says it does. diff --git a/tests/fixtures/demo/new/PedalMap.a2l b/tests/fixtures/demo/new/PedalMap.a2l new file mode 100644 index 0000000..3eb28fa --- /dev/null +++ b/tests/fixtures/demo/new/PedalMap.a2l @@ -0,0 +1,11 @@ +ASAP2_VERSION 1 71 +/begin PROJECT PedalMap "" + /begin MODULE CAL "" + /begin CHARACTERISTIC K_PedalGain "pedal scaling factor" + VALUE 0x8000 __UBYTE_Z 0 IDENTICAL 0 100 + /end CHARACTERISTIC + /begin CHARACTERISTIC K_PedalOffset "pedal zero offset" + VALUE 0x8010 __UBYTE_Z 0 IDENTICAL 0 50 + /end CHARACTERISTIC + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/new/PedalMap.arxml b/tests/fixtures/demo/new/PedalMap.arxml new file mode 100644 index 0000000..8fd3fe6 --- /dev/null +++ b/tests/fixtures/demo/new/PedalMap.arxml @@ -0,0 +1,23 @@ + + + + + Powertrain + + + PedalMap + + + Raw + /Powertrain/If_Raw + + + Scaled + /Powertrain/If_Scaled + + + + + + + diff --git a/tests/fixtures/demo/new/PedalMap.c b/tests/fixtures/demo/new/PedalMap.c new file mode 100644 index 0000000..19a1814 --- /dev/null +++ b/tests/fixtures/demo/new/PedalMap.c @@ -0,0 +1,16 @@ +/* + * File: PedalMap.c + * Code generated for Simulink model 'PedalMap'. + * Model version : 4.11 + */ + +#include "PedalMap.h" +#include "rtwtypes.h" + +/* Scale the raw pedal reading into a normalised request. */ +void PedalMap_step(void) +{ + rtY.Scaled = rtU.Raw * K_PedalGain + K_PedalOffset; +} + +/* [EOF] */ diff --git a/tests/fixtures/demo/new/SpeedCtrl.arxml b/tests/fixtures/demo/new/SpeedCtrl.arxml new file mode 100644 index 0000000..0b94c4d --- /dev/null +++ b/tests/fixtures/demo/new/SpeedCtrl.arxml @@ -0,0 +1,23 @@ + + + + + Powertrain + + + SpeedCtrl + + + Torque + /Powertrain/If_Torque + + + SpeedRequest + /Powertrain/If_Speed + + + + + + + diff --git a/tests/fixtures/demo/new/SpeedCtrl.c b/tests/fixtures/demo/new/SpeedCtrl.c new file mode 100644 index 0000000..c07e32c --- /dev/null +++ b/tests/fixtures/demo/new/SpeedCtrl.c @@ -0,0 +1,33 @@ +/* + * File: SpeedCtrl.c + * + * Code generated for Simulink model 'SpeedCtrl'. + * + * Model version : 2.32 + * Simulink Coder version : 9.9 (R2023a) + * C/C++ source code generated on : Thu Aug 14 17:26:44 2026 + * + * Target selection: autosar.tlc + */ + +#include "SpeedCtrl.h" +#include "rtwtypes.h" + +ExtU_SpeedCtrl_T rtU; +ExtY_SpeedCtrl_T rtY; + +/* Blend three independent driver inputs into one speed request. */ +void SpeedCtrl_step(void) +{ + real_T gainTorque; + real_T gainSpeed; + real_T gainPedal; + + gainPedal = rtU.Pedal * 2.00; + gainSpeed = rtU.Speed * 0.90; + gainTorque = rtU.Torque * 1.10; + + rtY.SpeedRequest = gainTorque + gainSpeed + gainPedal; +} + +/* [EOF] */ diff --git a/tests/fixtures/demo/new/SpeedCtrl.h b/tests/fixtures/demo/new/SpeedCtrl.h new file mode 100644 index 0000000..7b537ee --- /dev/null +++ b/tests/fixtures/demo/new/SpeedCtrl.h @@ -0,0 +1,23 @@ +/* + * File: SpeedCtrl.h + * Code generated for Simulink model 'SpeedCtrl'. + */ + +#ifndef RTW_HEADER_SpeedCtrl_h_ +#define RTW_HEADER_SpeedCtrl_h_ + +#include "rtwtypes.h" + +typedef struct { + real_T Torque; + real_T Speed; + real_T Pedal; +} ExtU_SpeedCtrl_T; + +typedef struct { + real_T SpeedRequest; +} ExtY_SpeedCtrl_T; + +extern void SpeedCtrl_step(void); + +#endif diff --git a/tests/fixtures/demo/new/TorqueLimiter.arxml b/tests/fixtures/demo/new/TorqueLimiter.arxml new file mode 100644 index 0000000..6e915af --- /dev/null +++ b/tests/fixtures/demo/new/TorqueLimiter.arxml @@ -0,0 +1,27 @@ + + + + + Powertrain + + + TorqueLimiter + + + PedalPosition + /Powertrain/If_Pedal + + + MotorSpeed + /Powertrain/If_Speed + + + TorqueCmd + /Powertrain/If_Torque + + + + + + + diff --git a/tests/fixtures/demo/new/TorqueLimiter.c b/tests/fixtures/demo/new/TorqueLimiter.c new file mode 100644 index 0000000..97fe259 --- /dev/null +++ b/tests/fixtures/demo/new/TorqueLimiter.c @@ -0,0 +1,37 @@ +/* + * File: TorqueLimiter.c + * + * Code generated for Simulink model 'TorqueLimiter'. + * + * Model version : 1.152 + * Simulink Coder version : 9.9 (R2023a) + * C/C++ source code generated on : Thu Aug 14 17:26:44 2026 + * + * Target selection: autosar.tlc + */ + +#include "TorqueLimiter.h" +#include "rtwtypes.h" + +extern real_T TorqueLimiter_LookupTorque(real_T speed); + +/* Model step function -- runs every 10 ms. */ +void Rte_Runnable_TorqueLimiter_Step(void) +{ + real_T rtb_Request; + real_T rtb_Ceiling; + + rtb_Request = rtU.PedalPosition * 300.0; + rtb_Ceiling = TorqueLimiter_LookupTorque(rtU.MotorSpeed); + + /* Gain: apply the driveability scaling factor to the raw request */ + rtb_Request = rtb_Request * 1.45; + + if (rtb_Request > rtb_Ceiling) { + rtb_Request = rtb_Ceiling; + } + + rtY.TorqueCmd = rtb_Request; +} + +/* [EOF] */ diff --git a/tests/fixtures/demo/old/PedalMap.a2l b/tests/fixtures/demo/old/PedalMap.a2l new file mode 100644 index 0000000..f8ee76d --- /dev/null +++ b/tests/fixtures/demo/old/PedalMap.a2l @@ -0,0 +1,8 @@ +ASAP2_VERSION 1 71 +/begin PROJECT PedalMap "" + /begin MODULE CAL "" + /begin CHARACTERISTIC K_PedalGain "pedal scaling factor" + VALUE 0x8000 __UBYTE_Z 0 IDENTICAL 0 100 + /end CHARACTERISTIC + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/old/PedalMap.arxml b/tests/fixtures/demo/old/PedalMap.arxml new file mode 100644 index 0000000..65232b2 --- /dev/null +++ b/tests/fixtures/demo/old/PedalMap.arxml @@ -0,0 +1,19 @@ + + + + + Powertrain + + + PedalMap + + + Raw + /Powertrain/If_Raw + + + + + + + diff --git a/tests/fixtures/demo/old/PedalMap.c b/tests/fixtures/demo/old/PedalMap.c new file mode 100644 index 0000000..9d1075f --- /dev/null +++ b/tests/fixtures/demo/old/PedalMap.c @@ -0,0 +1,16 @@ +/* + * File: PedalMap.c + * Code generated for Simulink model 'PedalMap'. + * Model version : 4.07 + */ + +#include "PedalMap.h" +#include "rtwtypes.h" + +/* Scale the raw pedal reading into a normalised request. */ +void PedalMap_step(void) +{ + rtY.Scaled = rtU.Raw * K_PedalGain; +} + +/* [EOF] */ diff --git a/tests/fixtures/demo/old/SpeedCtrl.arxml b/tests/fixtures/demo/old/SpeedCtrl.arxml new file mode 100644 index 0000000..0b94c4d --- /dev/null +++ b/tests/fixtures/demo/old/SpeedCtrl.arxml @@ -0,0 +1,23 @@ + + + + + Powertrain + + + SpeedCtrl + + + Torque + /Powertrain/If_Torque + + + SpeedRequest + /Powertrain/If_Speed + + + + + + + diff --git a/tests/fixtures/demo/old/SpeedCtrl.c b/tests/fixtures/demo/old/SpeedCtrl.c new file mode 100644 index 0000000..e6401a1 --- /dev/null +++ b/tests/fixtures/demo/old/SpeedCtrl.c @@ -0,0 +1,33 @@ +/* + * File: SpeedCtrl.c + * + * Code generated for Simulink model 'SpeedCtrl'. + * + * Model version : 2.31 + * Simulink Coder version : 9.9 (R2023a) + * C/C++ source code generated on : Mon Aug 11 08:02:15 2026 + * + * Target selection: autosar.tlc + */ + +#include "SpeedCtrl.h" +#include "rtwtypes.h" + +ExtU_SpeedCtrl_T rtU; +ExtY_SpeedCtrl_T rtY; + +/* Blend three independent driver inputs into one speed request. */ +void SpeedCtrl_step(void) +{ + real_T gainTorque; + real_T gainSpeed; + real_T gainPedal; + + gainTorque = rtU.Torque * 1.10; + gainSpeed = rtU.Speed * 0.90; + gainPedal = rtU.Pedal * 2.00; + + rtY.SpeedRequest = gainTorque + gainSpeed + gainPedal; +} + +/* [EOF] */ diff --git a/tests/fixtures/demo/old/SpeedCtrl.h b/tests/fixtures/demo/old/SpeedCtrl.h new file mode 100644 index 0000000..7b537ee --- /dev/null +++ b/tests/fixtures/demo/old/SpeedCtrl.h @@ -0,0 +1,23 @@ +/* + * File: SpeedCtrl.h + * Code generated for Simulink model 'SpeedCtrl'. + */ + +#ifndef RTW_HEADER_SpeedCtrl_h_ +#define RTW_HEADER_SpeedCtrl_h_ + +#include "rtwtypes.h" + +typedef struct { + real_T Torque; + real_T Speed; + real_T Pedal; +} ExtU_SpeedCtrl_T; + +typedef struct { + real_T SpeedRequest; +} ExtY_SpeedCtrl_T; + +extern void SpeedCtrl_step(void); + +#endif diff --git a/tests/fixtures/demo/old/TorqueLimiter.arxml b/tests/fixtures/demo/old/TorqueLimiter.arxml new file mode 100644 index 0000000..6e915af --- /dev/null +++ b/tests/fixtures/demo/old/TorqueLimiter.arxml @@ -0,0 +1,27 @@ + + + + + Powertrain + + + TorqueLimiter + + + PedalPosition + /Powertrain/If_Pedal + + + MotorSpeed + /Powertrain/If_Speed + + + TorqueCmd + /Powertrain/If_Torque + + + + + + + diff --git a/tests/fixtures/demo/old/TorqueLimiter.c b/tests/fixtures/demo/old/TorqueLimiter.c new file mode 100644 index 0000000..f9becb8 --- /dev/null +++ b/tests/fixtures/demo/old/TorqueLimiter.c @@ -0,0 +1,37 @@ +/* + * File: TorqueLimiter.c + * + * Code generated for Simulink model 'TorqueLimiter'. + * + * Model version : 1.148 + * Simulink Coder version : 9.9 (R2023a) + * C/C++ source code generated on : Mon Aug 11 09:14:22 2026 + * + * Target selection: autosar.tlc + */ + +#include "TorqueLimiter.h" +#include "rtwtypes.h" + +extern real_T TorqueLimiter_LookupTorque(real_T speed); + +/* Model step function -- runs every 10 ms. */ +void Rte_Runnable_TorqueLimiter_Step(void) +{ + real_T rtb_Request; + real_T rtb_Ceiling; + + rtb_Request = rtU.PedalPosition * 300.0; + rtb_Ceiling = TorqueLimiter_LookupTorque(rtU.MotorSpeed); + + /* Gain: apply the driveability scaling factor to the raw request */ + rtb_Request = rtb_Request * 1.25; + + if (rtb_Request > rtb_Ceiling) { + rtb_Request = rtb_Ceiling; + } + + rtY.TorqueCmd = rtb_Request; +} + +/* [EOF] */ diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..7b07cdb --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,86 @@ +"""The demo tree under fixtures/demo, and the three features it shows. + +`fixtures/demo/old` vs `fixtures/demo/new` is the folder pair a human runs to +see the new features (see fixtures/demo/README.md). These tests lock what it +claims, so the demo can never quietly stop demonstrating what it says it does. +""" + +import json +import unittest +from pathlib import Path + +from compare_tool import serialize +from compare_tool.report import consistency_advisories +from compare_tool.scanner import scan, summarize_a2l, summarize_swcs + +DEMO = Path(__file__).parent / 'fixtures' / 'demo' + + +def _kinds(r): + return {h['kind'] for h in r['hunks']} + + +class TestDemoTree(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.res = scan(str(DEMO / 'old'), str(DEMO / 'new')) + + # --- feature 1: provably-safe statement reorder folds to noise --- + + def test_reorder_folds_speedctrl_to_unimportant(self): + r = self.res['SpeedCtrl.c'] + self.assertEqual(r['status'], 'ignorable-only') + self.assertIn('reorder', _kinds(r)) + self.assertNotIn('real', _kinds(r)) + + # --- feature 4: cross-artifact consistency advisory --- + + def test_consistency_flags_only_the_desynced_model(self): + adv = consistency_advisories(self.res) + models = [m for m, _msg in adv] + # TorqueLimiter.c changed while its ARXML stayed identical -> flagged. + # SpeedCtrl has no real C change; PedalMap changed both sides -> quiet. + self.assertEqual(models, ['TorqueLimiter']) + + def test_the_desync_verdicts_are_what_drive_the_flag(self): + self.assertEqual(self.res['TorqueLimiter.c']['status'], 'real-change') + self.assertEqual(self.res['TorqueLimiter.arxml']['status'], 'identical') + # the healthy model: both sides really changed + self.assertEqual(self.res['PedalMap.c']['status'], 'real-change') + self.assertEqual(self.res['PedalMap.arxml']['status'], 'real-change') + + def test_a2l_change_alone_never_flags_a_model(self): + # PedalMap.a2l added a characteristic; that never makes a consistency + # advisory on its own -- calibration is not paired with the code + added, _removed = summarize_a2l(self.res) + self.assertIn(('PedalMap.a2l', 'K_PedalOffset', 'CHARACTERISTIC'), added) + self.assertNotIn('PedalMap', [m for m, _ in consistency_advisories(self.res)]) + + def test_autosar_summary_sees_the_new_port(self): + swc = summarize_swcs(self.res) + added = [(rel, name) for rel, _swc, name, _desc in swc['ports']['added']] + self.assertIn(('PedalMap.arxml', 'Scaled'), added) + + # --- feature 5: machine-readable output --- + + def test_sarif_lists_only_actionable_files(self): + log = serialize.build_sarif(self.res) + uris = {r['locations'][0]['physicalLocation']['artifactLocation']['uri'] + for r in log['runs'][0]['results']} + # the reordered file is Unimportant, so it is NOT a finding + self.assertNotIn('SpeedCtrl.c', uris) + self.assertEqual(uris, {'TorqueLimiter.c', 'PedalMap.c', + 'PedalMap.arxml', 'PedalMap.a2l'}) + + def test_json_round_trips_and_carries_the_reorder(self): + counts = {k: 0 for k in ('identical', 'comment-only', 'ignorable-only', + 'real-change', 'added', 'deleted', 'error')} + text = serialize.dumps(self.res, counts, 'old', 'new', 1) + doc = json.loads(text) + speed = next(f for f in doc['files'] if f['path'] == 'SpeedCtrl.c') + self.assertEqual(speed['status'], 'ignorable-only') + self.assertIn('reorder', {h['kind'] for h in speed['hunks']}) + + +if __name__ == '__main__': + unittest.main() From 2e41bc533ce5050955a1ee1959dff628cf75eb71 Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 22:29:01 +0700 Subject: [PATCH 6/9] fix(consistency): flag surface changes the code did not follow The direction was backwards. Flagging 'C changed but ARXML did not' fired on the ordinary case -- an internal logic or gain edit touches no interface, so the code legitimately moves alone. The real desync is the reverse: an ARXML (interface) or A2L (calibration) change with the generated C left identical -- a new port or characteristic the code never picked up, the sign of a stale regenerate. Now flag a model only when a surface (ARXML or A2L) really changed while its C did not; a code-only change is never flagged. A2L is included, and both surfaces collapse into one message. Moved the report section below the AUTOSAR changes and dropped the long caption. Demo gains a StaleGen model for the flag and keeps TorqueLimiter as the code-only case that must stay quiet. --- CHANGELOG.md | 11 ++-- compare_tool/consistency.py | 62 +++++++++--------- compare_tool/report.py | 15 ++--- docs/architecture.md | 2 +- docs/usage.md | 29 ++++---- tests/fixtures/demo/README.md | 11 ++-- tests/fixtures/demo/new/StaleGen.a2l | 11 ++++ tests/fixtures/demo/new/StaleGen.arxml | 23 +++++++ tests/fixtures/demo/new/StaleGen.c | 16 +++++ tests/fixtures/demo/old/StaleGen.a2l | 8 +++ tests/fixtures/demo/old/StaleGen.arxml | 19 ++++++ tests/fixtures/demo/old/StaleGen.c | 16 +++++ tests/test_consistency.py | 91 +++++++++++++++----------- tests/test_demo.py | 50 ++++++++------ 14 files changed, 246 insertions(+), 118 deletions(-) create mode 100644 tests/fixtures/demo/new/StaleGen.a2l create mode 100644 tests/fixtures/demo/new/StaleGen.arxml create mode 100644 tests/fixtures/demo/new/StaleGen.c create mode 100644 tests/fixtures/demo/old/StaleGen.a2l create mode 100644 tests/fixtures/demo/old/StaleGen.arxml create mode 100644 tests/fixtures/demo/old/StaleGen.c diff --git a/CHANGELOG.md b/CHANGELOG.md index bff23ca..717e394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,11 +12,12 @@ All notable changes to this project are documented here. Versions follow 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 when a model's ARXML and C did not regenerate together.** The report - and the terminal now point out a model whose generated C changed while its - ARXML did not, or the reverse — the usual sign of a partial or stale - regenerate. It is a heads-up only: it never changes a file's verdict or the - exit code. +- **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. - **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 diff --git a/compare_tool/consistency.py b/compare_tool/consistency.py index 795e2fb..8ab42a7 100644 --- a/compare_tool/consistency.py +++ b/compare_tool/consistency.py @@ -1,24 +1,22 @@ """Cross-artifact consistency advisories. -A model's ARXML (its contract) and its generated C (its behaviour) are produced -by the same regenerate and are expected to move together. When one is -regenerated and the other is not, the folder holds a *mix* that the per-file -diff cannot point at: each file is individually fine — Modified, or Identical — -and the inconsistency lives strictly *between* them. That is the one thing a -file-by-file view structurally cannot show, and the everyday cause is a partial -or stale regenerate. +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. -This is an **advisory, never a verdict**. Absence of a partner change can be -perfectly legitimate — a hand-written file kept beside generated ones, an ARXML -edited on its own, a symbol defined in another folder — so it must not fold a -file, move a count, or change the exit code. It says "worth a look", not -"wrong". The claim the rest of the tool makes ("you can ignore what I hid") is -never put at risk by a guess, because this makes no claim about noise at all: it -only reports which artifact families of a model carry a change the tool already -stands behind. +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. -Only the C <-> ARXML pair is checked. A2L (calibration) legitimately changes on -its own — a recal touches no code — so pairing it here would cry wolf. +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. """ @@ -28,7 +26,10 @@ # 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') +_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): @@ -49,26 +50,27 @@ def _families(rels, results): def model_advisories(groups, results, shared_group=None): - """``[(model, message)]`` for models whose generated C and ARXML did not - change together. + """``[(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. Only models that have BOTH a C and an ARXML file in the compare are - judged — with only one family present there is no partner to be out of step - with. Sorted by model name for a stable report and CLI. + 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'] and present['arxml']): + if not present['c'] or changed['c']: + # no code to have followed, or the code changed too -- both fine continue - if changed['c'] and not changed['arxml']: - out.append((model, 'generated C changed but its ARXML did not — ' - 'check the model was fully regenerated')) - elif changed['arxml'] and not changed['c']: - out.append((model, 'ARXML changed but its generated C did not — ' - 'the code may not have been regenerated')) + surfaces = [label for fam, label in _SURFACES + if present[fam] and changed[fam]] + if surfaces: + out.append((model, '{} changed but the generated C did not' + .format(' and '.join(surfaces)))) return out diff --git a/compare_tool/report.py b/compare_tool/report.py index 75d563d..b2df40f 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -1002,17 +1002,13 @@ def consistency_advisories(results): def _consistency_html(advisories): """The cross-artifact advisory block, or '' when there is nothing to say. - Rendered as a caution, not an error: it never counts toward the verdict.""" + A caution, not a verdict: it never counts toward the summary or exit code.""" if not advisories: return '' rows = ['
⚠ {} — {}
' .format(_esc(model), _esc(msg)) for model, msg in advisories] - return ('

Consistency check

' - '
A model\'s ARXML and generated C are expected ' - 'to regenerate together. These changed on their own — a heads-up, ' - 'not a verdict: a file kept elsewhere or an ARXML-only edit can be ' - 'perfectly fine.
{}
' - .format(''.join(rows))) + return '

Consistency check

{}
'.format( + ''.join(rows)) def _agg_status(node, results): @@ -1627,9 +1623,12 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None, ''.format(**counts) + rev_group + '') if groups: parts.append(_overview_table(groups, results, model_anchors)) + parts.append(_autosar_section(results, anchors)) + if groups: + # below the AUTOSAR changes: it reads them (a surface that moved) against + # the code, so it belongs after the reader has seen what moved parts.append(_consistency_html( consistency.model_advisories(groups, results, SHARED_GROUP))) - parts.append(_autosar_section(results, anchors)) if results: parts.append('

Folder tree

' diff --git a/docs/architecture.md b/docs/architecture.md index f844bb5..e7793b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 and generated C did not change together (heads-up only, never a verdict) +├── consistency.py # cross-artifact advisory: a model whose ARXML/A2L really changed but whose generated C did not follow (heads-up only, never a verdict) ├── serialize.py # machine-readable output of a scan: schema-versioned JSON (the whole record) and SARIF 2.1.0 (the files needing action) for a pipeline ├── review.py # reviewer notes and sign-offs, keyed by change content so they survive a rescan ├── gitsource.py # read-only `git archive` of a commit into a temp folder, so a commit can be the OLD side diff --git a/docs/usage.md b/docs/usage.md index 3c07c37..09e1022 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -264,18 +264,23 @@ set, …). Files that match no model land in a final **Shared / other** group. ## Consistency check -A model's ARXML (its contract) and its generated C (its behaviour) come out of -the same regenerate, so they are expected to change together. When one moved and -the other did not — the C is Modified but its ARXML is Identical, or the reverse -— the report and the terminal say so, per model. It is the one thing a -file-by-file view cannot show: each file is individually fine, and the mismatch -lives *between* them. The usual cause is a partial or stale regenerate. - -This is a **heads-up, not a verdict**. It never folds a file, moves a count, or -changes the exit code, because a partner change can be legitimately absent — a -hand-written file kept beside generated ones, or an ARXML edited on its own. -A2L is deliberately not paired here: a recalibration touches no code, so -flagging it would cry wolf. +A model's ARXML is its contract and its A2L is its calibration surface; both are +realised by the generated C. So the dependency runs **one way**: if the +interface or the calibration really changed, the code must have changed too — a +new port needs a new RTE access, a new characteristic needs a new symbol. When +an ARXML or A2L change lands with the generated C left identical, the report +(below the AUTOSAR changes) and the terminal flag the model — the usual sign of +a stale or partial regenerate, and the one thing a file-by-file view cannot +show, because each file is individually fine and the mismatch lives *between* +them. + +The reverse is **not** flagged. Code that changed while the ARXML and A2L did +not is the ordinary case — an internal logic or gain edit touches no interface +and no calibration variable, so there is nothing for them to follow. + +It is a **heads-up, not a verdict**: it never folds a file, moves a count, or +changes the exit code. Only a *real* surface change counts — an ARXML that +merely churned UUIDs did not really change, so a stale C is not a desync. ## HTML report diff --git a/tests/fixtures/demo/README.md b/tests/fixtures/demo/README.md index 38c506b..8b50d33 100644 --- a/tests/fixtures/demo/README.md +++ b/tests/fixtures/demo/README.md @@ -8,19 +8,20 @@ python -m compare_tool tests/fixtures/demo/old tests/fixtures/demo/new \ --report demo.html --json demo.json --sarif demo.sarif ``` -Three models, each making one point: +Four models, each making one point: | Model | Files | What it shows | |---|---|---| | **SpeedCtrl** | `.c` `.h` `.arxml` | **Reordered statements are noise.** `SpeedCtrl.c` emits the same three independent gains in a different order (and a new timestamp). It is filed under **Unimportant**, not Modified — the values are identical, and the tool proves it before hiding it. | -| **TorqueLimiter** | `.c` `.arxml` | **Cross-artifact consistency.** The C changed (a gain went 1.25 → 1.45) but the ARXML is untouched — the report and the terminal flag *"generated C changed but its ARXML did not"*, the usual sign of a partial regenerate. | -| **PedalMap** | `.c` `.arxml` `.a2l` | **The healthy case, plus machine output.** The C and the ARXML changed together (a new `Scaled` port), so no consistency flag. A new A2L characteristic (`K_PedalOffset`) is summarised but never triggers a flag on its own. | +| **StaleGen** | `.c` `.arxml` `.a2l` | **Cross-artifact consistency.** The ARXML gained a port and the A2L gained a characteristic, but the C is byte-for-byte unchanged — the interface and calibration moved without the code. The report and the terminal flag *"ARXML and A2L changed but the generated C did not"*, the usual sign of a stale regenerate. | +| **TorqueLimiter** | `.c` `.arxml` | **A code-only change is normal.** The C changed (a gain went 1.25 → 1.45) while the ARXML did not. A logic edit touches no interface, so this is **not** flagged — the check only fires when a surface changed without the code following. | +| **PedalMap** | `.c` `.arxml` `.a2l` | **The healthy case, plus machine output.** The C, the ARXML (a new `Scaled` port) and the A2L (a new `K_PedalOffset`) all changed together, so no flag — and the AUTOSAR summary lists the new port and characteristic. | What the outputs carry: -- **`demo.html`** — the human report: a *Consistency check* section names TorqueLimiter, and `SpeedCtrl.c` sits under Unimportant with its rows greyed until you click. +- **`demo.html`** — the human report: a *Consistency check* section (below the AUTOSAR changes) names StaleGen, and `SpeedCtrl.c` sits under Unimportant with its rows greyed until you click. - **`demo.json`** — the whole scan under a versioned schema, including the same exit code the process returns. -- **`demo.sarif`** — only the files that need action (TorqueLimiter.c, PedalMap.c, PedalMap.arxml, PedalMap.a2l). `SpeedCtrl.c` is Unimportant, so it is *not* a finding. +- **`demo.sarif`** — only the files that need action. `SpeedCtrl.c` (Unimportant) and `StaleGen.c` (identical) are *not* findings. `test_demo.py` asserts every one of these claims, so the demo cannot drift out of step with what it says it does. diff --git a/tests/fixtures/demo/new/StaleGen.a2l b/tests/fixtures/demo/new/StaleGen.a2l new file mode 100644 index 0000000..2c40998 --- /dev/null +++ b/tests/fixtures/demo/new/StaleGen.a2l @@ -0,0 +1,11 @@ +ASAP2_VERSION 1 71 +/begin PROJECT StaleGen "" + /begin MODULE CAL "" + /begin CHARACTERISTIC K_Bias "output bias" + VALUE 0x9000 __UBYTE_Z 0 IDENTICAL 0 255 + /end CHARACTERISTIC + /begin CHARACTERISTIC K_Limit "output clamp limit" + VALUE 0x9010 __UBYTE_Z 0 IDENTICAL 0 255 + /end CHARACTERISTIC + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/new/StaleGen.arxml b/tests/fixtures/demo/new/StaleGen.arxml new file mode 100644 index 0000000..a9928f4 --- /dev/null +++ b/tests/fixtures/demo/new/StaleGen.arxml @@ -0,0 +1,23 @@ + + + + + Powertrain + + + StaleGen + + + In + /Powertrain/If_In + + + Status + /Powertrain/If_Status + + + + + + + diff --git a/tests/fixtures/demo/new/StaleGen.c b/tests/fixtures/demo/new/StaleGen.c new file mode 100644 index 0000000..64bd665 --- /dev/null +++ b/tests/fixtures/demo/new/StaleGen.c @@ -0,0 +1,16 @@ +/* + * File: StaleGen.c + * Code generated for Simulink model 'StaleGen'. + * Model version : 3.02 + */ + +#include "StaleGen.h" +#include "rtwtypes.h" + +/* Pass the input straight through -- unchanged this regenerate. */ +void StaleGen_step(void) +{ + rtY.Out = rtU.In; +} + +/* [EOF] */ diff --git a/tests/fixtures/demo/old/StaleGen.a2l b/tests/fixtures/demo/old/StaleGen.a2l new file mode 100644 index 0000000..5b1c034 --- /dev/null +++ b/tests/fixtures/demo/old/StaleGen.a2l @@ -0,0 +1,8 @@ +ASAP2_VERSION 1 71 +/begin PROJECT StaleGen "" + /begin MODULE CAL "" + /begin CHARACTERISTIC K_Bias "output bias" + VALUE 0x9000 __UBYTE_Z 0 IDENTICAL 0 255 + /end CHARACTERISTIC + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/old/StaleGen.arxml b/tests/fixtures/demo/old/StaleGen.arxml new file mode 100644 index 0000000..6db041d --- /dev/null +++ b/tests/fixtures/demo/old/StaleGen.arxml @@ -0,0 +1,19 @@ + + + + + Powertrain + + + StaleGen + + + In + /Powertrain/If_In + + + + + + + diff --git a/tests/fixtures/demo/old/StaleGen.c b/tests/fixtures/demo/old/StaleGen.c new file mode 100644 index 0000000..64bd665 --- /dev/null +++ b/tests/fixtures/demo/old/StaleGen.c @@ -0,0 +1,16 @@ +/* + * File: StaleGen.c + * Code generated for Simulink model 'StaleGen'. + * Model version : 3.02 + */ + +#include "StaleGen.h" +#include "rtwtypes.h" + +/* Pass the input straight through -- unchanged this regenerate. */ +void StaleGen_step(void) +{ + rtY.Out = rtU.In; +} + +/* [EOF] */ diff --git a/tests/test_consistency.py b/tests/test_consistency.py index 63f4380..9f70495 100644 --- a/tests/test_consistency.py +++ b/tests/test_consistency.py @@ -1,4 +1,9 @@ -"""Cross-artifact consistency advisories.""" +"""Cross-artifact consistency advisories. + +The rule runs one way: a real change to the interface (ARXML) or the +calibration surface (A2L) must be reflected in the generated C. A code-only +change is the ordinary case and is never flagged. +""" import unittest @@ -9,56 +14,66 @@ def _results(**status_by_rel): return {rel: {'status': st} for rel, st in status_by_rel.items()} +def _adv(groups, results, shared=None): + return consistency.model_advisories(groups, results, shared) + + class TestModelAdvisories(unittest.TestCase): - def test_c_changed_arxml_not(self): - results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'identical'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} - adv = consistency.model_advisories(groups, results) + def test_arxml_changed_c_not_is_flagged(self): + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'real-change'}) + adv = _adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results) self.assertEqual(len(adv), 1) self.assertEqual(adv[0][0], 'Ctrl') - self.assertIn('generated C changed but its ARXML did not', adv[0][1]) + self.assertEqual(adv[0][1], 'ARXML changed but the generated C did not') - def test_arxml_changed_c_not(self): - results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'real-change'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} - adv = consistency.model_advisories(groups, results) - self.assertEqual(len(adv), 1) - self.assertIn('ARXML changed but its generated C did not', adv[0][1]) + def test_a2l_changed_c_not_is_flagged(self): + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.a2l': 'real-change'}) + adv = _adv({'Ctrl': ['Ctrl.c', 'Ctrl.a2l']}, results) + self.assertEqual(adv[0][1], 'A2L changed but the generated C did not') + + def test_both_surfaces_changed_c_not_is_one_combined_flag(self): + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'real-change', + 'Ctrl.a2l': 'added'}) + adv = _adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml', 'Ctrl.a2l']}, results) + self.assertEqual(adv[0][1], + 'ARXML and A2L changed but the generated C did not') + + def test_code_only_change_is_not_flagged(self): + # the corrected direction: C changed, the surfaces did not -> normal + results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'identical', + 'Ctrl.a2l': 'identical'}) + self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml', 'Ctrl.a2l']}, + results), []) - def test_both_changed_is_quiet(self): - results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'added'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} - self.assertEqual(consistency.model_advisories(groups, results), []) + def test_both_changed_together_is_quiet(self): + results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.arxml': 'real-change'}) + self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results), []) def test_neither_changed_is_quiet(self): results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'ignorable-only'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} - self.assertEqual(consistency.model_advisories(groups, results), []) - - def test_one_family_only_is_quiet(self): - # no ARXML in the model: nothing to be out of step with - results = _results(**{'Ctrl.c': 'real-change', 'Ctrl.h': 'identical'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.h']} - self.assertEqual(consistency.model_advisories(groups, results), []) - - def test_a2l_change_alone_is_not_flagged(self): - # calibration legitimately changes on its own; C and ARXML both quiet - results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'identical', - 'Ctrl.a2l': 'real-change'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml', 'Ctrl.a2l']} - self.assertEqual(consistency.model_advisories(groups, results), []) + self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results), []) + + def test_no_c_in_the_model_is_quiet(self): + # nothing generated to have followed the interface change + results = _results(**{'Ctrl.arxml': 'real-change'}) + self.assertEqual(_adv({'Ctrl': ['Ctrl.arxml']}, results), []) + + def test_noise_only_surface_change_is_not_real(self): + # an ARXML that only churned UUIDs (ignorable-only) did not really + # change, so a stale C is not a desync + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'ignorable-only'}) + self.assertEqual(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results), []) def test_shared_bucket_skipped(self): - results = _results(**{'util.c': 'real-change', 'util.arxml': 'identical'}) - groups = {'Shared / other': ['util.c', 'util.arxml']} + results = _results(**{'util.c': 'identical', 'util.arxml': 'real-change'}) self.assertEqual( - consistency.model_advisories(groups, results, 'Shared / other'), []) + _adv({'Shared / other': ['util.c', 'util.arxml']}, results, + 'Shared / other'), []) - def test_deleted_c_counts_as_changed(self): - results = _results(**{'Ctrl.c': 'deleted', 'Ctrl.arxml': 'identical'}) - groups = {'Ctrl': ['Ctrl.c', 'Ctrl.arxml']} - self.assertEqual(len(consistency.model_advisories(groups, results)), 1) + def test_deleted_arxml_counts_as_changed(self): + results = _results(**{'Ctrl.c': 'identical', 'Ctrl.arxml': 'deleted'}) + self.assertEqual(len(_adv({'Ctrl': ['Ctrl.c', 'Ctrl.arxml']}, results)), 1) if __name__ == '__main__': diff --git a/tests/test_demo.py b/tests/test_demo.py index 7b07cdb..0df4cff 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -35,31 +35,42 @@ def test_reorder_folds_speedctrl_to_unimportant(self): # --- feature 4: cross-artifact consistency advisory --- - def test_consistency_flags_only_the_desynced_model(self): + def test_consistency_flags_only_the_stale_model(self): adv = consistency_advisories(self.res) models = [m for m, _msg in adv] - # TorqueLimiter.c changed while its ARXML stayed identical -> flagged. - # SpeedCtrl has no real C change; PedalMap changed both sides -> quiet. - self.assertEqual(models, ['TorqueLimiter']) - - def test_the_desync_verdicts_are_what_drive_the_flag(self): + # StaleGen's ARXML and A2L really changed while its C stayed identical. + # Nothing else is out of step, so it is the only model named. + self.assertEqual(models, ['StaleGen']) + self.assertEqual(adv[0][1], + 'ARXML and A2L changed but the generated C did not') + + def test_stale_model_verdicts_drive_the_flag(self): + self.assertEqual(self.res['StaleGen.arxml']['status'], 'real-change') + self.assertEqual(self.res['StaleGen.a2l']['status'], 'real-change') + self.assertEqual(self.res['StaleGen.c']['status'], 'identical') + + def test_code_only_change_is_not_flagged(self): + # TorqueLimiter's C changed (a gain) but its ARXML did not -- a logic + # edit touches no interface, so this is normal and must NOT be flagged self.assertEqual(self.res['TorqueLimiter.c']['status'], 'real-change') self.assertEqual(self.res['TorqueLimiter.arxml']['status'], 'identical') - # the healthy model: both sides really changed + self.assertNotIn('TorqueLimiter', + [m for m, _ in consistency_advisories(self.res)]) + + def test_surfaces_and_code_changing_together_is_quiet(self): + # PedalMap changed its C, its ARXML (a new port) and its A2L together self.assertEqual(self.res['PedalMap.c']['status'], 'real-change') self.assertEqual(self.res['PedalMap.arxml']['status'], 'real-change') + self.assertEqual(self.res['PedalMap.a2l']['status'], 'real-change') + self.assertNotIn('PedalMap', + [m for m, _ in consistency_advisories(self.res)]) - def test_a2l_change_alone_never_flags_a_model(self): - # PedalMap.a2l added a characteristic; that never makes a consistency - # advisory on its own -- calibration is not paired with the code + def test_autosar_summary_sees_the_new_objects(self): + swc = summarize_swcs(self.res) + ports = [(rel, name) for rel, _swc, name, _desc in swc['ports']['added']] + self.assertIn(('PedalMap.arxml', 'Scaled'), ports) added, _removed = summarize_a2l(self.res) self.assertIn(('PedalMap.a2l', 'K_PedalOffset', 'CHARACTERISTIC'), added) - self.assertNotIn('PedalMap', [m for m, _ in consistency_advisories(self.res)]) - - def test_autosar_summary_sees_the_new_port(self): - swc = summarize_swcs(self.res) - added = [(rel, name) for rel, _swc, name, _desc in swc['ports']['added']] - self.assertIn(('PedalMap.arxml', 'Scaled'), added) # --- feature 5: machine-readable output --- @@ -67,10 +78,11 @@ def test_sarif_lists_only_actionable_files(self): log = serialize.build_sarif(self.res) uris = {r['locations'][0]['physicalLocation']['artifactLocation']['uri'] for r in log['runs'][0]['results']} - # the reordered file is Unimportant, so it is NOT a finding + # the reordered file and the stale (identical) C are NOT findings self.assertNotIn('SpeedCtrl.c', uris) - self.assertEqual(uris, {'TorqueLimiter.c', 'PedalMap.c', - 'PedalMap.arxml', 'PedalMap.a2l'}) + self.assertNotIn('StaleGen.c', uris) + self.assertEqual(uris, {'TorqueLimiter.c', 'PedalMap.c', 'PedalMap.arxml', + 'PedalMap.a2l', 'StaleGen.arxml', 'StaleGen.a2l'}) def test_json_round_trips_and_carries_the_reorder(self): counts = {k: 0 for k in ('identical', 'comment-only', 'ignorable-only', From c2a274c85e04326aaef593917091630f1ad1bcbf Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 22:45:38 +0700 Subject: [PATCH 7/9] feat(ui): name A2L changes by object kind, chips on the header line The AUTOSAR rollup showed a generic '+1 A2L', which hid whether a characteristic or a measurement moved. Split it by kind (+1 Characteristic / +1 Measurement) through one view_model seam so the report chips and the viewer header spell it the same way. Ports, interfaces, runnables and events already carried their noun. In the viewer the chips now ride on the file header line, right after the name and before 'Change k of N', instead of a second 'AUTOSAR / A2L:' line below -- one line, no prefix, less vertical space spent restating the category. --- CHANGELOG.md | 3 ++ compare_tool/qtviewer/diffpane.py | 60 ++++++++++++++++++------------- compare_tool/report.py | 24 +++++++++---- compare_tool/view_model.py | 13 +++++++ tests/test_diffpane_qt.py | 18 ++++++++++ tests/test_report.py | 20 +++++++++-- tests/test_view_model.py | 12 ++++++- 7 files changed, 114 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 717e394..f3aec6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ All notable changes to this project are documented here. Versions follow 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 diff --git a/compare_tool/qtviewer/diffpane.py b/compare_tool/qtviewer/diffpane.py index aa2e0c7..2476eaf 100644 --- a/compare_tool/qtviewer/diffpane.py +++ b/compare_tool/qtviewer/diffpane.py @@ -20,6 +20,7 @@ itself, and the two never touch -- the diff owns background, syntax owns text. """ +from collections import Counter from pathlib import Path from PySide6.QtCore import QEvent, QPointF, QRect, QRectF, QSize, Qt, Signal @@ -33,8 +34,9 @@ from .. import funcname, review, theme from ..scanner import looks_binary, read_text from ..syntax import language_for -from ..view_model import (MUTED, SWC_DISPLAY, Row, aligned_rows, char_span, - hunk_row_starts, mode_of, mute_rows, row_with) +from ..view_model import (A2L_KINDS, MUTED, SWC_DISPLAY, Row, a2l_kind_label, + aligned_rows, char_span, hunk_row_starts, mode_of, + mute_rows, row_with) from . import highlight from .highlight import CodeHighlighter from .icons import logo_pixmap @@ -73,9 +75,14 @@ def _pm(label, added, removed, changed=0): def _semantic_summary(result): - """Compact AUTOSAR / A2L change rollup for the file header, reusing the + """Compact AUTOSAR / A2L change chips for the file header, reusing the semantic diffs the scanner already attached (interfaces, SWC ports / - runnables / events, RTE access points, A2L objects). '' when none.""" + runnables / events, RTE access points, A2L objects). '' when none. + + Each chip names the concrete object kind -- '+1 Characteristic', not a + generic '+1 A2L' -- through the same view-model seam the report chips use, + so a change reads the same in both surfaces. No 'AUTOSAR / A2L:' prefix: + the chips ride on the header line beside 'Change k of N'.""" chips = [] s = result.get('swc') if s: @@ -91,9 +98,11 @@ def _semantic_summary(result): chips.append(_pm('RTE', len(t['added']), len(t['removed']))) a = result.get('a2l') if a: - chips.append(_pm('A2L', len(a['added']), len(a['removed']))) - chips = [c for c in chips if c] - return 'AUTOSAR / A2L: ' + ' · '.join(chips) if chips else '' + add = Counter(k for _n, k in a['added']) + rem = Counter(k for _n, k in a['removed']) + for kind in A2L_KINDS: + chips.append(_pm(a2l_kind_label(kind), add.get(kind, 0), rem.get(kind, 0))) + return ' · '.join(c for c in chips if c) # per-side row background by mode, as theme roles; None = context (editor base # colour). Looked up at paint time, so a theme switch is a repaint and never a @@ -360,9 +369,6 @@ def __init__(self): head_row.addWidget(self._header, 1) head_row.addWidget(self._fn) head_row.addLayout(self.nav_actions) - self._sem = QLabel('') - self._sem.setWordWrap(True) - self._sem.setVisible(False) self.old_edit = DiffEditor() self.new_edit = DiffEditor() # VS Code-style "sticky scroll": the enclosing function's signature @@ -405,12 +411,12 @@ def __init__(self): dl = QVBoxLayout(diff_page) dl.setContentsMargins(0, 0, 0, 0) dl.setSpacing(0) - # header + semantic line stay at their natural (small) height; the - # editor body takes ALL remaining vertical space (stretch=1), so the - # two-pane diff fills the pane from just under the header instead of - # being pushed to the bottom by an oversized header gap + # the header row stays at its natural (small) height; the editor body + # takes ALL remaining vertical space (stretch=1), so the two-pane diff + # fills the pane from just under the header instead of being pushed to + # the bottom by an oversized header gap. The AUTOSAR/A2L chips ride on + # the header line itself (see _load_rows), not a second line below. dl.addLayout(head_row) - dl.addWidget(self._sem) dl.addWidget(self._find_bar) dl.addWidget(body, 1) @@ -491,8 +497,6 @@ def _style_widgets(self): theme.c('sticky-border'))) self._sticky_old.setStyleSheet(sticky_qss) self._sticky_new.setStyleSheet(sticky_qss) - self._sem.setStyleSheet('color:{}; padding:0 10px 6px; font-size:12px;' - .format(theme.c('fg-dim'))) self._find_count.setStyleSheet('color:{}; font-size:12px;' .format(theme.c('st-ign'))) # neutral strip, coloured only in the OLD/NEW tag text and a thin @@ -1061,7 +1065,7 @@ def _show_file(self, rel, result, old_root, new_root): return lines = read_text(path).split('\n') self._load_one_side(rel, label, lines, - 'new' if status == 'added' else 'old') + 'new' if status == 'added' else 'old', result) return # real-change / ignorable-only / identical all show the two-pane code; # identical has no hunks so it renders as plain context (no highlights) @@ -1092,11 +1096,13 @@ def _load_rows(self, rel, status, result=None): if n_moved: head += ' · {} Moved line{}'.format(n_moved, '' if n_moved == 1 else 's') + # the AUTOSAR/A2L chips ride on the header line, after the file name and + # before "Change k of N", instead of a second line under it + sem = _semantic_summary(result or {}) + if sem: + head += ' · ' + sem self._head_base = head self._header.setText(head) - sem = _semantic_summary(result or {}) - self._sem.setText(sem) - self._sem.setVisible(bool(sem)) # configure before the text lands: setPlainText runs a full highlight # pass of its own, so this way the file is coloured once, not twice modes = [r.mode for r in rows] @@ -1178,7 +1184,7 @@ def _load_rows(self, rel, status, result=None): self.old_edit.verticalScrollBar().setValue(0) self._on_vscroll() # no change to reveal: name the scope at line 1 - def _load_one_side(self, rel, label, lines, side): + def _load_one_side(self, rel, label, lines, side, result=None): # rows are marked 'ctx': the pane is already one solid colour, so the # map has nothing to add by repeating it -- but they ARE the file, and # the find box searches rows, so a whole added file has to have them @@ -1192,11 +1198,15 @@ def _load_one_side(self, rel, label, lines, side): self._stop_units = [] self._pos_text = '' self._clear_selections() # nothing of the previous file may survive - self._sem.setVisible(False) # keep _head_base in step with the shown header (an added/deleted file # has no change stops, but leaving a stale base from the previous file - # is exactly the kind of drift that bites later) - self._head_base = '{} · {}'.format(rel, label) + # is exactly the kind of drift that bites later). A whole added/deleted + # ARXML/A2L still carries its chips (+N Characteristic, +N Port) inline + head = '{} · {}'.format(rel, label) + sem = _semantic_summary(result or {}) + if sem: + head += ' · ' + sem + self._head_base = head self._header.setText(self._head_base) edit = self.old_edit if side == 'old' else self.new_edit other = self.new_edit if side == 'old' else self.old_edit diff --git a/compare_tool/report.py b/compare_tool/report.py index b2df40f..efe2882 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -15,8 +15,8 @@ from .diff_engine import ruleset_for from .scanner import (looks_binary, read_text, summarize, summarize_a2l, summarize_ifaces, summarize_rte, summarize_swcs) -from .view_model import (SWC_DISPLAY, char_span, iface_kind, mode_of, - swc_item) +from .view_model import (A2L_KINDS, SWC_DISPLAY, a2l_kind_label, char_span, + iface_kind, mode_of, swc_item) CONTEXT = 3 MAX_CONTENT = 400 # max lines shown for added/deleted file content @@ -929,9 +929,15 @@ def _counts_html(rels, results): def _autosar_chips(rels, results): """Compact AUTOSAR change rollup for one model group, e.g. - '+1 Interface · +2/−1 Port · ~1 Event · +3 RTE'.""" - ia = ir = sa = sr = ra = rr = aa = ar = 0 + '+1 Interface · +2/−1 Port · ~1 Event · +3 RTE · +1 Characteristic'. + + A2L is split by object kind (Characteristic / Measurement) rather than a + generic '+N A2L', through the same seam the viewer header uses, so the two + surfaces name a calibration change the same way.""" + ia = ir = sa = sr = ra = rr = 0 cats = {cat.key: [0, 0, 0] for cat in SWC_DISPLAY} + a2l_add = {k: 0 for k in A2L_KINDS} + a2l_rem = {k: 0 for k in A2L_KINDS} for rel in rels: r = results[rel] d = r.get('ifaces') @@ -952,8 +958,10 @@ def _autosar_chips(rels, results): rr += len(t['removed']) a = r.get('a2l') if a: - aa += len(a['added']) - ar += len(a['removed']) + for _n, kind in a['added']: + a2l_add[kind] = a2l_add.get(kind, 0) + 1 + for _n, kind in a['removed']: + a2l_rem[kind] = a2l_rem.get(kind, 0) + 1 def chip(a, r, c, label): bits = [] @@ -967,7 +975,9 @@ def chip(a, r, c, label): chips = [chip(sa, sr, 0, 'SWC'), chip(ia, ir, 0, 'Interface')] chips += [chip(*(cats[cat.key] + [cat.noun])) for cat in SWC_DISPLAY] - chips += [chip(ra, rr, 0, 'RTE'), chip(aa, ar, 0, 'A2L')] + chips.append(chip(ra, rr, 0, 'RTE')) + chips += [chip(a2l_add.get(k, 0), a2l_rem.get(k, 0), 0, a2l_kind_label(k)) + for k in A2L_KINDS] return ' · '.join(c for c in chips if c) diff --git a/compare_tool/view_model.py b/compare_tool/view_model.py index 9c8ff3a..e7bf1f5 100644 --- a/compare_tool/view_model.py +++ b/compare_tool/view_model.py @@ -25,6 +25,7 @@ import re from collections import namedtuple +from .a2l_rules import A2L_OBJECT_KINDS from .arxml_rules import SWC_CATEGORIES _WORD_RE = re.compile(r'\w') @@ -67,6 +68,18 @@ SWC_DISPLAY = tuple(SwcCategory(key, *_SWC_LABELS[key]) for key in SWC_CATEGORIES) +# A2L object kinds, in the order a chip lists them. A generic '+N A2L' count +# hides which kind moved, so both the report chips and the viewer header break +# it apart through this one seam and cannot spell it two ways. +A2L_KINDS = A2L_OBJECT_KINDS + + +def a2l_kind_label(kind): + """'CHARACTERISTIC' -> 'Characteristic' for a chip label, so it sits beside + 'Port' and 'Interface' rather than shouting in all-caps.""" + return kind.capitalize() + + def iface_kind(tag): """'SENDER-RECEIVER-INTERFACE' -> 'SENDER-RECEIVER' for display.""" return tag.replace('-INTERFACE', '') diff --git a/tests/test_diffpane_qt.py b/tests/test_diffpane_qt.py index 54a8083..df7a6b6 100644 --- a/tests/test_diffpane_qt.py +++ b/tests/test_diffpane_qt.py @@ -1010,5 +1010,23 @@ def test_both_empty_is_blank(self): self.assertEqual(dlg._start_dir(dlg.old_row), '') +@unittest.skipUnless(HAVE_QT, 'PySide6 not installed') +class TestSemanticSummary(unittest.TestCase): + """The AUTOSAR/A2L chips that ride on the file header line.""" + + def test_a2l_is_spelled_by_kind_with_no_prefix(self): + from compare_tool.qtviewer.diffpane import _semantic_summary + s = _semantic_summary({'a2l': {'added': [('K_Gain', 'CHARACTERISTIC')], + 'removed': [('EngSpd', 'MEASUREMENT')]}}) + self.assertIn('+1 Characteristic', s) + self.assertIn('−1 Measurement', s) + self.assertNotIn('A2L', s) + self.assertNotIn('AUTOSAR', s) + + def test_nothing_semantic_is_blank(self): + from compare_tool.qtviewer.diffpane import _semantic_summary + self.assertEqual(_semantic_summary({}), '') + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_report.py b/tests/test_report.py index 01a136f..f7d13eb 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -5,9 +5,10 @@ from pathlib import Path from compare_tool.diff_engine import compare_pair -from compare_tool.report import (_char_diff, _counts_html, _CSS, _group_hunks, - _group_table, _groups_html, _model_groups, - _THEME_JS, build_arxml_report, build_report) +from compare_tool.report import (_autosar_chips, _char_diff, _counts_html, + _CSS, _group_hunks, _group_table, _groups_html, + _model_groups, _THEME_JS, build_arxml_report, + build_report) from compare_tool.scanner import scan FIX = Path(__file__).parent / 'fixtures' @@ -1088,5 +1089,18 @@ def test_moved_group_not_hidden_by_unimportant_toggle(self): self.assertNotIn('', self.out) +class TestAutosarChips(unittest.TestCase): + def test_a2l_chips_spell_the_object_kind(self): + # a generic '+N A2L' hides which kind moved -- split it out + results = {'m.a2l': {'a2l': {'added': [('K_Gain', 'CHARACTERISTIC'), + ('EngSpd', 'MEASUREMENT')], + 'removed': []}}} + chips = _autosar_chips(['m.a2l'], results) + self.assertIn('Characteristic', chips) + self.assertIn('Measurement', chips) + self.assertNotIn('>A2L', chips) + self.assertNotIn(' A2L', chips) + + if __name__ == '__main__': unittest.main() diff --git a/tests/test_view_model.py b/tests/test_view_model.py index 29b45d1..dd19f45 100644 --- a/tests/test_view_model.py +++ b/tests/test_view_model.py @@ -11,7 +11,8 @@ from compare_tool.qtviewer.summary_model import summary_sections from compare_tool.report import _char_diff, _swc_note from compare_tool.scanner import summarize -from compare_tool.view_model import (MUTED, SWC_DISPLAY, Row, aligned_rows, +from compare_tool.view_model import (A2L_KINDS, MUTED, SWC_DISPLAY, Row, + a2l_kind_label, aligned_rows, char_span, hunk_row_starts, mute_rows, row_with) @@ -322,3 +323,12 @@ def test_the_terminal_summary_names_every_category(self): text = '\n'.join(summary_lines(results, summarize(results))) for cat in SWC_DISPLAY: self.assertIn(cat.noun, text) + + +class TestA2lKindLabel(unittest.TestCase): + def test_capitalised_for_a_chip(self): + self.assertEqual(a2l_kind_label('CHARACTERISTIC'), 'Characteristic') + self.assertEqual(a2l_kind_label('MEASUREMENT'), 'Measurement') + + def test_kinds_are_the_a2l_object_kinds(self): + self.assertEqual(tuple(A2L_KINDS), ('CHARACTERISTIC', 'MEASUREMENT')) From 00c97f7e7396d1eb171eb3cda86a342ca7e054db Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 22:55:09 +0700 Subject: [PATCH 8/9] test: fold the noise-rule and model-grouping fixtures into the demo tree One compare instead of three: fixtures/demo now also carries copies of the noise-rule fixtures (rules/ -- comment, uuid, timestamp, rename, added, deleted) and the model-grouping fixture (models/Ctrl) beside the four feature models, so a reviewer sees every ignorable kind, an add/delete, and more than one model in the Overview table in the SAME run that shows the reorder fold, the consistency check and the A2L-by-kind chips. Copies, not moves: tests/fixtures/{old,new,model_old,model_new} stay put since other tests pin exact counts and paths against them; duplicating a few small fixture files costs nothing next to that. test_demo.py gains coverage for the folded-in paths (every ignorable kind present, added/deleted present, the four models still their own Overview rows) and the SARIF assertion moves from an exact set to inclusion checks now that the merged tree has more actionable files than the four models alone. --- tests/fixtures/demo/README.md | 30 +++++++++-- tests/fixtures/demo/new/models/Ctrl.c | 10 ++++ tests/fixtures/demo/new/models/Ctrl.h | 5 ++ .../demo/new/models/Ctrl_component.arxml | 45 ++++++++++++++++ tests/fixtures/demo/new/models/Ctrl_types.h | 4 ++ tests/fixtures/demo/new/models/rtwtypes.h | 4 ++ tests/fixtures/demo/new/rules/a2l/cal.a2l | 12 +++++ .../demo/new/rules/a2l/comment_only.a2l | 8 +++ .../demo/new/rules/arxml/admindata.arxml | 16 ++++++ .../fixtures/demo/new/rules/arxml/iface.arxml | 26 ++++++++++ .../demo/new/rules/arxml/real_change.arxml | 13 +++++ .../demo/new/rules/arxml/uuid_only.arxml | 18 +++++++ tests/fixtures/demo/new/rules/src/added.c | 6 +++ .../demo/new/rules/src/comment_only.c | 15 ++++++ .../fixtures/demo/new/rules/src/real_change.c | 12 +++++ .../demo/new/rules/src/rename_conflict.c | 10 ++++ .../fixtures/demo/new/rules/src/rename_only.c | 15 ++++++ tests/fixtures/demo/new/rules/src/same.h | 6 +++ tests/fixtures/demo/old/models/Ctrl.c | 9 ++++ tests/fixtures/demo/old/models/Ctrl.h | 5 ++ .../demo/old/models/Ctrl_component.arxml | 41 +++++++++++++++ tests/fixtures/demo/old/models/Ctrl_types.h | 4 ++ tests/fixtures/demo/old/models/rtwtypes.h | 4 ++ tests/fixtures/demo/old/rules/a2l/cal.a2l | 13 +++++ .../demo/old/rules/a2l/comment_only.a2l | 8 +++ .../demo/old/rules/arxml/admindata.arxml | 16 ++++++ .../fixtures/demo/old/rules/arxml/iface.arxml | 26 ++++++++++ .../demo/old/rules/arxml/real_change.arxml | 13 +++++ .../demo/old/rules/arxml/uuid_only.arxml | 18 +++++++ .../demo/old/rules/src/comment_only.c | 15 ++++++ tests/fixtures/demo/old/rules/src/deleted.h | 6 +++ .../fixtures/demo/old/rules/src/real_change.c | 12 +++++ .../demo/old/rules/src/rename_conflict.c | 10 ++++ .../fixtures/demo/old/rules/src/rename_only.c | 15 ++++++ tests/fixtures/demo/old/rules/src/same.h | 6 +++ tests/test_demo.py | 51 +++++++++++++++---- 36 files changed, 513 insertions(+), 14 deletions(-) create mode 100644 tests/fixtures/demo/new/models/Ctrl.c create mode 100644 tests/fixtures/demo/new/models/Ctrl.h create mode 100644 tests/fixtures/demo/new/models/Ctrl_component.arxml create mode 100644 tests/fixtures/demo/new/models/Ctrl_types.h create mode 100644 tests/fixtures/demo/new/models/rtwtypes.h create mode 100644 tests/fixtures/demo/new/rules/a2l/cal.a2l create mode 100644 tests/fixtures/demo/new/rules/a2l/comment_only.a2l create mode 100644 tests/fixtures/demo/new/rules/arxml/admindata.arxml create mode 100644 tests/fixtures/demo/new/rules/arxml/iface.arxml create mode 100644 tests/fixtures/demo/new/rules/arxml/real_change.arxml create mode 100644 tests/fixtures/demo/new/rules/arxml/uuid_only.arxml create mode 100644 tests/fixtures/demo/new/rules/src/added.c create mode 100644 tests/fixtures/demo/new/rules/src/comment_only.c create mode 100644 tests/fixtures/demo/new/rules/src/real_change.c create mode 100644 tests/fixtures/demo/new/rules/src/rename_conflict.c create mode 100644 tests/fixtures/demo/new/rules/src/rename_only.c create mode 100644 tests/fixtures/demo/new/rules/src/same.h create mode 100644 tests/fixtures/demo/old/models/Ctrl.c create mode 100644 tests/fixtures/demo/old/models/Ctrl.h create mode 100644 tests/fixtures/demo/old/models/Ctrl_component.arxml create mode 100644 tests/fixtures/demo/old/models/Ctrl_types.h create mode 100644 tests/fixtures/demo/old/models/rtwtypes.h create mode 100644 tests/fixtures/demo/old/rules/a2l/cal.a2l create mode 100644 tests/fixtures/demo/old/rules/a2l/comment_only.a2l create mode 100644 tests/fixtures/demo/old/rules/arxml/admindata.arxml create mode 100644 tests/fixtures/demo/old/rules/arxml/iface.arxml create mode 100644 tests/fixtures/demo/old/rules/arxml/real_change.arxml create mode 100644 tests/fixtures/demo/old/rules/arxml/uuid_only.arxml create mode 100644 tests/fixtures/demo/old/rules/src/comment_only.c create mode 100644 tests/fixtures/demo/old/rules/src/deleted.h create mode 100644 tests/fixtures/demo/old/rules/src/real_change.c create mode 100644 tests/fixtures/demo/old/rules/src/rename_conflict.c create mode 100644 tests/fixtures/demo/old/rules/src/rename_only.c create mode 100644 tests/fixtures/demo/old/rules/src/same.h diff --git a/tests/fixtures/demo/README.md b/tests/fixtures/demo/README.md index 8b50d33..9d29b0c 100644 --- a/tests/fixtures/demo/README.md +++ b/tests/fixtures/demo/README.md @@ -1,13 +1,19 @@ # Demo tree -A small before/after pair that shows the three newest features. Run it and look -at the report and the terminal: +One before/after pair, one compare, that shows the whole tool: every noise rule +plus the three newest features. Run it and look at the report and the terminal: ```bash python -m compare_tool tests/fixtures/demo/old tests/fixtures/demo/new \ --report demo.html --json demo.json --sarif demo.sarif ``` +Four top-level models make the newest features' point. `rules/` and `models/` +are the tool's own noise-rule and model-grouping fixtures folded in beside +them, so the same run also shows comment/uuid/timestamp/rename noise, an added +file, a deleted file, and Modified files sitting right next to what does not +count — everything a reviewer would otherwise need several compares to see. + Four models, each making one point: | Model | Files | What it shows | @@ -17,11 +23,27 @@ Four models, each making one point: | **TorqueLimiter** | `.c` `.arxml` | **A code-only change is normal.** The C changed (a gain went 1.25 → 1.45) while the ARXML did not. A logic edit touches no interface, so this is **not** flagged — the check only fires when a surface changed without the code following. | | **PedalMap** | `.c` `.arxml` `.a2l` | **The healthy case, plus machine output.** The C, the ARXML (a new `Scaled` port) and the A2L (a new `K_PedalOffset`) all changed together, so no flag — and the AUTOSAR summary lists the new port and characteristic. | +`rules/` (noise coverage, one file per rule) and `models/` (the `Ctrl` model, +for the model-grouping / Overview table): + +| Path | What it shows | +|---|---| +| `rules/src/comment_only.c` | **Comment** — banner/comment churn only | +| `rules/src/rename_only.c` | **Unimportant** — a consistent 1-to-1 identifier rename | +| `rules/src/real_change.c` | **Modified** — a real change beside a comment change | +| `rules/src/added.c` / `deleted.h` | **Added** / **Deleted** | +| `rules/arxml/uuid_only.arxml` | **Unimportant** — `UUID="…"` churn only | +| `rules/arxml/admindata.arxml` | **Unimportant** — `` timestamp churn | +| `rules/arxml/iface.arxml` | **Modified** — a port-interface change, alongside a UUID bump | +| `rules/a2l/comment_only.a2l` | **Comment** | +| `rules/a2l/cal.a2l` | **Modified** — a calibration object change | +| `models/Ctrl.*` | a second model, so the Overview table groups more than one | + What the outputs carry: -- **`demo.html`** — the human report: a *Consistency check* section (below the AUTOSAR changes) names StaleGen, and `SpeedCtrl.c` sits under Unimportant with its rows greyed until you click. +- **`demo.html`** — the human report: a *Consistency check* section (below the AUTOSAR changes) names StaleGen, `SpeedCtrl.c` sits under Unimportant with its rows greyed until you click, and the folder tree/Overview show every verdict at once. - **`demo.json`** — the whole scan under a versioned schema, including the same exit code the process returns. -- **`demo.sarif`** — only the files that need action. `SpeedCtrl.c` (Unimportant) and `StaleGen.c` (identical) are *not* findings. +- **`demo.sarif`** — only the files that need action. Unimportant, Comment and identical files are *not* findings. `test_demo.py` asserts every one of these claims, so the demo cannot drift out of step with what it says it does. diff --git a/tests/fixtures/demo/new/models/Ctrl.c b/tests/fixtures/demo/new/models/Ctrl.c new file mode 100644 index 0000000..a89f768 --- /dev/null +++ b/tests/fixtures/demo/new/models/Ctrl.c @@ -0,0 +1,10 @@ +/* Model step function */ +#include "Ctrl.h" + +void Ctrl_Step(void) +{ + Float32 u; + (void) Rte_Read_In1_Speed(&u); + (void) Rte_Write_Out1_Cmd(u * 3.0F); + (void) Rte_Write_Out2_Diag(1U); +} diff --git a/tests/fixtures/demo/new/models/Ctrl.h b/tests/fixtures/demo/new/models/Ctrl.h new file mode 100644 index 0000000..9118c98 --- /dev/null +++ b/tests/fixtures/demo/new/models/Ctrl.h @@ -0,0 +1,5 @@ +#ifndef CTRL_H +#define CTRL_H +#include "rtwtypes.h" +void Ctrl_Step(void); +#endif diff --git a/tests/fixtures/demo/new/models/Ctrl_component.arxml b/tests/fixtures/demo/new/models/Ctrl_component.arxml new file mode 100644 index 0000000..2675647 --- /dev/null +++ b/tests/fixtures/demo/new/models/Ctrl_component.arxml @@ -0,0 +1,45 @@ + + + + + Components + + + Ctrl + + + In1 + /Interfaces/If_Speed + + + Out1 + /Interfaces/If_Cmd + + + Out2 + /Interfaces/If_Diag + + + + + IB + + + TE_Step + /Components/Ctrl/IB/Ctrl_Step + 0.02 + + + + + Ctrl_Step + Ctrl_Step + + + + + + + + + diff --git a/tests/fixtures/demo/new/models/Ctrl_types.h b/tests/fixtures/demo/new/models/Ctrl_types.h new file mode 100644 index 0000000..2c62bad --- /dev/null +++ b/tests/fixtures/demo/new/models/Ctrl_types.h @@ -0,0 +1,4 @@ +#ifndef CTRL_TYPES_H +#define CTRL_TYPES_H +typedef float Float32; +#endif diff --git a/tests/fixtures/demo/new/models/rtwtypes.h b/tests/fixtures/demo/new/models/rtwtypes.h new file mode 100644 index 0000000..8362173 --- /dev/null +++ b/tests/fixtures/demo/new/models/rtwtypes.h @@ -0,0 +1,4 @@ +#ifndef RTWTYPES_H +#define RTWTYPES_H +typedef unsigned char boolean_T; +#endif diff --git a/tests/fixtures/demo/new/rules/a2l/cal.a2l b/tests/fixtures/demo/new/rules/a2l/cal.a2l new file mode 100644 index 0000000..60ec067 --- /dev/null +++ b/tests/fixtures/demo/new/rules/a2l/cal.a2l @@ -0,0 +1,12 @@ +/* generated by demo toolchain -- Tue Feb 17 2026 */ +ASAP2_VERSION 1 71 +/begin PROJECT Demo "" + /begin MODULE Ctrl "" + /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000 + ECU_ADDRESS 0x40001000 + /end MEASUREMENT + /begin MEASUREMENT VehSpd "vehicle speed" UWORD CM_VehSpd 1 100 0 300 + ECU_ADDRESS 0x40001004 + /end MEASUREMENT + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/new/rules/a2l/comment_only.a2l b/tests/fixtures/demo/new/rules/a2l/comment_only.a2l new file mode 100644 index 0000000..1152b0f --- /dev/null +++ b/tests/fixtures/demo/new/rules/a2l/comment_only.a2l @@ -0,0 +1,8 @@ +/* generated Tue */ +ASAP2_VERSION 1 71 +/begin PROJECT Demo "" + /begin MODULE Ctrl "" + /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000 + /end MEASUREMENT + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/new/rules/arxml/admindata.arxml b/tests/fixtures/demo/new/rules/arxml/admindata.arxml new file mode 100644 index 0000000..01e9734 --- /dev/null +++ b/tests/fixtures/demo/new/rules/arxml/admindata.arxml @@ -0,0 +1,16 @@ + + + + + + 9.9 + 2026-02-17T08:45:01 + + + + + + Interfaces + + + diff --git a/tests/fixtures/demo/new/rules/arxml/iface.arxml b/tests/fixtures/demo/new/rules/arxml/iface.arxml new file mode 100644 index 0000000..d48f7a8 --- /dev/null +++ b/tests/fixtures/demo/new/rules/arxml/iface.arxml @@ -0,0 +1,26 @@ + + + + + Interfaces + + + If_Speed + + + Speed + + + + + If_Torque + + + Torque + + + + + + + diff --git a/tests/fixtures/demo/new/rules/arxml/real_change.arxml b/tests/fixtures/demo/new/rules/arxml/real_change.arxml new file mode 100644 index 0000000..60d177a --- /dev/null +++ b/tests/fixtures/demo/new/rules/arxml/real_change.arxml @@ -0,0 +1,13 @@ + + + + + DataTypes + + + Velocity_T + + + + + diff --git a/tests/fixtures/demo/new/rules/arxml/uuid_only.arxml b/tests/fixtures/demo/new/rules/arxml/uuid_only.arxml new file mode 100644 index 0000000..ee1b464 --- /dev/null +++ b/tests/fixtures/demo/new/rules/arxml/uuid_only.arxml @@ -0,0 +1,18 @@ + + + + + ComponentTypes + + + Controller + + + Out1 + + + + + + + diff --git a/tests/fixtures/demo/new/rules/src/added.c b/tests/fixtures/demo/new/rules/src/added.c new file mode 100644 index 0000000..6855653 --- /dev/null +++ b/tests/fixtures/demo/new/rules/src/added.c @@ -0,0 +1,6 @@ +#include "added.h" + +void New_step(void) +{ + rtY.Out9 = 0.0; +} diff --git a/tests/fixtures/demo/new/rules/src/comment_only.c b/tests/fixtures/demo/new/rules/src/comment_only.c new file mode 100644 index 0000000..4f93e59 --- /dev/null +++ b/tests/fixtures/demo/new/rules/src/comment_only.c @@ -0,0 +1,15 @@ +/* + * File: comment_only.c + * Code generated for Simulink model 'Model'. + * Model version : 1.43 + * Simulink Coder version : 9.8 (R2023a) 19-Nov-2022 + * C/C++ source code generated on : Tue Feb 17 08:45:01 2026 + */ +#include "comment_only.h" + +/* Model step function (regenerated) */ +void Model_step(void) +{ + /* Outport: '/Out1' */ + rtY.Out1 = rtU.In1 * 2.0; +} diff --git a/tests/fixtures/demo/new/rules/src/real_change.c b/tests/fixtures/demo/new/rules/src/real_change.c new file mode 100644 index 0000000..bc6bd34 --- /dev/null +++ b/tests/fixtures/demo/new/rules/src/real_change.c @@ -0,0 +1,12 @@ +/* Generated on : Tue Feb 17 08:45:01 2026 */ +#include "real_change.h" + +void Calc_step(void) +{ + /* saturation limit */ + if (rtU.In1 > 10) { + rtY.Out1 = 10; + } else { + rtY.Out1 = rtU.In1; + } +} diff --git a/tests/fixtures/demo/new/rules/src/rename_conflict.c b/tests/fixtures/demo/new/rules/src/rename_conflict.c new file mode 100644 index 0000000..0fa12a1 --- /dev/null +++ b/tests/fixtures/demo/new/rules/src/rename_conflict.c @@ -0,0 +1,10 @@ +#include "rename_conflict.h" + +void Conf_step(void) +{ + real_T rtb_B; + + rtb_B = rtU.In1 * 2.0; + rtY.Out1 = rtb_B + 1.0; + rtY.Out2 = rtb_C + 2.0; +} diff --git a/tests/fixtures/demo/new/rules/src/rename_only.c b/tests/fixtures/demo/new/rules/src/rename_only.c new file mode 100644 index 0000000..740171a --- /dev/null +++ b/tests/fixtures/demo/new/rules/src/rename_only.c @@ -0,0 +1,15 @@ +#include "rename_only.h" + +void Sub_step(void) +{ + real_T rtb_Sum_k2j; + real_T rtb_Gain_p0f; + + rtb_Sum_k2j = rtU.In1 + rtU.In2; + rtb_Gain_p0f = rtb_Sum_k2j * 3.5; + if (rtb_Sum_k2j > 0.0) { + rtY.Out1 = rtb_Gain_p0f; + } else { + rtY.Out1 = rtb_Sum_k2j; + } +} diff --git a/tests/fixtures/demo/new/rules/src/same.h b/tests/fixtures/demo/new/rules/src/same.h new file mode 100644 index 0000000..a3d7f02 --- /dev/null +++ b/tests/fixtures/demo/new/rules/src/same.h @@ -0,0 +1,6 @@ +#ifndef SAME_H +#define SAME_H + +extern void Sub_step(void); + +#endif diff --git a/tests/fixtures/demo/old/models/Ctrl.c b/tests/fixtures/demo/old/models/Ctrl.c new file mode 100644 index 0000000..490be36 --- /dev/null +++ b/tests/fixtures/demo/old/models/Ctrl.c @@ -0,0 +1,9 @@ +/* Model step function */ +#include "Ctrl.h" + +void Ctrl_Step(void) +{ + Float32 u; + (void) Rte_Read_In1_Speed(&u); + (void) Rte_Write_Out1_Cmd(u * 2.0F); +} diff --git a/tests/fixtures/demo/old/models/Ctrl.h b/tests/fixtures/demo/old/models/Ctrl.h new file mode 100644 index 0000000..9118c98 --- /dev/null +++ b/tests/fixtures/demo/old/models/Ctrl.h @@ -0,0 +1,5 @@ +#ifndef CTRL_H +#define CTRL_H +#include "rtwtypes.h" +void Ctrl_Step(void); +#endif diff --git a/tests/fixtures/demo/old/models/Ctrl_component.arxml b/tests/fixtures/demo/old/models/Ctrl_component.arxml new file mode 100644 index 0000000..ac6153a --- /dev/null +++ b/tests/fixtures/demo/old/models/Ctrl_component.arxml @@ -0,0 +1,41 @@ + + + + + Components + + + Ctrl + + + In1 + /Interfaces/If_Speed + + + Out1 + /Interfaces/If_Cmd + + + + + IB + + + TE_Step + /Components/Ctrl/IB/Ctrl_Step + 0.01 + + + + + Ctrl_Step + Ctrl_Step + + + + + + + + + diff --git a/tests/fixtures/demo/old/models/Ctrl_types.h b/tests/fixtures/demo/old/models/Ctrl_types.h new file mode 100644 index 0000000..2c62bad --- /dev/null +++ b/tests/fixtures/demo/old/models/Ctrl_types.h @@ -0,0 +1,4 @@ +#ifndef CTRL_TYPES_H +#define CTRL_TYPES_H +typedef float Float32; +#endif diff --git a/tests/fixtures/demo/old/models/rtwtypes.h b/tests/fixtures/demo/old/models/rtwtypes.h new file mode 100644 index 0000000..8362173 --- /dev/null +++ b/tests/fixtures/demo/old/models/rtwtypes.h @@ -0,0 +1,4 @@ +#ifndef RTWTYPES_H +#define RTWTYPES_H +typedef unsigned char boolean_T; +#endif diff --git a/tests/fixtures/demo/old/rules/a2l/cal.a2l b/tests/fixtures/demo/old/rules/a2l/cal.a2l new file mode 100644 index 0000000..be9196c --- /dev/null +++ b/tests/fixtures/demo/old/rules/a2l/cal.a2l @@ -0,0 +1,13 @@ +/* generated by demo toolchain -- Mon Jan 05 2026 */ +ASAP2_VERSION 1 71 +/begin PROJECT Demo "" + /begin MODULE Ctrl "" + /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000 + ECU_ADDRESS 0x40001000 + /end MEASUREMENT + /begin CHARACTERISTIC K_Gain "controller gain" VALUE 0x80001000 __Scalar 100 CM_Gain 0 10 + /begin IF_DATA XCP + /end IF_DATA + /end CHARACTERISTIC + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/old/rules/a2l/comment_only.a2l b/tests/fixtures/demo/old/rules/a2l/comment_only.a2l new file mode 100644 index 0000000..6f9b37d --- /dev/null +++ b/tests/fixtures/demo/old/rules/a2l/comment_only.a2l @@ -0,0 +1,8 @@ +/* generated Mon */ +ASAP2_VERSION 1 71 +/begin PROJECT Demo "" + /begin MODULE Ctrl "" + /begin MEASUREMENT EngSpd "engine speed" UWORD CM_EngSpd 1 100 0 8000 + /end MEASUREMENT + /end MODULE +/end PROJECT diff --git a/tests/fixtures/demo/old/rules/arxml/admindata.arxml b/tests/fixtures/demo/old/rules/arxml/admindata.arxml new file mode 100644 index 0000000..0a3593f --- /dev/null +++ b/tests/fixtures/demo/old/rules/arxml/admindata.arxml @@ -0,0 +1,16 @@ + + + + + + 9.8 + 2026-01-05T10:12:33 + + + + + + Interfaces + + + diff --git a/tests/fixtures/demo/old/rules/arxml/iface.arxml b/tests/fixtures/demo/old/rules/arxml/iface.arxml new file mode 100644 index 0000000..0d2f4ad --- /dev/null +++ b/tests/fixtures/demo/old/rules/arxml/iface.arxml @@ -0,0 +1,26 @@ + + + + + Interfaces + + + If_Speed + + + Speed + + + + + If_Diag + + + ReadDtc + + + + + + + diff --git a/tests/fixtures/demo/old/rules/arxml/real_change.arxml b/tests/fixtures/demo/old/rules/arxml/real_change.arxml new file mode 100644 index 0000000..6a8ea65 --- /dev/null +++ b/tests/fixtures/demo/old/rules/arxml/real_change.arxml @@ -0,0 +1,13 @@ + + + + + DataTypes + + + Speed_T + + + + + diff --git a/tests/fixtures/demo/old/rules/arxml/uuid_only.arxml b/tests/fixtures/demo/old/rules/arxml/uuid_only.arxml new file mode 100644 index 0000000..cb0fc1b --- /dev/null +++ b/tests/fixtures/demo/old/rules/arxml/uuid_only.arxml @@ -0,0 +1,18 @@ + + + + + ComponentTypes + + + Controller + + + Out1 + + + + + + + diff --git a/tests/fixtures/demo/old/rules/src/comment_only.c b/tests/fixtures/demo/old/rules/src/comment_only.c new file mode 100644 index 0000000..329aa4a --- /dev/null +++ b/tests/fixtures/demo/old/rules/src/comment_only.c @@ -0,0 +1,15 @@ +/* + * File: comment_only.c + * Code generated for Simulink model 'Model'. + * Model version : 1.42 + * Simulink Coder version : 9.8 (R2023a) 19-Nov-2022 + * C/C++ source code generated on : Mon Jan 05 10:12:33 2026 + */ +#include "comment_only.h" + +/* Model step function */ +void Model_step(void) +{ + /* Outport: '/Out1' */ + rtY.Out1 = rtU.In1 * 2.0; +} diff --git a/tests/fixtures/demo/old/rules/src/deleted.h b/tests/fixtures/demo/old/rules/src/deleted.h new file mode 100644 index 0000000..f0e6007 --- /dev/null +++ b/tests/fixtures/demo/old/rules/src/deleted.h @@ -0,0 +1,6 @@ +#ifndef DELETED_H +#define DELETED_H + +extern void Old_step(void); + +#endif diff --git a/tests/fixtures/demo/old/rules/src/real_change.c b/tests/fixtures/demo/old/rules/src/real_change.c new file mode 100644 index 0000000..2e796cb --- /dev/null +++ b/tests/fixtures/demo/old/rules/src/real_change.c @@ -0,0 +1,12 @@ +/* Generated on : Mon Jan 05 10:12:33 2026 */ +#include "real_change.h" + +void Calc_step(void) +{ + /* saturation limit */ + if (rtU.In1 > 5) { + rtY.Out1 = 5; + } else { + rtY.Out1 = rtU.In1; + } +} diff --git a/tests/fixtures/demo/old/rules/src/rename_conflict.c b/tests/fixtures/demo/old/rules/src/rename_conflict.c new file mode 100644 index 0000000..83b9caf --- /dev/null +++ b/tests/fixtures/demo/old/rules/src/rename_conflict.c @@ -0,0 +1,10 @@ +#include "rename_conflict.h" + +void Conf_step(void) +{ + real_T rtb_A; + + rtb_A = rtU.In1 * 2.0; + rtY.Out1 = rtb_A + 1.0; + rtY.Out2 = rtb_A + 2.0; +} diff --git a/tests/fixtures/demo/old/rules/src/rename_only.c b/tests/fixtures/demo/old/rules/src/rename_only.c new file mode 100644 index 0000000..6436f05 --- /dev/null +++ b/tests/fixtures/demo/old/rules/src/rename_only.c @@ -0,0 +1,15 @@ +#include "rename_only.h" + +void Sub_step(void) +{ + real_T rtb_Sum1; + real_T rtb_Gain2; + + rtb_Sum1 = rtU.In1 + rtU.In2; + rtb_Gain2 = rtb_Sum1 * 3.5; + if (rtb_Sum1 > 0.0) { + rtY.Out1 = rtb_Gain2; + } else { + rtY.Out1 = rtb_Sum1; + } +} diff --git a/tests/fixtures/demo/old/rules/src/same.h b/tests/fixtures/demo/old/rules/src/same.h new file mode 100644 index 0000000..a3d7f02 --- /dev/null +++ b/tests/fixtures/demo/old/rules/src/same.h @@ -0,0 +1,6 @@ +#ifndef SAME_H +#define SAME_H + +extern void Sub_step(void); + +#endif diff --git a/tests/test_demo.py b/tests/test_demo.py index 0df4cff..3dffe68 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -1,9 +1,17 @@ -"""The demo tree under fixtures/demo, and the three features it shows. +"""The demo tree under fixtures/demo: one folder pair covering every noise rule +and the three newest features in a single compare. -`fixtures/demo/old` vs `fixtures/demo/new` is the folder pair a human runs to -see the new features (see fixtures/demo/README.md). These tests lock what it -claims, so the demo can never quietly stop demonstrating what it says it does. -""" +`fixtures/demo/old` vs `fixtures/demo/new` is the pair a human runs (see +fixtures/demo/README.md). Four top-level models make the newest features' +point; `rules/` and `models/` are copies of the tool's own noise-rule and +model-grouping fixtures, folded in so the same one compare also shows every +ignorable kind (comment, uuid, timestamp, rename), an added and a deleted file, +side by side with what is real. Copies, not moves -- `tests/fixtures/old`, +`new`, `model_old` and `model_new` stay put, since other tests pin exact +counts and paths against them. + +These tests lock what the merged demo claims, so it can never quietly stop +demonstrating what it says it does.""" import json import unittest @@ -78,11 +86,34 @@ def test_sarif_lists_only_actionable_files(self): log = serialize.build_sarif(self.res) uris = {r['locations'][0]['physicalLocation']['artifactLocation']['uri'] for r in log['runs'][0]['results']} - # the reordered file and the stale (identical) C are NOT findings - self.assertNotIn('SpeedCtrl.c', uris) - self.assertNotIn('StaleGen.c', uris) - self.assertEqual(uris, {'TorqueLimiter.c', 'PedalMap.c', 'PedalMap.arxml', - 'PedalMap.a2l', 'StaleGen.arxml', 'StaleGen.a2l'}) + # the reordered file, the stale (identical) C, and any Unimportant / + # Comment file are NOT findings + for rel in ('SpeedCtrl.c', 'StaleGen.c', 'rules/arxml/uuid_only.arxml', + 'rules/src/comment_only.c', 'rules/src/rename_only.c'): + self.assertNotIn(rel, uris) + for rel in ('TorqueLimiter.c', 'PedalMap.c', 'PedalMap.arxml', + 'PedalMap.a2l', 'StaleGen.arxml', 'StaleGen.a2l', + 'rules/src/added.c', 'rules/src/deleted.h', + 'rules/src/real_change.c'): + self.assertIn(rel, uris) + + # --- one compare, every noise kind --- + + def test_every_ignorable_kind_is_represented(self): + seen = {h['kind'] for r in self.res.values() for h in r.get('hunks', [])} + for kind in ('comment', 'reorder', 'rename', 'uuid', 'timestamp'): + self.assertIn(kind, seen) + + def test_added_and_deleted_are_represented(self): + statuses = {r['status'] for r in self.res.values()} + self.assertIn('added', statuses) + self.assertIn('deleted', statuses) + + def test_model_grouping_still_separates_the_four_demo_models(self): + from compare_tool.report import _model_groups + groups = _model_groups(self.res) + for model in ('SpeedCtrl', 'StaleGen', 'TorqueLimiter', 'PedalMap'): + self.assertIn(model, groups) def test_json_round_trips_and_carries_the_reorder(self): counts = {k: 0 for k in ('identical', 'comment-only', 'ignorable-only', From 8b6d7062492a00f13cd815557ae32782055c5a6a Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 16 Aug 2026 22:55:58 +0700 Subject: [PATCH 9/9] chore(release): 1.10.0 --- CHANGELOG.md | 2 ++ compare_tool/__init__.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3aec6d..8956186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ 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 diff --git a/compare_tool/__init__.py b/compare_tool/__init__.py index 50f3c15..9949047 100644 --- a/compare_tool/__init__.py +++ b/compare_tool/__init__.py @@ -1,3 +1,3 @@ """CodeGen Compare Tool - AUTOSAR MATLAB codegen diff with noise filtering.""" -__version__ = "1.9.0" +__version__ = "1.10.0"