diff --git a/CHANGELOG.md b/CHANGELOG.md index 35765c8..8956186 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,30 @@ All notable changes to this project are documented here. Versions follow ## [Unreleased] +## [1.10.0] — 2026-08-16 + +### Added + +- **Filter out reordered statements in generated C.** When a regenerated model + emits the same independent assignments in a different order, the diff now + recognises it as noise instead of a change — but only when it can prove the + new order computes identical values. A real edit mixed into the reordering + still shows as a change. +- **Flag an interface or calibration change the code did not follow.** When a + model's ARXML or A2L really changed but its generated C stayed identical — a + new port or characteristic with no matching code — the report and the terminal + point it out, the usual sign of a stale regenerate. A code-only change is not + flagged. It is a heads-up only: it never changes a file's verdict or the exit + code. +- **Name calibration changes by kind.** The AUTOSAR change chips now say + “+1 Characteristic” / “+1 Measurement” instead of a generic “+1 A2L”, in both + the report and the viewer, and the viewer shows them on the file header line. +- **Write the result as JSON or SARIF for a pipeline.** `--json` emits the full + scan — every file's verdict, the summary and the exit code — for a build to + read directly instead of screen-scraping. `--sarif` emits a SARIF 2.1.0 log + of the files that need action, so GitHub or Azure DevOps code scanning can + annotate them inline on a pull request. + ## [1.9.0] — 2026-08-14 Diff and review C++, Python, YAML and JSON files alongside the AUTOSAR output. 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" 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/consistency.py b/compare_tool/consistency.py new file mode 100644 index 0000000..8ab42a7 --- /dev/null +++ b/compare_tool/consistency.py @@ -0,0 +1,76 @@ +"""Cross-artifact consistency advisories. + +A model's ARXML is its contract and its A2L is its calibration surface; both are +realised by the generated C. So the dependency runs one way: if the **interface +or the calibration really changed, the code must have changed too** -- a new +port needs a new RTE access, a new characteristic needs a new symbol. When an +ARXML or A2L change lands with no corresponding change in the generated C, the +folder holds a mix that the per-file diff cannot point at: each file is +individually fine, and the inconsistency lives strictly *between* them. The +everyday cause is a stale or partial regenerate -- the model was re-exported but +the code was not. + +The reverse is **not** flagged. Code that changed while the ARXML and A2L did +not is the ordinary case: an internal logic or gain edit touches no interface +and no calibration variable, so there is nothing for them to follow. + +This is an **advisory, never a verdict**. It never folds a file, moves a count +or changes the exit code. It only reports which artifact families of a model +carry a change the tool already stands behind. + +Stdlib only, no Qt: the report and the CLI both import it. +""" + +from .diff_engine import ruleset_for + +# a family carries a change when at least one of its files got one of these +# verdicts -- the ones the tool reports as "something happened here" +_CHANGED = frozenset(('real-change', 'added', 'deleted')) +_FAMILIES = ('c', 'arxml', 'a2l') + +# the interface / calibration surfaces, and how each is spelled in the advisory +_SURFACES = (('arxml', 'ARXML'), ('a2l', 'A2L')) + + +def _families(rels, results): + """``(present, changed)`` for one model's files: two ``{family: bool}`` + dicts over :data:`_FAMILIES`. ``present`` is True when the model has any + file of that family in the compare at all; ``changed`` when at least one + such file carries a reported change.""" + present = {f: False for f in _FAMILIES} + changed = {f: False for f in _FAMILIES} + for rel in rels: + fam = ruleset_for(rel) + if fam not in _FAMILIES: + continue + present[fam] = True + if results[rel]['status'] in _CHANGED: + changed[fam] = True + return present, changed + + +def model_advisories(groups, results, shared_group=None): + """``[(model, message)]`` for models whose ARXML or A2L really changed while + the generated C did not. + + ``groups`` is ``{model: [rel, ...]}`` (the report's model grouping); + ``shared_group`` names the catch-all bucket to skip, since it is not one + model. A model is judged only when it has a C file in the compare -- with no + generated code there is nothing that should have followed the change. A + code-only change (C changed, the surfaces did not) is never flagged. Sorted + by model name for a stable report and CLI. + """ + out = [] + for model in sorted(groups): + if shared_group is not None and model == shared_group: + continue + present, changed = _families(groups[model], results) + if not present['c'] or changed['c']: + # no code to have followed, or the code changed too -- both fine + continue + surfaces = [label for fam, label in _SURFACES + if present[fam] and changed[fam]] + if surfaces: + out.append((model, '{} changed but the generated C did not' + .format(' and '.join(surfaces)))) + return out 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/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/compare_tool/main.py b/compare_tool/main.py index 5ff5cf6..05b5e5a 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -15,9 +15,9 @@ 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 +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 @@ -226,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 ' @@ -377,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/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 6b812ac..efe2882 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -11,12 +11,12 @@ 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) -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) @@ -987,6 +997,30 @@ 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. + 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

{}
'.format( + ''.join(rows)) + + def _agg_status(node, results): """Folder status = most significant child status.""" best = 'identical' @@ -1600,6 +1634,11 @@ def build_report(results, old_root, new_root, reviews=None, old_label=None, 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))) if results: parts.append('

Folder tree

' 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/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/docs/architecture.md b/docs/architecture.md index d98f3ca..e7793b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -98,6 +98,8 @@ compare_tool/ ├── langspec.py # the comment/string grammar per language, shared by syntax.py (colouring) and the diff shadow (folding) so they agree; generic comment stripper for Python/YAML/JSON ├── syntax.py # line-at-a-time C / C++ / XML / A2L / Python / JSON / YAML token spans, Qt-free so it ships in the .pyz ├── funcname.py # enclosing scope name per line (C/C++ function / Python class·method / SHORT-NAME / A2L block), Qt-free — feeds hunk captions and the "Affected" list +├── consistency.py # cross-artifact advisory: a model whose ARXML/A2L really changed but whose generated C did not follow (heads-up only, never a verdict) +├── 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 @@ -242,8 +244,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..09e1022 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**, @@ -241,6 +262,26 @@ 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 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 Self-contained file, one per compare: badge toggles, folder tree, filter box, @@ -296,6 +337,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/fixtures/demo/README.md b/tests/fixtures/demo/README.md new file mode 100644 index 0000000..9d29b0c --- /dev/null +++ b/tests/fixtures/demo/README.md @@ -0,0 +1,49 @@ +# Demo tree + +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 | +|---|---|---| +| **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. | +| **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. | + +`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, `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. 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/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/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/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/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/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/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/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/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_consistency.py b/tests/test_consistency.py new file mode 100644 index 0000000..9f70495 --- /dev/null +++ b/tests/test_consistency.py @@ -0,0 +1,80 @@ +"""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 + +from compare_tool import consistency + + +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_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.assertEqual(adv[0][1], 'ARXML changed but the generated C did not') + + 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_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'}) + 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': 'identical', 'util.arxml': 'real-change'}) + self.assertEqual( + _adv({'Shared / other': ['util.c', 'util.arxml']}, results, + 'Shared / other'), []) + + 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__': + unittest.main() diff --git a/tests/test_demo.py b/tests/test_demo.py new file mode 100644 index 0000000..3dffe68 --- /dev/null +++ b/tests/test_demo.py @@ -0,0 +1,129 @@ +"""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 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 +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_stale_model(self): + adv = consistency_advisories(self.res) + models = [m for m, _msg in adv] + # 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') + 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_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) + + # --- 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, 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', + '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() 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_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_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.""" 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_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() 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() 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'))