diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e7f3227 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,185 @@ +# CodeGen Compare Tool + +Tool for comparing AUTOSAR MATLAB/Simulink codegen folders and identifying real changes while filtering generator noise. + +Repo: `codegen-compare-tool` +Package: `compare_tool` +Architecture: `docs/architecture.md` + +This file contains **project invariants only**. Do not duplicate general coding guidance. + +--- + +## Core Rule + +**If a difference cannot be proven to be noise, it is a real change.** + +Never hide, downgrade or silently ignore a potentially real change. + +A scan/read/compare failure is an `error`, never an empty result. + +--- + +## Verdicts + +Supported file verdicts: + +```text +identical +comment-only +ignorable-only +real-change +added +deleted +error +``` + +* Verdict logic has a single source of truth. +* Only noise verdicts may be folded/hidden by the UI. +* `real-change`, `added`, `deleted` and `error` must remain visible. +* UI filtering must never change the underlying verdict or counts. +* Reports and summaries must use the **raw scan result**, not the filtered UI state. + +--- + +## Noise Rules + +A noise rule must be conservative. + +Every new rule should prove both: + +1. The pattern alone is noise. +2. The same pattern next to a real change remains a real change. + +Prefer text-based, anchored rules that preserve line structure where possible. + +Never add a rule simply because it makes a diff smaller. + +--- + +## Architecture + +Keep shared decisions in one place. + +* Shared comparison/view-model logic must not be duplicated between CLI, viewer and report. +* Shared visual roles belong in `theme.py`; do not add raw colour literals elsewhere. +* The comparison core must remain independent of Qt. +* `compare_tool/qtviewer/` is the only Qt-dependent area. +* Keep reusable parsing/model modules Qt-free so headless tests continue to work. + +See `docs/architecture.md` before making structural changes. + +--- + +## Dependencies + +The comparison core and `compare_tool.pyz` must remain **Python standard-library only**. + +PySide6 is allowed only for the desktop viewer and must be imported lazily. + +The CLI must remain usable on locked-down machines with no installed third-party dependencies. + +Python support: + +```text +>= 3.8 +``` + +Do not introduce syntax/runtime features unavailable on Python 3.8. + +The HTML report must remain fully self-contained: + +* CSS inline +* JavaScript inline +* No CDN +* No network requests + +--- + +## Fail Safe + +Prefer graceful degradation for non-critical UI resources. + +Examples: + +* Missing icons → keep the text label. +* Missing PySide6 → provide a clear viewer-install message. +* Legacy Windows console → handle encoding safely. + +But **never degrade silently for comparison failures**. + +A failed or incomplete comparison must be obvious and must not look like a clean comparison. + +--- + +## Exit Codes + +```text +0 No real changes +1 Real changes found +2 Compare incomplete / error +``` + +Exit code `2` is part of the CI contract and must never be suppressed by `--exit-zero`. + +--- + +## Release + +For a normal release: + +1. Bump `compare_tool.__version__`. +2. Move the relevant CHANGELOG entries from `[Unreleased]` to the new version/date. +3. Let the release workflow build and publish. +4. Never overwrite an already published version. + +`packaging/release_check.py` owns release preconditions. + +--- + +## Documentation + +The English documentation is the source of truth. + +Vietnamese documentation in `docs/vi/` should translate meaning, not terminology. + +Keep industry/tool terms in English where appropriate: + +```text +port, runnable, calibration, noise, hunk, verdict, SWC, A2L, ARXML +``` + +When changing a documented behavior or claim, update the relevant documentation in the same change. + +--- + +## Common Commands + +```bash +# Compare +python -m compare_tool --report out.html + +# Tests +python -m unittest discover -s tests + +# Lint +python -m ruff check . + +# Build EXE +.\build.ps1 + +# Build EXE + zipapp +.\build.ps1 -Pyz + +# Zipapp only +.\build.ps1 -PyzOnly +``` + +Before committing: + +```bash +python -m unittest discover -s tests +python -m ruff check . +``` + +Qt tests may skip when PySide6 is unavailable, so a green test run without PySide6 does not prove viewer tests were executed. diff --git a/CHANGELOG.md b/CHANGELOG.md index b1ab4f4..a973858 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,17 @@ All notable changes to this project are documented here. Versions follow [semantic versioning](https://semver.org/). -## [Unreleased] +## [1.15.0] + +### Added + +- Add terminal-only comparisons with `--no-report`, including a complete file tree, AUTOSAR/A2L summaries and consistency warnings. + +### Fixed + +- Fix missed changes to string contents and Python/YAML indentation. +- Fix external function changes and reordered assignments with side effects being reported as noise. +- Fix incomplete comparisons when an added or deleted text file has invalid encoding. ## [1.14.1] — 2026-09-13 diff --git a/README.md b/README.md index 8cdcd6e..9a72531 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,15 @@ ZIP files can be compared directly: python -m compare_tool baseline.zip current.zip --report report.html ``` +### Print a terminal summary without a report + +```bash +python -m compare_tool old_gen_folder new_gen_folder --no-report +``` + +Prints a folder tree with every file's verdict, AUTOSAR/A2L changes, and the same +consistency warnings as the HTML report. No code diff or HTML file is generated. + ### Open the desktop viewer ```bash @@ -105,7 +114,7 @@ Both use the **same comparison engine**, so they always produce the same compari | ---------- | ------------------ | ------------------- | | Best for | Interactive review | CI / automation | | Input | Folders / ZIP | Folders / ZIP | -| Output | Interactive diff | HTML / JSON / SARIF | +| Output | Interactive diff | Terminal summary / HTML / JSON / SARIF | | Build gate | — | Exit code | --- diff --git a/compare_tool/a2l_rules.py b/compare_tool/a2l_rules.py index 4834fff..5ee2c3e 100644 --- a/compare_tool/a2l_rules.py +++ b/compare_tool/a2l_rules.py @@ -14,7 +14,7 @@ import re -from .c_rules import collapse_ws +from .langspec import SPECS, normalize_ws # keywords are uppercase per the ASAM grammar, but /begin casing varies in # the wild, so matching is case-insensitive and the kind is normalized @@ -86,7 +86,7 @@ def strip_a2l_comments(text): def a2l_shadow(text): """Normalized shadow for A2L: comments stripped + whitespace collapsed.""" - return collapse_ws(strip_a2l_comments(text)) + return normalize_ws(strip_a2l_comments(text), SPECS['a2l']) def extract_objects(text): diff --git a/compare_tool/c_rules.py b/compare_tool/c_rules.py index e1abf42..654e343 100644 --- a/compare_tool/c_rules.py +++ b/compare_tool/c_rules.py @@ -101,9 +101,10 @@ def strip_c_comments(text): return ''.join(out) -def collapse_ws(text): - """Collapse each line's whitespace runs to single spaces, strip edges.""" - return '\n'.join(' '.join(line.split()) for line in text.split('\n')) +def collapse_ws(text: str) -> str: + """Collapse C layout whitespace while preserving string/character data.""" + from .langspec import SPECS, normalize_ws + return normalize_ws(text, SPECS['c']) def c_shadow(text): @@ -227,6 +228,7 @@ def detect_renames(old_shadow, new_shadow, hunks=None): return None old_ids = set(t for t in tokenize(old_shadow) if is_identifier(t)) new_ids = set(t for t in tokenize(new_shadow) if is_identifier(t)) + calls = _call_names(old_shadow) | _call_names(new_shadow) mapping = {} for o, ns in fwd.items(): if len(ns) != 1: @@ -238,6 +240,8 @@ def detect_renames(old_shadow, new_shadow, hunks=None): continue # not a true rename (name still in use / swap) if _is_authored_name(o) or _is_authored_name(n): continue # RTE port / DWork field / macro-enum -- not this file's to rename + if (o in calls or n in calls) and not _generated_call_pair(o, n): + continue # a different callee is not a local variable rename if not same_checksummed_object(o, n): continue # consistent, but it names a different generated object mapping[o] = n @@ -248,7 +252,18 @@ def apply_rename_map(text, mapping): """Apply an identifier rename map to text (word-boundary safe).""" if not mapping: return text - return re.sub(r'[A-Za-z_]\w*', lambda m: mapping.get(m.group(0), m.group(0)), text) + return TOKEN_RE.sub(lambda m: mapping.get(m.group(0), m.group(0)), text) + + +def _call_names(text: str) -> set: + tokens = tokenize(text) + return {a for a, b in zip(tokens, tokens[1:]) if b == '(' and is_identifier(a)} + + +def _generated_call_pair(old: str, new: str) -> bool: + # A generic suffix (get_x/get_y) alone cannot identify a generated callee. + root = checksum_root(old) + return root is not None and root == checksum_root(new) # --- MATLAB codegen autogenerated-name noise (Simulink/Embedded Coder) --- @@ -385,13 +400,15 @@ def canonical_generated(text): """ def sub(m): tok = m.group(0) + if not is_identifier(tok): + return tok root = generated_root(tok) if root is not None: return root root = checksum_root(tok) return root if root is not None else tok - return re.sub(r'[A-Za-z_]\w*', sub, text) + return TOKEN_RE.sub(sub, text) def _map_has_cycle(mapping): @@ -418,7 +435,12 @@ def autogen_noise_map(old_lines, new_lines, old_ids=None, new_ids=None): collected, or when the map contains a cycle (i_0 <-> i_1 index swap / rotation is a real semantic change). """ - accept = lambda a, b: is_autogen_name_pair(a, b, old_ids, new_ids) # noqa: E731 + calls = _call_names('\n'.join(old_lines + new_lines)) + + def accept(a, b): + if (a in calls or b in calls) and not _generated_call_pair(a, b): + return False + return is_autogen_name_pair(a, b, old_ids, new_ids) if len(old_lines) != len(new_lines): # A shorter generated name lets an argument fit on one line where it # used to wrap at 80 columns, so the two sides hold the same statements @@ -627,6 +649,8 @@ def _parse_scalar_stmt(line): 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 re.search(r'\+\+|--|(?:<<|>>|[+*/%&^|\-])=|(?])=(?!=)', rhs): + return None # RHS writes are not represented by the scalar read set if lhs in C_KEYWORDS: return None content = canonical_generated(body) diff --git a/compare_tool/diff_engine.py b/compare_tool/diff_engine.py index 2d88c4e..cdc9905 100644 --- a/compare_tool/diff_engine.py +++ b/compare_tool/diff_engine.py @@ -265,7 +265,9 @@ def _build_variants(old_text, new_text, ruleset, rename_map, ext='', user_rules= kind wins the label when both explain a hunk. A hunk only a user rule explains is labelled with that rule's name and is ignorable-only, never comment-only -- comment is a built-in category with its own report rules.""" - cw = c_rules.collapse_ws + def cw(text): + return langspec.normalize_ws(text, langspec.SPECS.get(ruleset, + langspec.SPECS['c'])) variants = [('whitespace', _lines(cw(old_text)), _lines(cw(new_text)))] if ruleset == 'c': old_nc = c_rules.strip_c_comments(old_text) @@ -308,8 +310,8 @@ def _build_variants(old_text, new_text, ruleset, rename_map, ext='', user_rules= # the pass-2 diff runs on, so variant and shadow stay in sync. spec = langspec.SPECS[ruleset] variants.append(('comment', - _lines(cw(langspec.strip_comments(old_text, spec))), - _lines(cw(langspec.strip_comments(new_text, spec))))) + _lines(langspec.shadow(old_text, spec)), + _lines(langspec.shadow(new_text, spec)))) # user rules last: a hunk a built-in kind already explains keeps that kind for r in user_rules: if r.applies_to(ext): diff --git a/compare_tool/langspec.py b/compare_tool/langspec.py index 083714f..9c01807 100644 --- a/compare_tool/langspec.py +++ b/compare_tool/langspec.py @@ -24,7 +24,9 @@ rule as the rest of the compare core. """ -from .c_rules import collapse_ws +from __future__ import annotations + +import re class LangSpec: @@ -204,8 +206,61 @@ def emit_string(segment): return ''.join(out) +def normalize_ws(text: str, spec: LangSpec) -> str: + """Normalize layout outside literals, preserving semantic indentation. + + Literal placeholders keep whitespace normalization away from payload. + Continuation lines receive a nonblank marker so an empty string-content + line cannot be discarded later as a blank-only diff hunk. + """ + marker = '\x00' + while marker in text: + marker += '\x00' + literals: dict[str, str] = {} + parts = [] + i = 0 + quotes = re.compile('[{}]'.format(re.escape(spec.quotes))) if spec.quotes else None + while i < len(text): + match = quotes.search(text, i) if quotes else None + if match is None: + parts.append(text[i:]) + break + parts.append(text[i:match.start()]) + i = match.start() + triple = next((t for t in spec.triples if text.startswith(t, i)), None) + if triple: + end = triple_close(text, i + len(triple), triple) + if end < 0: + end = len(text) + else: + end = string_end(text, i, text[i], spec.escape, spec.doubled_quote) + key = '{}{}{}'.format(marker, len(literals), marker) + literals[key] = text[i:end].replace('\n', '\n' + marker) + parts.append(key) + i = end + + lines = [] + for line in ''.join(parts).split('\n'): + if spec is SPECS['yaml']: + # Plain YAML scalars also carry significant internal whitespace. + normalized = line.rstrip() if line.strip() else '' + else: + normalized = ' '.join(line.split()) + if spec is SPECS['python'] and normalized: + normalized = line[:len(line) - len(line.lstrip())] + normalized + lines.append(normalized) + out = '\n'.join(lines) + return re.sub(re.escape(marker) + r'\d+' + re.escape(marker), + lambda m: literals[m.group(0)], out) + + def shadow(text, spec): """Normalized shadow for a generic language: comments blanked, whitespace collapsed. The same order `c_shadow` / `a2l_shadow` use, so the shadow a variant is tested under matches the shadow ``compare_pair`` diffs on.""" - return collapse_ws(strip_comments(text, spec)) + if spec is SPECS['yaml'] and re.search(r'(?:^|[ \t])[|>][+\-0-9]*[ \t]*(?:#.*)?$', + text, re.M): + # Without parsing block-scalar boundaries, a '#' or blank line may + # be payload. Keep this file verbatim rather than erase its content. + return '\n'.join('\x00' + line for line in text.split('\n')) + return normalize_ws(strip_comments(text, spec), spec) diff --git a/compare_tool/main.py b/compare_tool/main.py index e9da0d9..7db0d5d 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -57,19 +57,20 @@ def run_compare(old_root, new_root, out, arxml_only=False, exclude=(), progress=None, reviews=None, theme_name=theme.DEFAULT, old_label=None, new_label=None, max_diff_lines=0, user_rules=(), skip_var_renames=False): - """Scan two trees and write the HTML report. + """Scan two trees and optionally write the HTML report. + + ``out=None`` returns the raw scan without touching any report file. ``old_label`` / ``new_label`` name the two sides in the report header when their folder names do not (``--baseline-name`` / ``--current-name``). - Returns (results, counts). Raises :class:`ReportWriteError` when the report - could not be written -- a run whose record does not exist is not a run that - may report success.""" - out = Path(out) + Returns (results, counts). Raises :class:`ReportWriteError` when a requested + report could not be written -- that failure must not look like success.""" + out = Path(out) if out is not None else None # delete a leftover report from an earlier run BEFORE scanning: if this # run dies, a stale report must not pass for this run's result try: - if out.exists(): + if out is not None and out.exists(): out.unlink() # `from None` here and below: _write_hint already folds the OSError into a # sentence, and the caller prints that sentence and exits 2 -- the chained @@ -82,6 +83,8 @@ def run_compare(old_root, new_root, out, arxml_only=False, exclude=(), include=include, user_rules=user_rules, skip_var_renames=skip_var_renames) counts = summarize(results) + if out is None: + return results, counts if arxml_only: # ALWAYS written: "no changes" must be an explicit statement, never # a silently absent file (indistinguishable from a run that died) @@ -99,9 +102,43 @@ def run_compare(old_root, new_root, out, arxml_only=False, exclude=(), return results, counts -def summary_lines(results, counts): +def _terminal_tree_lines(results): + """ASCII folder tree with a verdict for every scanned path.""" + root = {} + for rel in sorted(results): + parts = rel.replace('\\', '/').split('/') + node = root + for part in parts[:-1]: + node = node.setdefault(part + '/', {}) + node[parts[-1]] = results[rel]['status'] + lines = ['Folder tree:'] + + def walk(node, prefix): + entries = sorted(node, key=lambda name: (not name.endswith('/'), name)) + for index, name in enumerate(entries): + last = index == len(entries) - 1 + branch = '`-- ' if last else '|-- ' + value = node[name] + if isinstance(value, dict): + lines.append(prefix + branch + name) + walk(value, prefix + (' ' if last else '| ')) + else: + label = 'modified' if value == 'real-change' else value + lines.append('{}{}{} [{}]'.format(prefix, branch, name, label)) + + walk(root, '') + if not root: + lines.append(' (no files matched)') + return lines + + +def summary_lines(results, counts, tree=False): """Scan summary as plain-text lines the CLI prints: counts, uncompared - paths, modified files and the AUTOSAR/A2L semantic rollups.""" + paths, modified files and the AUTOSAR/A2L semantic rollups. + + ``tree=True`` replaces modified-file hunk counts with every scanned path + and its verdict. Error and consistency warnings are shared by both modes. + """ lines = [] lines.append('Summary: {real-change} modified, {comment-only} comment-only, ' '{ignorable-only} unimportant, {added} added, {deleted} deleted, ' @@ -121,7 +158,11 @@ def summary_lines(results, counts): if r['status'] == 'error': for note in r['notes']: lines.append(' !! {} -- {}'.format(rel, note)) - for rel, r in sorted(results.items()): + if tree: + lines.append('') + lines.extend(_terminal_tree_lines(results)) + modified_files = () if tree else sorted(results.items()) + for rel, r in modified_files: if r['status'] == 'real-change': n_real = sum(1 for h in r['hunks'] if h['kind'] == 'real') if 'binary' in r['notes']: @@ -130,6 +171,10 @@ def summary_lines(results, counts): lines.append(' MODIFIED {} ({} hunk(s){})'.format( rel, n_real, ', {} moved'.format(n_moved) if n_moved else '')) + if tree: + lines.append('') + lines.append('AUTOSAR / A2L changes:') + semantic_start = len(lines) if_added, if_removed = summarize_ifaces(results) if if_added or if_removed: lines.append('ARXML interfaces: {} added, {} removed'.format( @@ -179,6 +224,9 @@ def summary_lines(results, counts): for rel, n, kind in a2l_removed: lines.append(' - {} ({}) in {}'.format(n, kind, rel)) + if tree and len(lines) == semantic_start: + lines.append(' No extracted AUTOSAR/A2L changes.') + # 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) @@ -195,7 +243,8 @@ def _parser(): description='Compare two AUTOSAR codegen folders, filtering MATLAB noise ' '(comments, 1-1 renames, UUIDs, timestamps, whitespace). ' 'With both folders given it writes a self-contained HTML ' - 'report; without them it opens the side-by-side viewer.') + 'report (or a terminal summary with --no-report); without ' + 'them it opens the side-by-side viewer.') ap.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__)) ap.add_argument('old_dir', nargs='?', default=None, @@ -208,14 +257,22 @@ def _parser(): 'This is also what runs when no folders are given, so ' 'the flag is only needed to view folders passed on the ' 'command line instead of comparing them in the terminal') - ap.add_argument('--report', metavar='OUT.html', default=None, + reports = ap.add_mutually_exclusive_group() + reports.add_argument('--report', metavar='OUT.html', default=None, help='HTML report output path (default: compare_report.html, ' 'or arxml_update.html with --arxml-only)') + reports.add_argument('--no-report', action='store_true', + help='terminal summary only: show every scanned file ' + 'in a folder tree with its verdict, plus AUTOSAR ' + 'and A2L changes, without code diffs or an HTML ' + 'report. Requires both input paths; existing ' + 'reports are left untouched') ap.add_argument('--arxml-only', action='store_true', help='compare only ARXML/XML and A2L files and write a ' 'compact "what changed in the AUTOSAR model and ' 'calibration surface" report instead of the full ' - 'diff report; the report is ALWAYS written -- when ' + 'diff report; unless --no-report is used, the report ' + 'is ALWAYS written -- when ' 'nothing real changed it states "no changes" ' 'explicitly per file type') # A pipeline stages the baseline into a fixed scratch directory, so the @@ -299,8 +356,9 @@ def _parser(): def _wants_viewer(args): """The viewer is the default front end: the terminal compare runs only - when both folders are named on the command line.""" - return bool(args.qt or not (args.old_dir and args.new_dir)) + when both folders are named. --no-report keeps the console even for usage + errors, so a missing input or conflicting viewer flag can be reported.""" + return not args.no_report and bool(args.qt or not (args.old_dir and args.new_dir)) def viewer_requested(argv): @@ -379,6 +437,11 @@ def _load_user_rules(ap, args): def _run(ap, args, zip_temp): + if args.no_report: + if args.qt: + ap.error('--no-report cannot be combined with --qt/--viewer') + if not (args.old_dir and args.new_dir): + ap.error('--no-report requires both old_dir and new_dir') user_rules = _load_user_rules(ap, args) if _wants_viewer(args): from .qtviewer import run_viewer # deferred: PySide6 may be absent @@ -402,7 +465,7 @@ def _run(ap, args, zip_temp): for stream in (sys.stdout, sys.stderr): if hasattr(stream, 'reconfigure'): stream.reconfigure(errors='replace') - if args.report is None: + if args.report is None and not args.no_report: args.report = default_report_name(args.arxml_only) old_root, old_zip = _resolve_source(ap, args.old_dir, zip_temp) @@ -412,7 +475,9 @@ def _run(ap, args, zip_temp): ap.error('{} is not a directory: {}'.format(name, p)) reviews = None - if args.review: + if args.review and args.no_report: + print('note: --review has no effect with --no-report', file=sys.stderr) + if args.review and not args.no_report: reviews = review.ReviewStore.load(args.review) if reviews.error: # loud, not fatal: an unread review file leaves every change @@ -426,7 +491,10 @@ def _run(ap, args, zip_temp): print('note: --skip-var-renames has no effect with --arxml-only (it ' 'only ever folds C/C++ bindings)', file=sys.stderr) - out = Path(args.report) + out = Path(args.report) if args.report is not None else None + if args.no_report: + print('BASELINE: {}'.format(args.baseline_name or args.old_dir)) + print('CURRENT: {}'.format(args.current_name or args.new_dir)) print('Scanning...') def progress(done, total, rel): @@ -435,7 +503,8 @@ def progress(done, total, rel): try: results, counts = run_compare(old_root, new_root, out, args.arxml_only, - exclude=args.exclude, progress=progress, + exclude=args.exclude, + progress=None if args.no_report else progress, reviews=reviews, theme_name=args.theme, old_label=args.baseline_name or old_zip, new_label=args.current_name or new_zip, @@ -455,10 +524,10 @@ def progress(done, total, rel): # normal outcome, and a run that produced no report must not be # indistinguishable from it (--exit-zero cannot mask this either) return 2 - for line in summary_lines(results, counts): + for line in summary_lines(results, counts, tree=args.no_report): print(line) - if args.arxml_only: + if out is not None and args.arxml_only: if counts['real-change'] or counts['added'] or counts['deleted']: print('ARXML/A2L update report written: {}'.format(out.resolve())) elif counts['error']: @@ -466,7 +535,7 @@ def progress(done, total, rel): .format(out.resolve())) else: print('No ARXML/A2L changes -- report written: {}'.format(out.resolve())) - else: + elif out is not None: print('Report written: {}'.format(out.resolve())) code = _exit_code(counts, args.exit_zero) diff --git a/compare_tool/scanner.py b/compare_tool/scanner.py index c67a869..0d3aecf 100644 --- a/compare_tool/scanner.py +++ b/compare_tool/scanner.py @@ -152,8 +152,10 @@ def _single_info(root, rel, is_added): out = {} if looks_binary(path): return out + # Validate every one-sided text file, including formats without semantic + # extras, before move matching can attempt to decode it again. + text = read_text(path) if ruleset_for(rel) == 'arxml': - text = read_text(path) old_t, new_t = (None, text) if is_added else (text, None) d = arxml_rules.interface_diff(old_t, new_t) if d is not None: @@ -162,12 +164,10 @@ def _single_info(root, rel, is_added): if s is not None and not arxml_rules.swc_diff_empty(s): out['swc'] = s elif ruleset_for(rel) == 'a2l': - text = read_text(path) d = a2l_rules.a2l_diff(None, text) if is_added else a2l_rules.a2l_diff(text, None) if d['added'] or d['removed']: out['a2l'] = d elif rel.endswith('.c'): - text = read_text(path) d = c_rules.rte_diff(None, text) if is_added else c_rules.rte_diff(text, None) if d['added'] or d['removed']: out['rte'] = d @@ -227,19 +227,11 @@ def _shadow_lines(rel, text): def _candidate(root, rel): - """One side of a possible move, or None when the file cannot be read. - - Unreadable is not an error here: the file already has its own `added` / - `deleted` entry and stays in the report either way. Failing to pair it - costs a convenience, not a change. - """ + """Read one side of a possible move; the caller records read failures.""" path = Path(root) / rel ext = rel[rel.rfind('.'):].lower() if '.' in rel.rsplit('/', 1)[-1] else '' - try: - digest = hashlib.sha1(path.read_bytes()).hexdigest() - lines = None if looks_binary(path) else _shadow_lines(rel, read_text(path)) - except OSError: - return None + digest = hashlib.sha1(path.read_bytes()).hexdigest() + lines = None if looks_binary(path) else _shadow_lines(rel, read_text(path)) return filepair.Candidate(rel, ext, digest, lines) @@ -254,10 +246,18 @@ def _link_moves(results, old_root, new_root, user_rules=(), instead of leaving the reviewer to read both in full -- plus `move_status`, the verdict that pair WOULD have had if the path had not changed. """ - added = [c for c in (_candidate(new_root, rel) for rel, r in results.items() - if r['status'] == 'added') if c] - deleted = [c for c in (_candidate(old_root, rel) for rel, r in results.items() - if r['status'] == 'deleted') if c] + added, deleted = [], [] + for rel, result in results.items(): + status = result['status'] + if status not in ('added', 'deleted'): + continue + root, candidates = (new_root, added) if status == 'added' else (old_root, deleted) + try: + candidates.append(_candidate(root, rel)) + except (OSError, UnicodeError) as exc: + # A file can become unreadable after its initial validation. + results[rel] = _error_result('move candidate read failed: {}: {}'.format( + type(exc).__name__, exc)) for a_rel, (d_rel, sim) in filepair.find_moves(added, deleted).items(): try: pair = compare_pair(read_text(Path(old_root) / d_rel), diff --git a/docs/architecture.md b/docs/architecture.md index 5b83a01..9facbdd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,11 +183,17 @@ records the measurements, because the fast path the heuristic was bought with turns out not to be needed. - **Pass 2 decides the truth.** Each side is reduced to a *shadow*: comments - stripped, whitespace collapsed, UUIDs and dates and version stamps removed, + stripped, layout whitespace outside literals normalized, UUIDs and dates and version stamps removed, and for C a verified 1-to-1 rename map applied. Whatever still differs between the two shadows is a real change. The rename map is best-effort and then *checked* — it is applied to the old shadow and re-diffed, and any line it does not fully explain stays real. + `langspec.normalize_ws` preserves literal payload and Python/YAML indentation. + Literal continuation lines carry a nonblank shadow marker so blank-only hunk + filtering cannot erase an inserted empty line inside a string. The raw text + and raw line coordinates remain unchanged. Callee changes require matching + generated checksum roots before entering a rename map; RHS writes disqualify + a block from reorder folding. - **Pass 1 decides what you see.** The raw line diff keeps every textual difference, so the viewer can show the churn instead of pretending the files were identical. A raw hunk that intersects no real hunk is ignorable, and is @@ -386,7 +392,7 @@ everything else opens the viewer. ### CLI -`run_compare` deletes any leftover report *before* scanning — if this run dies, +With an output path, `run_compare` deletes any leftover report *before* scanning — if this run dies, a stale file from the previous one must not pass for its result. A report path that cannot be written raises `ReportWriteError` carrying the scan it could not write, so the terminal still prints what was found, and the run exits `2`: @@ -397,6 +403,12 @@ write, so the terminal still prints what was found, and the run exits `2`: | 1 | Real changes found (the CI gate) | | 2 | Compare INCOMPLETE — a path could not be listed, read or compared, or the report could not be written | +`--no-report` passes `out=None` to the same `run_compare` function. It returns +immediately after scanning and counting, before any HTML rendering or file +write. `summary_lines(..., tree=True)` prints all scanned paths and reuses the +existing semantic summaries and consistency advisories; it does not fold the +results or print source-code hunks. Old reports are untouched in this mode. + The exit code is a contract with somebody's pipeline. `--exit-zero` suppresses `1`, never `2` — an incomplete compare must never look green. diff --git a/docs/usage.md b/docs/usage.md index 0507f97..394ec92 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -21,14 +21,38 @@ This is the long version of everything the [README](../README.md) points to: eve python -m compare_tool [--report out.html] ``` +For a terminal summary without generating HTML: + +```bash +python -m compare_tool --no-report +``` + +This prints the counts, a folder tree including **every scanned file**, and the +existing AUTOSAR/A2L summaries: interfaces, SWCs, ports, runnables, events, RTE +access points and calibration objects. Files are labelled `modified`, `identical`, +`added`, `deleted`, `comment-only`, `ignorable-only`, or `error`. There are no code +diffs or hunk details. Folder entries organize the paths; verdicts belong to files. + +The report's consistency advisories are also printed: ARXML/A2L changes without +corresponding generated C changes, and added RTE access while a peer model stayed +identical. Compare failures and unsafe quick-check warnings remain visible. +Advisories do not change verdicts or exit codes. + +Both inputs are required; `--no-report` cannot be combined with `--report` or +`--qt`/`--viewer`. Existing HTML reports are left untouched. Filters, custom rules, +ZIP inputs and exit codes `0`/`1`/`2` work as usual; `--arxml-only` restricts the +tree and summaries to ARXML/XML/A2L. `--json` and `--sarif` still write files when +explicitly requested. `--review` has no effect in this mode. + Either side can be a `.zip` instead of a folder — an Azure DevOps build artifact, say. The tool unpacks it read-only into a temp directory, compares it like an ordinary folder, and deletes the temp copy on exit. If the archive has a single wrapper directory inside it, the tool descends into that automatically. The report header shows the zip's name rather than the temp path it was unpacked to (`--baseline-name` / `--current-name` still override that if you want something else). A zip that can't be read stops the run with a loud error — it never quietly falls through to comparing an empty folder. | Flag | Meaning | |---|---| +| `--no-report` | Print a complete file tree, AUTOSAR/A2L summaries and warnings to the terminal without creating HTML or printing code diffs | | `--report out.html` | Report output path (default `compare_report.html`). An existing file there is deleted before the scan starts | | `--exclude PATTERN` | Skip files matching a glob (relative path or bare file name), repeatable. Example: `--exclude compare_report.html` | | `--exit-zero` | Always exit 0 even when real changes exist (report-only mode for pipelines). Compare errors still exit 2 | -| `--arxml-only` | Scan only `.arxml`/`.xml`/`.a2l` and write a compact per-type report (default `arxml_update.html`) — always written, even when nothing changed | +| `--arxml-only` | Scan only `.arxml`/`.xml`/`.a2l`. Writes a compact per-type report (default `arxml_update.html`), even when nothing changed, unless `--no-report` is used | | `--review FILE` | Render notes and sign-offs from a review file (`codegen-review.json`, written by the viewer) next to the changes they belong to, plus a `Reviewed` badge that hides the changes already signed off. Must be named explicitly — a report must not pick up someone else's sign-off by accident; no effect with `--arxml-only` | | `--baseline-name NAME` | Name the BASELINE side in the report header instead of using its folder name. For a pipeline that stages the previous codegen into a fixed scratch directory, where `cg_temp` names the mechanism rather than the build. Example: `--baseline-name "build 4821"` | | `--current-name NAME` | Same for the CURRENT side. Either flag only changes the header text — the folder path stays in the tooltip, so a compare is still traceable to where the files were read from | @@ -130,11 +154,16 @@ Turning on `Review mode` adds a note box and a `Review` column to the tree — g | `sw-version` | `` version stamps (bumped on every regenerate). Anchored, so `` and the like are untouched | .arxml .xml | | `description` | ``, ``, `` — the prose an Identifiable carries (schema 4.2 and 4.4 alike). `` and `` are **not** included: the first is semantic, the second can carry tool payload | .arxml .xml | | `assumed-rename` | Assignments and declarations differing only by variable names, folded **without proof** — only with `--skip-var-renames`, never by default | .c .h .cpp .hpp | -| `whitespace` | Indentation, trailing spaces, blank lines | all | +| `whitespace` | Layout outside string literals. Python/YAML indentation and literal whitespace remain significant | all | | `line-endings` | CRLF vs LF, BOM | all | ### Renames +Changing a called function, such as `getSpeed()` to `getTorque()`, is a real +change even when the old name disappears entirely. Callee names are folded +only for a regenerated embedded checksum with the same remaining name. +Rename maps never rewrite string or character literal contents. + Auto-generated name churn gets recognised as a `rename`, but only under a fairly strict test. Two identifiers count as the same name only when the code generator plausibly owns both of them — a generated prefix (`rtb_`, `rtu_`, `rty_`, `rtDW`, `rtP`, `rtC`, `rtZC`, `localB`, `localDW`, and so on), a DWork field (`_DSTATE`, `_PreviousInput`, `_MODE`, `_SubsysRanBC`, …), or an embedded block-path checksum (`Sub_c4nxjoom3d_step` → `Sub_j2kqp1wxab_step`) — **and** they still share a root once the generated part is stripped away. That generated part is either a mangling suffix (`_c`, `_o4`) or a checksum (`rtb_AND_c4nxjoom3d` → `rtb_AND_j2kqp1wxab`); renumbered MATLAB Coder temporaries (`tmp`, `idx`, `loop_ub`, `i`) fall under the same rule. Sometimes a shorter name is enough to stop an argument list wrapping at 80 columns, which leaves the two sides holding the same statements over a different number of lines. That kind of hunk gets compared as one token stream instead, so where the newlines happen to fall stops mattering — token order still has to match exactly, though. @@ -145,12 +174,24 @@ Everything else keeps its suffix as meaning, which is the whole point of being t Regenerating a model routinely emits the same independent assignments — output ports, temporaries — in a different order, which a plain text diff reads as a change even though the block computes exactly the same values. A `reorder` fold recognises this case, but only where it can actually be **proven**, never guessed at: -- 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); +- every line on both sides is a side-effect-free scalar assignment (`ident = expr;` — no call, increment/decrement, nested assignment, store through an array/pointer/field, control flow, or declaration with a type); - the two sides hold the same statements, just in a different order; - the new order preserves **every data dependence** — whenever two statements share a variable and one of them writes it, their relative order hasn't changed. Two straight-line schedules that agree on the order of every dependent pair are guaranteed to compute the same result, so folding the reorder is behaviour-preserving, not a guess. If any of those three conditions fails — a call sneaks in between the lines, a right-hand side actually changed, a dependent pair got flipped — the whole block stays a real change. The rule errs toward calling a block real rather than toward hiding one; when in doubt, it shows you the diff. +### Significant whitespace + +String contents are compared exactly, including spaces and blank lines in +Python triple-quoted strings. Python and YAML indentation stays visible because +it can change scope or nesting. YAML plain scalar spacing also stays significant. +For YAML files containing block scalars (`|` or `>`), all textual changes remain +visible: the tool does not attempt to distinguish block content from comments. + +A corrupt text file on either side, including a file that was added or deleted, +is reported as `error`. Other files are still compared, and the CLI exits `2` +even with `--exit-zero`. + ### Quick check: skipping variable renames Every rule above proves its case before folding anything away. `--skip-var-renames` does not, and it is the only part of the tool that works this way — so it is off by default and has to be asked for by name. diff --git a/docs/vi/README.md b/docs/vi/README.md index da58969..9cb51c9 100644 --- a/docs/vi/README.md +++ b/docs/vi/README.md @@ -64,6 +64,15 @@ Có thể so sánh trực tiếp hai file ZIP: python -m compare_tool baseline.zip current.zip --report report.html ``` +### In summary trên terminal, không tạo report + +```bash +python -m compare_tool old_gen_folder new_gen_folder --no-report +``` + +In folder tree với verdict của mọi file, AUTOSAR/A2L changes và các cảnh báo +consistency giống HTML report. Không in code diff và không tạo file HTML. + ### Mở Desktop Viewer ```bash @@ -102,7 +111,7 @@ Cả hai đều sử dụng **cùng một comparison engine**, vì vậy kết q | ----------- | ---------------- | ------------------- | | Phù hợp với | Review trực tiếp | CI / automation | | Input | Folder / ZIP | Folder / ZIP | -| Output | Interactive diff | HTML / JSON / SARIF | +| Output | Interactive diff | Terminal summary / HTML / JSON / SARIF | | Build gate | — | Exit code | --- diff --git a/docs/vi/architecture.md b/docs/vi/architecture.md index 27bb2a6..2bd63ae 100644 --- a/docs/vi/architecture.md +++ b/docs/vi/architecture.md @@ -126,10 +126,15 @@ quy vào khoảng giữa, nên các đoạn còn lại đủ nhỏ để giao ch đường nhanh mà heuristic kia đánh đổi để có được là không cần thiết. - **Lượt 2 quyết định sự thật.** Mỗi bên được rút gọn thành một *shadow*: bóc - comment, gộp whitespace, bỏ UUID, ngày tháng, version stamp, và với C thì áp một + comment, chuẩn hoá whitespace định dạng ngoài literal, bỏ UUID, ngày tháng, version stamp, và với C thì áp một rename map đã được kiểm chứng. Cái gì còn khác nhau giữa hai shadow là thay đổi thật. Rename map chỉ là best-effort rồi *bị kiểm lại* — nó được áp lên shadow cũ và diff lại, dòng nào nó không giải thích trọn vẹn thì vẫn là real. + `langspec.normalize_ws` giữ nội dung literal và indentation Python/YAML. + Các dòng tiếp theo trong literal có marker không rỗng trong shadow để bước lọc + hunk trắng không bỏ mất dòng trống được thêm bên trong string. Text gốc và vị trí + dòng gốc không đổi. Đổi tên hàm được gọi chỉ vào rename map khi chung gốc checksum + do generator sinh; biểu thức RHS ghi vào biến khiến block không được gộp reorder. - **Lượt 1 quyết định cái bạn nhìn thấy.** Diff dòng thô giữ lại mọi khác biệt về text, nhờ vậy viewer hiện được đống rác thay vì giả vờ hai file y hệt nhau. Hunk thô nào không giao với hunk real nào thì là ignorable, và được *gán nhãn* bởi @@ -312,7 +317,7 @@ thư mục được nêu trên command line; còn lại đều mở viewer. ### CLI -`run_compare` xoá report cũ sót lại *trước khi* scan — nếu lần chạy này chết giữa +Khi có đường dẫn output, `run_compare` xoá report cũ sót lại *trước khi* scan — nếu lần chạy này chết giữa chừng, file cũ của lần trước không được phép bị hiểu thành kết quả của lần này. Đường dẫn report không ghi được sẽ ném `ReportWriteError` mang theo lần scan mà nó không ghi nổi, nên terminal vẫn in ra những gì tìm được, và lần chạy exit `2`: @@ -326,6 +331,12 @@ không ghi nổi, nên terminal vẫn in ra những gì tìm được, và lần Exit code là contract với pipeline của ai đó. `--exit-zero` dập được `1`, không bao giờ dập `2` — một lần compare không trọn vẹn không được phép trông xanh. +`--no-report` truyền `out=None` vào cùng hàm `run_compare`. Hàm trả về sau khi +scan và đếm verdict, trước khi render HTML hay ghi file. `summary_lines(..., +tree=True)` in mọi đường dẫn đã scan, dùng lại summary ngữ nghĩa và consistency +advisory hiện có; không fold kết quả hoặc in code hunk. Report cũ được giữ nguyên +trong chế độ này. + ### Viewer Scan phải duyệt đĩa, nên nó chạy trên một `QThread` (`qtviewer/worker.py`) và kết diff --git a/docs/vi/usage.md b/docs/vi/usage.md index d7eb6cf..d36e484 100644 --- a/docs/vi/usage.md +++ b/docs/vi/usage.md @@ -25,6 +25,29 @@ sao* thay vì chạy nó thế nào, phần đó nằm ở [architecture.md](arc python -m compare_tool [--report out.html] ``` +Để chỉ in summary trên terminal, không tạo HTML: + +```bash +python -m compare_tool --no-report +``` + +Lệnh in số đếm, folder tree chứa **mọi file đã scan**, và summary AUTOSAR/A2L hiện +có: interface, SWC, port, runnable, event, RTE access point và calibration object. +Mỗi file có nhãn `modified`, `identical`, `added`, `deleted`, `comment-only`, +`ignorable-only` hoặc `error`. Không in code diff hay chi tiết hunk. Các folder +dùng để nhóm đường dẫn; verdict được ghi ở từng file. + +Các cảnh báo consistency giống report cũng được in: ARXML/A2L thay đổi nhưng +generated C không đổi, hoặc thêm RTE access trong khi model khác vẫn identical. +Lỗi compare và cảnh báo quick check không an toàn vẫn hiện đầy đủ. Advisory +không làm đổi verdict hoặc exit code. + +Phải truyền đủ hai input; không dùng cùng `--report` hoặc `--qt`/`--viewer`. +HTML report cũ được giữ nguyên. Filter, custom rule, input ZIP và exit code +`0`/`1`/`2` vẫn như cũ; `--arxml-only` giới hạn tree và summary ở ARXML/XML/A2L. +`--json` và `--sarif` vẫn ghi file nếu được chỉ định. `--review` không có tác dụng +trong chế độ này. + Mỗi vị trí có thể là một file `.zip` thay vì thư mục — ví dụ artifact build tải từ Azure DevOps. Tool giải nén nó read-only vào một thư mục tạm, so sánh như một thư mục bình thường, rồi xoá thư mục tạm đó khi thoát. Nếu archive chỉ có đúng @@ -35,10 +58,11 @@ giờ âm thầm rơi xuống so sánh một thư mục rỗng. | Flag | Ý nghĩa | |---|---| +| `--no-report` | In đầy đủ file tree, summary AUTOSAR/A2L và cảnh báo trên terminal; không tạo HTML hay in code diff | | `--report out.html` | Đường dẫn report (mặc định `compare_report.html`). File cũ ở đó bị xoá trước khi scan bắt đầu | | `--exclude PATTERN` | Bỏ qua file khớp glob (đường dẫn tương đối hoặc tên file trần), lặp lại được. Ví dụ: `--exclude compare_report.html` | | `--exit-zero` | Luôn exit 0 kể cả khi có thay đổi thật (chế độ chỉ ghi report cho pipeline). Lỗi compare vẫn exit 2 | -| `--arxml-only` | Chỉ scan `.arxml`/`.xml`/`.a2l` và ghi report gọn theo từng loại file (mặc định `arxml_update.html`) — luôn được ghi, kể cả khi không có gì đổi | +| `--arxml-only` | Chỉ scan `.arxml`/`.xml`/`.a2l`. Ghi report gọn theo từng loại file (mặc định `arxml_update.html`), kể cả khi không có gì đổi, trừ khi dùng `--no-report` | | `--review FILE` | Render note và sign-off từ review file (`codegen-review.json`, do viewer ghi) ngay cạnh change tương ứng, kèm badge `Reviewed` để ẩn các change đã ký duyệt. Phải chỉ tên tường minh — một report không được vô tình mang sign-off của người khác; không có tác dụng với `--arxml-only` | | `--baseline-name NAME` | Đặt tên phía BASELINE trên header report thay vì lấy tên thư mục. Dành cho pipeline luôn dựng bản codegen cũ vào một thư mục tạm cố định, chỗ mà `cg_temp` là tên của cơ chế chứ không phải của bản build. Ví dụ: `--baseline-name "build 4821"` | | `--current-name NAME` | Tương tự cho phía CURRENT. Cả hai cờ chỉ đổi chữ trên header — đường dẫn thư mục vẫn nằm ở tooltip, nên vẫn truy được file đã đọc từ đâu | @@ -179,7 +203,7 @@ trên cây vẫn nằm trong file export với verdict thật của nó. | `sw-version` | Version stamp `` (tăng mỗi lần regenerate). Regex có anchor, nên `` và các thẻ tương tự không bị đụng | .arxml .xml | | `description` | ``, ``, `` — các thẻ chứa mô tả bằng chữ, không ảnh hưởng hành vi (áp dụng cho cả schema 4.2 và 4.4). `` và `` **không** được lọc: `` ảnh hưởng cách phần tử được hiểu, còn `` có thể chứa dữ liệu do tool khác ghi vào | .arxml .xml | | `assumed-rename` | Lệnh gán và khai báo chỉ khác nhau ở tên biến, gộp **không kèm chứng minh** — chỉ xuất hiện khi bật `--skip-var-renames`, mặc định không bao giờ | .c .h .cpp .hpp | -| `whitespace` | Thụt đầu dòng, khoảng trắng cuối dòng, dòng trống | tất cả | +| `whitespace` | Khoảng trắng định dạng ngoài string literal. Indentation Python/YAML và khoảng trắng trong literal vẫn có ý nghĩa | tất cả | | `line-endings` | CRLF vs LF, BOM | tất cả | ### Rename @@ -212,6 +236,11 @@ các thay đổi thật, không phải rename: - `Sub_…_step` → `Sub_…_Init`: entry point khác hẳn. - `rtb_Switch1` → `rtb_Switch2`: chữ số dính liền tên block là một phần của tên, không phải đuôi mangling. +Đổi hàm được gọi, chẳng hạn `getSpeed()` thành `getTorque()`, là thay đổi thật +kể cả khi tên cũ biến mất hoàn toàn. Tên hàm chỉ được gộp khi thay checksum do +generator sinh và phần tên còn lại giữ nguyên. Rename map không sửa nội dung +string hoặc character literal. + ### Reorder Regenerate một model thường xuyên sinh ra cùng những phép gán độc lập — output @@ -219,7 +248,7 @@ port, biến tạm — theo thứ tự khác, thứ mà một text diff thuần đổi dù block tính ra đúng y hệt giá trị cũ. Một lần gộp `reorder` nhận ra trường hợp này, nhưng chỉ khi có thể **chứng minh** được, không bao giờ đoán: -- mọi dòng ở cả hai bên đều là phép gán scalar không side-effect (`ident = expr;` — không call, không ghi qua array/pointer/field, không control flow, không khai báo kèm kiểu); +- mọi dòng ở cả hai bên đều là phép gán scalar không side-effect (`ident = expr;` — không call, tăng/giảm biến, phép gán lồng, ghi qua array/pointer/field, control flow hoặc khai báo kèm kiểu); - hai bên chứa đúng cùng các câu lệnh, chỉ đảo thứ tự; - thứ tự mới giữ nguyên **mọi phụ thuộc dữ liệu** — hễ hai câu lệnh chung một biến và một trong hai ghi vào biến đó, thứ tự tương đối của chúng không đổi. @@ -230,6 +259,17 @@ vào giữa, vế phải của một phép gán thật sự đổi, hay một c đảo thứ tự — thì cả block vẫn tính là thay đổi thật. Khi không chắc, tool luôn chọn hiện diff ra chứ không giấu đi. +### Khoảng trắng có ý nghĩa + +Nội dung string được so sánh chính xác, kể cả khoảng trắng và dòng trống trong +triple-quoted string của Python. Indentation Python/YAML được giữ vì có thể đổi +scope hoặc cấu trúc lồng nhau. Khoảng trắng trong plain scalar YAML cũng được giữ. +Với file YAML chứa block scalar (`|` hoặc `>`), mọi thay đổi text đều hiện ra: +tool không tự phân biệt nội dung block với comment. + +File text hỏng ở một phía, kể cả file added hoặc deleted, nhận verdict `error`. +Các file khác vẫn được so sánh; CLI trả exit code `2` kể cả khi bật `--exit-zero`. + ### Quick check: bỏ qua đổi tên biến Mọi rule ở trên đều chứng minh được trước khi gộp một khác biệt đi. diff --git a/report_quick.html b/report_quick.html new file mode 100644 index 0000000..4b049f3 --- /dev/null +++ b/report_quick.html @@ -0,0 +1,262 @@ +AUTOSAR Code Generation Report

AUTOSAR Code Generation Report

BASELINE gen_old → CURRENT gen_new · 2026-09-11 21:54:52
1 Modified0 Added0 Deleted3 Unimportant
⚠ QUICK CHECK (--skip-var-renames): variable renames folded without proof in 3 file(s), labelled Assumed rename — a rewiring has the same shape, so a real change can be missing.

AUTOSAR changes

No AUTOSAR-level changes (interfaces, ports, runnables, events, RTE access points, A2L objects).

Folder tree

Detailed changes

/Removed / Added Moved, not changed Unimportant (revealed)
swc_ctrl/Ctrl_real.c Modified Affected: Ctrl_out
ƒ Ctrl_out
33
4void Ctrl_out(void)4void Ctrl_out(void)
5{5{
6 boolean_T armed = FALSE;6 boolean_T armed = TRUE;
7 sint32 limit = 100;7 sint32 limit = 250;
88
9 mode = IDLE;9 mode = DRIVE;
10 gain = base;10 gain = limit;
11 torque = gain * 2.0;11 torque = gain * 3.0;
12}12}
1313
swc_ctrl/Ctrl_bindings.c Unimportant Assumed rename
ƒ Ctrl_step
33
4void Ctrl_step(void)4void Ctrl_step(void)
5{5{
6 acc_cmd = rtU.Pedal;6 drv_cmd = rtU.Pedal;
7 brk_cmd = rtU.Brake;7 dec_cmd = rtU.Brake;
8 trq_out = acc_cmd;8 trq_out = drv_cmd;
⋯ 3 minor (assumed-rename) lines hidden
99
10 Log(acc_cmd);10 Log(acc_cmd);
11 Log(brk_cmd);11 Log(brk_cmd);
swc_ctrl/Ctrl_decl.c Unimportant Assumed rename
ƒ Ctrl_calc
33
4void Ctrl_calc(void)4void Ctrl_calc(void)
5{5{
6 real_T filt_in;6 real_T filt_val;
⋯ 1 minor (assumed-rename) line hidden
77
8 filt_in = rtU.Raw;8 filt_val = rtU.Raw;
9 rtY.Out = filt_in;9 rtY.Out = filt_val;
⋯ 2 minor (assumed-rename) lines hidden
10 Log(filt_in);10 Log(filt_in);
11}11}
1212
swc_ctrl/Ctrl_init.c Unimportant Assumed rename, Rename ×2
ƒ Ctrl_init
33
4void Ctrl_init(void)4void Ctrl_init(void)
5{5{
6 boolean_T enable_flag = FALSE;6 boolean_T drive_ready = FALSE;
7 sint32 retry_count = 0;7 sint32 attempt_no = 0;
⋯ 2 minor (rename) lines hidden
8 real_T ramp_gain = 1.0;8 real_T slope_gain = 1.0;
⋯ 1 minor (assumed-rename) line hidden
99
10 Use(enable_flag, retry_count, ramp_gain);10 Use(drive_ready, attempt_no, ramp_gain);
11 Use(enable_flag, retry_count, ramp_gain);11 Use(drive_ready, attempt_no, ramp_gain);
⋯ 2 minor (rename) lines hidden
12}12}
1313
\ No newline at end of file diff --git a/tests/test_cli_modes.py b/tests/test_cli_modes.py index 9cbc68f..1b0b19f 100644 --- a/tests/test_cli_modes.py +++ b/tests/test_cli_modes.py @@ -7,8 +7,12 @@ """ import io +import json +import tempfile import unittest from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path +from unittest import mock from compare_tool.main import main, viewer_requested @@ -161,6 +165,172 @@ def test_an_unreadable_zip_is_a_fatal_usage_error(self): quiet(main, [str(empty), str(tmp), '--report', str(tmp / 'o.html')]) +class TestNoReport(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + self.old = self.root / 'old' + self.new = self.root / 'new' + self.old.mkdir() + self.new.mkdir() + + def _file(self, root, rel, text): + path = root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding='utf-8') + + def _run(self, *flags): + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = main([str(self.old), str(self.new), '--no-report', *flags]) + return code, out.getvalue(), err.getvalue() + + def test_no_report_generation_or_existing_report_changes(self): + stale = self.root / 'compare_report.html' + stale.write_text('existing report', encoding='utf-8') + for side in (self.old, self.new): + self._file(side, 'same.c', 'int value = 1;\n') + before = sorted(self.root.rglob('*')) + with mock.patch('compare_tool.main.default_report_name', return_value=str(stale)), \ + mock.patch('compare_tool.main.build_report') as report, \ + mock.patch('compare_tool.main.build_arxml_report') as arxml_report: + code, output, errors = self._run() + self.assertEqual(code, 0) + self.assertEqual(errors, '') + self.assertIn('same.c [identical]', output) + self.assertIn('No extracted AUTOSAR/A2L changes.', output) + self.assertNotIn('Report written:', output) + self.assertEqual(stale.read_text(encoding='utf-8'), 'existing report') + self.assertEqual(sorted(self.root.rglob('*')), before) + report.assert_not_called() + arxml_report.assert_not_called() + + def test_tree_includes_every_verdict_without_code_or_hunk_details(self): + pairs = { + 'model/changed.c': ('int value = 1;\n', 'int value = 2;\n'), + 'model/same.h': ('int same;\n', 'int same;\n'), + 'comment.c': ('int c; // old\n', 'int c; // new\n'), + 'noise.c': ('int n;\n', 'int n;\n'), + } + for rel, (old, new) in pairs.items(): + self._file(self.old, rel, old) + self._file(self.new, rel, new) + self._file(self.old, 'removed.txt', 'removed') + self._file(self.new, 'added.txt', 'new') + (self.new / 'bad.txt').write_bytes(b'\xff\xfe\x41') + code, output, _ = self._run() + self.assertEqual(code, 2) + for name, status in (('changed.c', 'modified'), ('same.h', 'identical'), + ('comment.c', 'comment-only'), ('noise.c', 'ignorable-only'), + ('removed.txt', 'deleted'), ('added.txt', 'added'), + ('bad.txt', 'error')): + self.assertIn('{} [{}]'.format(name, status), output) + self.assertIn('|-- model/\n| |-- changed.c [modified]', output) + self.assertIn('COMPARE INCOMPLETE', output) + self.assertNotIn('int value', output) + self.assertNotIn('hunk(s)', output) + + def test_autosar_and_a2l_summaries_use_the_scan(self): + from compare_tool.main import summary_lines + from compare_tool.scanner import scan, summarize + fixture = Path(__file__).parent / 'fixtures' / 'demo' + self.old, self.new = fixture / 'old', fixture / 'new' + results = scan(self.old, self.new) + code, output, _ = self._run() + self.assertEqual(code, 1) + for heading in ('ARXML interfaces:', 'AUTOSAR behavior:', + 'RTE access points:', 'A2L objects:'): + self.assertIn(heading, output) + for line in summary_lines(results, summarize(results)): + if not line.startswith(' MODIFIED'): + self.assertIn(line, output) + + def test_report_consistency_warnings_remain_visible_with_exit_zero(self): + from compare_tool.report import consistency_advisories + from compare_tool.scanner import scan + fixture = Path(__file__).parent / 'fixtures' / 'demo' + self.old, self.new = fixture / 'old', fixture / 'new' + advisories = consistency_advisories(scan(self.old, self.new)) + code, output, _ = self._run('--exit-zero') + self.assertEqual(code, 0) + self.assertTrue(any('generated C did not' in msg for _, msg in advisories)) + self.assertTrue(any('regenerate the architecture' in msg for _, msg in advisories)) + for model, message in advisories: + self.assertIn('!! {}: {}'.format(model, message), output) + + def test_filters_and_no_report_work_together(self): + self._file(self.old, 'model.c', 'int value = 1;\n') + self._file(self.new, 'model.c', 'int value = 2;\n') + self._file(self.new, 'model.arxml', '\n') + self._file(self.new, 'skip.a2l', '/begin PROJECT P ""\n/end PROJECT\n') + code, output, _ = self._run('--arxml-only', '--exclude', 'skip.a2l') + self.assertEqual(code, 1) + self.assertIn('model.arxml [added]', output) + self.assertNotIn('model.c', output) + self.assertNotIn('skip.a2l', output) + self.assertNotIn('report written', output) + + def test_exit_zero_only_suppresses_real_changes(self): + self._file(self.new, 'added.txt', 'new') + self.assertEqual(self._run()[0], 1) + self.assertEqual(self._run('--exit-zero')[0], 0) + (self.new / 'bad.txt').write_bytes(b'\xff\xfe\x41') + self.assertEqual(self._run('--exit-zero')[0], 2) + + def test_empty_filtered_tree_is_explicit(self): + code, output, _ = self._run() + self.assertEqual(code, 0) + self.assertIn('(no files matched)', output) + self.assertIn('Summary: 0 modified', output) + + def test_json_and_sarif_remain_opt_in(self): + self._file(self.new, 'added.txt', 'new') + json_path, sarif_path = self.root / 'scan.json', self.root / 'scan.sarif' + code, _, _ = self._run('--json', str(json_path), '--sarif', str(sarif_path)) + self.assertEqual(code, 1) + self.assertEqual(json.loads(json_path.read_text(encoding='utf-8'))['exit_code'], code) + self.assertEqual(json.loads(sarif_path.read_text(encoding='utf-8'))['version'], '2.1.0') + self.assertEqual(list(self.root.glob('*.html')), []) + + def test_rules_and_quick_check_are_still_applied(self): + self._file(self.old, 'model.cpp', 'out = input_a;\n') + self._file(self.new, 'model.cpp', 'out = input_b;\n') + code, output, _ = self._run('--skip-var-renames') + self.assertEqual(code, 0) + self.assertIn('QUICK CHECK', output) + self.assertIn('model.cpp [ignorable-only]', output) + self._file(self.old, 'stamp.txt', 'Build 100\n') + self._file(self.new, 'stamp.txt', 'Build 101\n') + rules = self.root / 'rules.json' + rules.write_text(json.dumps([{'name': 'build-stamp', 'pattern': r'Build \d+', + 'extensions': ['.txt']}]), encoding='utf-8') + code, output, _ = self._run('--rules', str(rules)) + self.assertIn('stamp.txt [ignorable-only]', output) + + def test_invalid_combinations_keep_console_and_raise_usage_error(self): + for argv in (['--no-report'], ['old', '--no-report'], + ['old', 'new', '--no-report', '--qt'], + ['old', 'new', '--no-report', '--report', 'out.html']): + with self.subTest(argv=argv): + self.assertFalse(quiet(viewer_requested, argv)) + with self.assertRaises(SystemExit) as raised: + quiet(main, argv) + self.assertEqual(raised.exception.code, 2) + + def test_zip_sources_and_side_labels(self): + import zipfile + for name in ('baseline.zip', 'current.zip'): + with zipfile.ZipFile(self.root / name, 'w') as archive: + archive.writestr('gen/same.c', 'int same;\n') + self.old, self.new = self.root / 'baseline.zip', self.root / 'current.zip' + code, output, _ = self._run('--baseline-name', 'build 100', '--current-name', 'build 101') + self.assertEqual(code, 0) + self.assertIn('BASELINE: build 100', output) + self.assertIn('CURRENT: build 101', output) + self.assertIn('same.c [identical]', output) + + class TestVersionFlag(unittest.TestCase): def test_version_prints_the_package_version_and_exits_zero(self): from compare_tool import __version__ diff --git a/tests/test_engine.py b/tests/test_engine.py index d6d93cb..0f8619e 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -307,6 +307,95 @@ def test_empty_files(self): self.assertEqual(r2['status'], 'real-change') +class TestCoreSafety(unittest.TestCase): + def test_literal_whitespace_is_real(self): + cases = [ + ('f.c', 'const char *s = "a b";\n'), + ('f.cpp', 'const char *s = "a b";\n'), + ('f.json', '{"value": "a b"}\n'), + ('f.py', 'value = "a b"\n'), + ('f.yaml', 'value: "a b"\n'), + ('f.a2l', '/begin PROJECT P "a b"\n/end PROJECT\n'), + ] + for path, old in cases: + with self.subTest(path=path): + result = compare_pair(old, old.replace('a b', 'a b'), path) + self.assertEqual(result['status'], 'real-change') + self.assertIn('real', kinds(result)) + + def test_layout_outside_literals_is_still_noise(self): + for path in ('f.c', 'f.cpp', 'f.json', 'f.a2l'): + with self.subTest(path=path): + result = compare_pair('value = "a b";\n', + ' value = "a b"; \n', path) + self.assertEqual(result['status'], 'ignorable-only') + + def test_indentation_changes_scope(self): + cases = [ + ('f.py', 'if enabled:\n output = 1\n counter = 2\n', + 'if enabled:\n output = 1\ncounter = 2\n'), + ('f.yaml', 'model:\n enabled: true\n gain: 2\n', + 'model:\n enabled: true\ngain: 2\n'), + ] + for path, old, new in cases: + with self.subTest(path=path): + self.assertEqual(compare_pair(old, new, path)['status'], 'real-change') + + def test_multiline_literal_whitespace_and_blank_lines_are_real(self): + old = 'value = """first\n second\nlast"""\n' + for new in (old.replace(' second', ' second'), + old.replace('second\n', 'second\n\n'), + old.replace('second\n', 'second\n \n')): + with self.subTest(new=new): + self.assertEqual(compare_pair(old, new, 'f.py')['status'], 'real-change') + + def test_yaml_plain_and_block_scalar_payload_is_real(self): + cases = [('value: a b\n', 'value: a b\n'), + ('value: |\n a\n # old\n', 'value: |\n a\n # new\n'), + ('|\n a\n # old\n', '|\n a\n # new\n'), + ('value: |\n a\n b\n', 'value: |\n a\n\n b\n')] + for old, new in cases: + with self.subTest(new=new): + self.assertEqual(compare_pair(old, new, 'f.yaml')['status'], 'real-change') + + def test_comment_noise_beside_literal_change_stays_real(self): + result = compare_pair('// old\nconst char *s = "a b";\n', + '// new\nconst char *s = "a b";\n', 'f.c') + self.assertEqual(result['status'], 'real-change') + self.assertIn('comment', kinds(result)) + self.assertIn('real', kinds(result)) + + def test_external_callee_change_is_real(self): + for old_name, new_name in (('getSpeed', 'getTorque'), ('get_x', 'get_y')): + with self.subTest(old=old_name, new=new_name): + template = '#include "inputs.h"\nvoid step(void) {{ output = {}(); }}\n' + result = compare_pair(template.format(old_name), template.format(new_name), 'f.c') + self.assertEqual(result['status'], 'real-change') + self.assertEqual(result['renames'], {}) + + def test_callee_change_beside_generated_rename_stays_real(self): + result = compare_pair('int rtb_A;\nrtb_A = getSpeed();\n', + 'int rtb_B;\nrtb_B = getTorque();\n', 'f.c') + self.assertEqual(result['status'], 'real-change') + self.assertEqual(result['renames'], {'rtb_A': 'rtb_B'}) + + def test_rename_never_rewrites_a_literal(self): + result = compare_pair('int rtb_A;\nlog("rtb_A");\n', + 'int rtb_B;\nlog("rtb_B");\n', 'f.c') + self.assertEqual(result['status'], 'real-change') + self.assertIn('real', kinds(result)) + + def test_rhs_writes_cannot_be_reordered(self): + for expr in ('i++', '--i', '(i = 1)', '(i += 1)', '(i <<= 1)', '(i >>= 1)'): + with self.subTest(expr=expr): + first, second = 'a = {};\n'.format(expr), 'b = i;\n' + before = 'int a, b, i;\nvoid step(void) {\n' + result = compare_pair(before + first + second + '}\n', + before + second + first + '}\n', 'f.c') + self.assertEqual(result['status'], 'real-change') + self.assertNotIn('reorder', kinds(result)) + + class TestAutogenNoise(unittest.TestCase): # rtb_* suffix reshuffle across two functions: the strict 1-1 map is # rejected (names reused on both sides), the autogen rule catches it diff --git a/tests/test_failsafe.py b/tests/test_failsafe.py index c157eb9..3c9e76c 100644 --- a/tests/test_failsafe.py +++ b/tests/test_failsafe.py @@ -47,6 +47,37 @@ def tearDown(self): class TestScanErrors(_TreeCase): + def test_corrupt_one_sided_text_is_error_and_other_files_are_compared(self): + for side in (self.old, self.new): + for name in ('bad.txt', 'bad.py', 'bad.h', 'bad.arxml'): + with self.subTest(side=side.name, name=name): + path = side / name + path.write_bytes(b'\xff\xfe\x41') + try: + results = scan(self.old, self.new) + self.assertEqual(results[name]['status'], 'error') + self.assertIn('UnicodeDecodeError', results[name]['notes'][0]) + self.assertEqual(results['a.c']['status'], 'real-change') + self.assertEqual(results['b.c']['status'], 'identical') + finally: + path.unlink() + + def test_corrupt_one_sided_text_keeps_exit_two_with_exit_zero(self): + (self.new / 'bad.txt').write_bytes(b'\xff\xfe\x41') + report = Path(self.tmp.name) / 'report.html' + with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()): + code = main([str(self.old), str(self.new), '--report', str(report), '--exit-zero']) + self.assertEqual(code, 2) + self.assertIn('bad.txt', report.read_text(encoding='utf-8')) + + def test_move_candidate_read_failure_is_error(self): + (self.new / 'added.txt').write_text('new file', encoding='utf-8') + with mock.patch.object(scanner, '_candidate', side_effect=OSError('read failed')): + results = scan(self.old, self.new) + self.assertEqual(results['added.txt']['status'], 'error') + self.assertIn('read failed', results['added.txt']['notes'][0]) + self.assertEqual(results['a.c']['status'], 'real-change') + def test_compare_error_recorded_other_files_still_compared(self): with mock.patch.object(scanner, 'compare_file', _boom_on('a.c')): results = scan(self.old, self.new)