From 252fa8486f3d3ea8af21029eebed2307582ad09c Mon Sep 17 00:00:00 2001 From: longvo920 Date: Sun, 27 Sep 2026 19:27:37 +0700 Subject: [PATCH] feat: add opt-in codegen consistency check --- CHANGELOG.md | 6 + README.md | 7 +- compare_tool/__init__.py | 2 +- compare_tool/consistency.py | 622 +++++++++++++++++++++++++++++++++++- compare_tool/main.py | 151 ++++++++- docs/architecture.md | 2 +- docs/usage.md | 57 +++- docs/vi/README.md | 7 +- docs/vi/architecture.md | 2 +- docs/vi/usage.md | 57 +++- tests/test_cli_modes.py | 61 ++++ tests/test_consistency.py | 98 ++++++ 12 files changed, 1036 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bfe424..84a2139 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project are documented here. Versions follow ## [Unreleased] +## [1.18.0] — 2026-09-27 + +### Added + +- Add opt-in current-tree RTE/ARXML consistency validation and CI gating to CLI comparisons. + ## [1.17.0] — 2026-09-27 ### Changed diff --git a/README.md b/README.md index 7f8f89d..dc4becb 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ Print only a terminal summary: python -m compare_tool baseline current --no-report ``` -The terminal mode shows a per-model Overview, AUTOSAR/A2L changes, consistency warnings, effective compare settings, hunk-classification totals and one summary for each non-identical file. It does not print individual hunks, source diffs or create HTML. +The terminal mode shows a per-model Overview, AUTOSAR/A2L changes, consistency warnings, effective compare settings, hunk-classification totals and one summary for each non-identical file. Add `--check-consistency` to validate CURRENT generated RTE calls against CURRENT ARXML declarations. It does not print individual hunks, source diffs or create HTML. Open the desktop viewer: @@ -72,9 +72,10 @@ The comparison engine also reports: - ports, port interfaces, runnables and events; - `Rte_*` access points; - A2L `CHARACTERISTIC` and `MEASUREMENT` objects; -- possible incomplete regeneration across ARXML, A2L and generated C. +- possible incomplete regeneration across ARXML, A2L and generated C; +- generated RTE calls that disagree with CURRENT ARXML access declarations when `--check-consistency` is enabled. -Consistency warnings are advisory. They do not change file verdicts or exit codes. +Old/new regeneration warnings remain advisory. The optional CURRENT-tree check is disabled by default; when enabled, a `fail` finding exits `1`, an incomplete consistency scan exits `2`, and a `warn` finding does not change the exit code. No consistency result changes a file verdict. ## Verdicts diff --git a/compare_tool/__init__.py b/compare_tool/__init__.py index 410f351..d887692 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.17.0" +__version__ = "1.18.0" diff --git a/compare_tool/consistency.py b/compare_tool/consistency.py index 628da1f..8b60d37 100644 --- a/compare_tool/consistency.py +++ b/compare_tool/consistency.py @@ -34,10 +34,27 @@ 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. +The CLI also validates the CURRENT tree's generated RTE calls against its +ARXML access-point declarations. That current-tree pass is intentionally +separate from the old/new advisories above: it describes whether one generated +snapshot is internally coherent, while the original rules describe whether a +regeneration batch was complete. + +Stdlib only, no Qt: the report and the CLI both import this module. """ +from __future__ import annotations + +import fnmatch +import re +import xml.etree.ElementTree as ET +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from .c_rules import strip_c_comments from .diff_engine import ruleset_for +from .scanner import list_files, looks_binary, read_text # 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" @@ -48,6 +65,609 @@ _SURFACES = (('arxml', 'ARXML'), ('a2l', 'A2L')) +@dataclass(frozen=True) +class RteCall: + """One generated RTE call and the C function that contains it.""" + + file: str + line: int + function: Optional[str] + api: str + api_type: str + + +@dataclass(frozen=True) +class FunctionInfo: + """Generated C function used to resolve an AUTOSAR runnable.""" + + name: str + + +@dataclass(frozen=True) +class AccessPoint: + """One Sender/Receiver access declared below an ARXML runnable.""" + + runnable: str + port: str + data_element: str + direction: str + access_mode: str + container: str + source_file: str + + +@dataclass +class CurrentTreeCheck: + """Result of the CLI-only RTE/ARXML consistency pass.""" + + findings: List[Dict[str, Any]] + skipped_accesses: List[Dict[str, Any]] + skipped_rules: int + ignored_accesses: int + c_files_scanned: int + arxml_files_scanned: int + functions_detected: int + rte_calls_detected: int + access_points_detected: int + + +_RTE_CALL_RE = re.compile( + r'\b(Rte_(?:IRead|IWrite|Read|Write|Call)_[A-Za-z0-9_]+)\s*\(' +) +_AUTOSAR_FUNCTION_RE = re.compile( + r'\bFUNC\s*\([^)]*\)\s*([A-Za-z_][A-Za-z0-9_]*)' + r'\s*\([^;{}]*\)\s*\{', + re.MULTILINE, +) +_NORMAL_FUNCTION_RE = re.compile( + r'(?:^|\n)\s*(?:(?:static|inline|extern)\s+)*' + r'[A-Za-z_][A-Za-z0-9_]*(?:\s*\*)*\s+' + r'([A-Za-z_][A-Za-z0-9_]*)\s*\([^;{}]*\)\s*\{', + re.MULTILINE, +) +_ARXML_ACCESS_CONTAINERS = { + 'DATA-READ-ACCESSS': ('READ', 'IMPLICIT'), + 'DATA-WRITE-ACCESSS': ('WRITE', 'IMPLICIT'), + 'DATA-RECEIVE-POINT-BY-ARGUMENTS': ('READ', 'EXPLICIT'), + 'DATA-RECEIVE-POINT-BY-VALUES': ('READ', 'EXPLICIT'), + 'DATA-SEND-POINTS': ('WRITE', 'EXPLICIT'), +} +_CHECKER_RULES = frozenset(( + 'forbidden_rte_api', + 'no_matching_port_dataelement', + 'missing_access_point_in_arxml', + 'inconsistent_arxml_access_mode', + 'access_mode_mismatch', + 'orphan_access', +)) +DEFAULT_CHECKER_CONFIG = { + 'forbidden_rte_prefixes': ['Rte_IRead_', 'Rte_IWrite_'], + 'ignore_access_port_regex': [], + 'rules': { + 'forbidden_rte_api': 'fail', + 'no_matching_port_dataelement': 'fail', + 'missing_access_point_in_arxml': 'fail', + 'inconsistent_arxml_access_mode': 'fail', + 'access_mode_mismatch': 'fail', + 'orphan_access': 'warn', + }, +} + + +def _checker_defaults(): + return { + 'forbidden_rte_prefixes': list( + DEFAULT_CHECKER_CONFIG['forbidden_rte_prefixes']), + 'ignore_access_port_regex': list( + DEFAULT_CHECKER_CONFIG['ignore_access_port_regex']), + 'rules': dict(DEFAULT_CHECKER_CONFIG['rules']), + } + + +def _strip_yaml_value(value): + value = value.strip() + if (len(value) >= 2 and value[0] == value[-1] + and value[0] in ("'", '"')): + return value[1:-1] + return value + + +def load_checker_config(path: Optional[Path]) -> Dict[str, Any]: + """Load the small YAML subset used by the standalone checker. + + Keeping this parser local preserves the standard-library-only CLI. A bad + section, rule name or severity is rejected instead of being silently + ignored and changing the effective check. + """ + config = _checker_defaults() + if path is None: + return config + raw = Path(path).read_text(encoding='utf-8') + section = None + for raw_line in raw.splitlines(): + line = raw_line.strip() + if not line or line.startswith('#'): + continue + if not raw_line.startswith((' ', '\t')): + if not line.endswith(':'): + raise ValueError('invalid top-level config line: {}'.format(raw_line)) + section = line[:-1].strip() + if section in ('forbidden_rte_prefixes', + 'ignore_access_port_regex'): + config[section] = [] + elif section != 'rules': + raise ValueError('unknown config section: {}'.format(section)) + continue + if section in ('forbidden_rte_prefixes', 'ignore_access_port_regex'): + if not line.startswith('- '): + raise ValueError('expected list item under {}: {}'.format( + section, raw_line)) + config[section].append(_strip_yaml_value(line[2:])) + continue + if section == 'rules': + if ':' not in line: + raise ValueError("expected 'rule: severity': {}".format(raw_line)) + name, severity = line.split(':', 1) + name = name.strip().lower() + severity = _strip_yaml_value(severity).lower() + if name not in _CHECKER_RULES: + raise ValueError('unknown consistency rule: {}'.format(name)) + if severity not in ('fail', 'warn', 'skip', 'off'): + raise ValueError("invalid severity '{}' for {}".format( + severity, name)) + config['rules'][name] = severity + continue + raise ValueError('config value outside a known section: {}'.format( + raw_line)) + return config + + +def _local_name(tag): + return tag.rsplit('}', 1)[-1] + + +def _ref_short_name(ref): + return ref.strip().rstrip('/').rsplit('/', 1)[-1] if ref else None + + +def _api_type(api): + for name in ('IRead', 'IWrite', 'Read', 'Write', 'Call'): + if api.startswith('Rte_{}_'.format(name)): + return name + return 'Unknown' + + +def _expected_access(api_type): + return { + 'Read': ('READ', 'EXPLICIT'), + 'IRead': ('READ', 'IMPLICIT'), + 'Write': ('WRITE', 'EXPLICIT'), + 'IWrite': ('WRITE', 'IMPLICIT'), + }.get(api_type, (None, None)) + + +def _matches(rel, patterns): + name = rel.rsplit('/', 1)[-1] + return any(fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(name, pattern) + for pattern in patterns) + + +def _mask_c_literals(text): + """Mask string and character contents while preserving every position.""" + out = list(text) + i = 0 + quote = None + while i < len(out): + char = out[i] + if quote is None: + if char in ('"', "'"): + quote = char + i += 1 + continue + if char == '\\' and i + 1 < len(out): + if out[i] != '\n': + out[i] = ' ' + if out[i + 1] != '\n': + out[i + 1] = ' ' + i += 2 + continue + if char == quote: + quote = None + elif char != '\n': + out[i] = ' ' + i += 1 + return ''.join(out) + + +def _matching_brace(text, open_pos): + depth = 0 + for index in range(open_pos, len(text)): + if text[index] == '{': + depth += 1 + elif text[index] == '}': + depth -= 1 + if depth == 0: + return index + return None + + +def _discover_functions(content): + structural = _mask_c_literals(strip_c_comments(content)) + found = [] + for regex in (_AUTOSAR_FUNCTION_RE, _NORMAL_FUNCTION_RE): + for match in regex.finditer(structural): + open_pos = structural.find('{', match.start(), match.end()) + end_pos = _matching_brace(structural, open_pos) + if open_pos < 0 or end_pos is None: + continue + found.append({ + 'name': match.group(1), + 'start_pos': match.start(), + 'body_start': open_pos, + 'end_pos': end_pos, + }) + unique = {(item['name'], item['body_start']): item for item in found} + return sorted(unique.values(), key=lambda item: item['start_pos']) + + +def _containing_function(position, functions): + candidates = [item for item in functions + if item['body_start'] <= position <= item['end_pos']] + if not candidates: + return None + return min(candidates, + key=lambda item: item['end_pos'] - item['body_start'])['name'] + + +def _scan_code(root, rels): + calls = [] + functions = [] + errors = [] + for rel in rels: + path = Path(root) / rel + try: + if looks_binary(path): + raise UnicodeError('file is binary') + content = read_text(path) + except (OSError, UnicodeError) as exc: + errors.append(_scan_error(rel, exc)) + continue + detected = _discover_functions(content) + functions.extend(FunctionInfo(item['name']) for item in detected) + searchable = _mask_c_literals(strip_c_comments(content)) + for match in _RTE_CALL_RE.finditer(searchable): + api = match.group(1) + calls.append(RteCall( + rel, + searchable.count('\n', 0, match.start()) + 1, + _containing_function(match.start(), detected), + api, + _api_type(api), + )) + return calls, functions, errors + + +def _parse_variable_access(variable_access): + port_ref = data_ref = None + for node in variable_access.iter(): + tag = _local_name(node.tag) + if tag == 'PORT-PROTOTYPE-REF': + port_ref = node.text + elif tag == 'TARGET-DATA-PROTOTYPE-REF': + data_ref = node.text + return _ref_short_name(port_ref), _ref_short_name(data_ref) + + +def _parse_arxml(base, rels): + accesses = [] + runnable_files = {} + errors = [] + for rel in rels: + path = Path(base) / rel + try: + if looks_binary(path): + raise UnicodeError('file is binary') + xml_root = ET.fromstring(read_text(path)) + except (OSError, UnicodeError, ET.ParseError) as exc: + errors.append(_scan_error(rel, exc)) + continue + for runnable_elem in xml_root.iter(): + if _local_name(runnable_elem.tag) != 'RUNNABLE-ENTITY': + continue + runnable = next(((child.text or '').strip() + for child in runnable_elem + if _local_name(child.tag) == 'SHORT-NAME'), '') + if not runnable: + continue + runnable_files.setdefault(runnable, set()).add(rel) + for container_elem in runnable_elem: + container = _local_name(container_elem.tag) + if container not in _ARXML_ACCESS_CONTAINERS: + continue + direction, mode = _ARXML_ACCESS_CONTAINERS[container] + for variable_access in container_elem.iter(): + if _local_name(variable_access.tag) != 'VARIABLE-ACCESS': + continue + port, data_element = _parse_variable_access(variable_access) + if port and data_element: + accesses.append(AccessPoint( + runnable, port, data_element, direction, mode, + container, rel)) + return accesses, runnable_files, errors + + +def _scan_error(rel, exc): + return { + 'reason': 'CONSISTENCY_SCAN_ERROR', + 'severity': 'fail', + 'file': rel, + 'error': '{}: {}'.format(type(exc).__name__, exc), + } + + +def _find_runnable(function_name, runnable_names): + if not function_name: + return None + if function_name in runnable_names: + return function_name + matches = [name for name in runnable_names + if function_name.endswith(name) or name.endswith(function_name)] + return matches[0] if len(matches) == 1 else None + + +def _access_indexes(access_points): + by_file_runnable_pair = {} + all_pairs = set() + for access in access_points: + pair = (access.port, access.data_element) + all_pairs.add(pair) + by_file_runnable_pair.setdefault(access.source_file, {}).setdefault( + access.runnable, {}).setdefault(pair, []).append(access) + return by_file_runnable_pair, all_pairs + + +def _match_pair(call, all_pairs): + prefix = 'Rte_{}_'.format(call.api_type) + payload = call.api[len(prefix):] + candidates = [] + for port, data_element in all_pairs: + suffix = '{}_{}'.format(port, data_element) + if call.api_type in ('Read', 'Write'): + matched = payload == suffix + else: + matched = payload == suffix or payload.endswith('_' + suffix) + if matched: + candidates.append((port, data_element)) + if not candidates: + return None + return max(candidates, key=lambda pair: len('{}_{}'.format(*pair))) + + +def _duplicate_inconsistencies(access_points): + grouped = {} + for access in access_points: + key = (access.runnable, access.port, access.data_element) + grouped.setdefault(key, {}).setdefault( + access.source_file, []).append(access) + findings = [] + for (runnable, port, data_element), file_map in grouped.items(): + if len(file_map) < 2: + continue + file_modes = { + source: sorted({'{}/{}'.format(item.direction, item.access_mode) + for item in accesses}) + for source, accesses in file_map.items() + } + if len({tuple(modes) for modes in file_modes.values()}) > 1: + findings.append({ + 'reason': 'INCONSISTENT_ARXML_ACCESS_MODE', + 'runnable': runnable, + 'port': port, + 'data_element': data_element, + 'files': file_modes, + }) + return findings + + +def _check_access_points(calls, access_points, runnable_files, ignore_pattern): + issues = _duplicate_inconsistencies(access_points) + ignored = 0 + skipped = [] + by_file_runnable_pair, all_pairs = _access_indexes(access_points) + runnable_names = set(runnable_files) + issue_keys = set() + for call in calls: + if call.api_type not in ('Read', 'Write', 'IRead', 'IWrite'): + continue + payload = call.api[len('Rte_{}_'.format(call.api_type)):] + if ignore_pattern and ignore_pattern.search(payload): + ignored += 1 + continue + runnable = _find_runnable(call.function, runnable_names) + pair = _match_pair(call, all_pairs) + if pair is None: + issues.append({ + 'reason': 'NO_MATCHING_PORT_DATAELEMENT', + 'call': call, + 'runnable': runnable, + }) + continue + if runnable is None: + skipped.append({ + 'reason': 'RUNNABLE_NOT_RESOLVED', + 'call': call, + 'port': pair[0], + 'data_element': pair[1], + }) + continue + expected_direction, expected_mode = _expected_access(call.api_type) + for source_file in sorted(runnable_files.get(runnable, ())): + accesses = by_file_runnable_pair.get(source_file, {}).get( + runnable, {}).get(pair, []) + if not accesses: + key = ('missing', source_file, runnable, pair, call.api) + if key not in issue_keys: + issue_keys.add(key) + issues.append({ + 'reason': 'MISSING_ACCESS_POINT_IN_ARXML', + 'call': call, + 'arxml_file': source_file, + 'runnable': runnable, + 'port': pair[0], + 'data_element': pair[1], + }) + continue + actual_modes = sorted({(item.direction, item.access_mode) + for item in accesses}) + if (expected_direction, expected_mode) not in actual_modes: + key = ('mode', source_file, runnable, pair, call.api, + tuple(actual_modes)) + if key not in issue_keys: + issue_keys.add(key) + issues.append({ + 'reason': 'ACCESS_MODE_MISMATCH', + 'call': call, + 'arxml_file': source_file, + 'runnable': runnable, + 'port': pair[0], + 'data_element': pair[1], + 'expected': '{}/{}'.format(expected_direction, + expected_mode), + 'actual': [ + '{}/{} [{}]'.format(item.direction, + item.access_mode, + item.container) + for item in accesses + ], + }) + return issues, ignored, skipped + + +def _orphan_accesses(calls, functions, access_points, runnable_files, + ignore_pattern): + _by_file, all_pairs = _access_indexes(access_points) + runnable_names = set(runnable_files) + resolved = { + runnable for function in functions + for runnable in (_find_runnable(function.name, runnable_names),) + if runnable is not None + } + used = set() + for call in calls: + if call.api_type not in ('Read', 'Write', 'IRead', 'IWrite'): + continue + payload = call.api[len('Rte_{}_'.format(call.api_type)):] + if ignore_pattern and ignore_pattern.search(payload): + continue + pair = _match_pair(call, all_pairs) + direction, _mode = _expected_access(call.api_type) + if pair is not None and direction is not None: + used.add((direction, pair[0], pair[1])) + findings = [] + seen = set() + for access in access_points: + key = (access.direction, access.port, access.data_element) + payload = '{}_{}'.format(access.port, access.data_element) + report_key = (access.source_file, access.runnable, access.port, + access.data_element, access.direction, access.access_mode) + if (access.runnable not in resolved or key in used or report_key in seen + or (ignore_pattern and ignore_pattern.search(payload))): + continue + seen.add(report_key) + findings.append({ + 'reason': 'ORPHAN_ACCESS', + 'arxml_file': access.source_file, + 'runnable': access.runnable, + 'port': access.port, + 'data_element': access.data_element, + 'access': '{}/{} [{}]'.format( + access.direction, access.access_mode, access.container), + }) + return findings + + +def _compile_ignore_pattern(config, cli_pattern): + patterns = [pattern for pattern in config['ignore_access_port_regex'] + if pattern] + if cli_pattern: + patterns.append(cli_pattern) + return re.compile('|'.join('(?:{})'.format(pattern) for pattern in patterns)) \ + if patterns else None + + +def validate_checker_regex(config: Dict[str, Any], + cli_pattern: Optional[str] = None) -> None: + """Reject an invalid configured or CLI ignore regex before scanning.""" + try: + _compile_ignore_pattern(config, cli_pattern) + except re.error as exc: + raise ValueError( + 'invalid access-port ignore regex: {}'.format(exc)) from exc + + +def _classify(findings, config): + out = [] + skipped = 0 + for finding in findings: + if finding['reason'] == 'CONSISTENCY_SCAN_ERROR': + out.append(finding) + continue + severity = config['rules'].get(finding['reason'].lower(), 'fail') + if severity == 'skip': + skipped += 1 + continue + if severity == 'off': + continue + item = dict(finding) + item['severity'] = severity + out.append(item) + return out, skipped + + +def current_tree_check(root: Path, config: Optional[Dict[str, Any]] = None, + exclude: Sequence[str] = (), + ignore_access_port_regex: Optional[str] = None + ) -> CurrentTreeCheck: + """Validate CURRENT generated C calls against CURRENT ARXML declarations. + + Findings never rewrite a file verdict. ``fail`` findings make the CLI exit + 1, while read, listing and XML parse failures are returned as explicit + ``CONSISTENCY_SCAN_ERROR`` findings and make it exit 2. + """ + root = Path(root) + config = config or _checker_defaults() + listing_errors: List[Tuple[str, str]] = [] + rels = sorted(rel for rel in list_files(root, listing_errors) + if not _matches(rel, exclude)) + c_rels = [rel for rel in rels if Path(rel).suffix.lower() == '.c'] + arxml_rels = [rel for rel in rels + if Path(rel).suffix.lower() == '.arxml'] + calls, functions, code_errors = _scan_code(root, c_rels) + access_points, runnable_files, arxml_errors = _parse_arxml(root, arxml_rels) + listing_findings = [_scan_error(rel, RuntimeError(message)) + for rel, message in listing_errors] + validate_checker_regex(config, ignore_access_port_regex) + ignore_pattern = _compile_ignore_pattern(config, ignore_access_port_regex) + + raw = list(code_errors) + list(arxml_errors) + listing_findings + prefixes = tuple(config['forbidden_rte_prefixes']) + for call in calls: + if prefixes and call.api.startswith(prefixes): + raw.append({'reason': 'FORBIDDEN_RTE_API', 'call': call}) + ignored = 0 + skipped = [] + if access_points or runnable_files: + issues, ignored, skipped = _check_access_points( + calls, access_points, runnable_files, ignore_pattern) + raw.extend(issues) + raw.extend(_orphan_accesses( + calls, functions, access_points, runnable_files, ignore_pattern)) + findings, skipped_rules = _classify(raw, config) + return CurrentTreeCheck( + findings, skipped, skipped_rules, ignored, len(c_rels), + len(arxml_rels), len(functions), len(calls), len(access_points)) + + def _iface_changed(r): """True when an ARXML file's interface surface really moved -- a port-interface or an SWC port/runnable/event was added, removed or diff --git a/compare_tool/main.py b/compare_tool/main.py index a973bc5..14d59c5 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -17,7 +17,8 @@ from collections import Counter from pathlib import Path -from . import __version__, review, serialize, theme, userrules, zipsource +from . import (__version__, consistency, review, serialize, theme, userrules, + zipsource) from .diff_engine import RULES, ruleset_for from .report import (build_arxml_report, build_report, consistency_advisories, model_overview) @@ -142,7 +143,67 @@ def autosar_text(parts): return lines -def summary_lines(results, counts, terminal=False): +def _current_consistency_lines(check): + """Compact terminal rendering for the CURRENT-tree RTE/ARXML check.""" + lines = [ + ' Current tree: {} C file(s), {} ARXML file(s), {} RTE call(s), ' + '{} access point(s)'.format( + check.c_files_scanned, check.arxml_files_scanned, + check.rte_calls_detected, check.access_points_detected) + ] + for finding in check.findings: + reason = finding['reason'] + call = finding.get('call') + location = finding.get('file') or finding.get('arxml_file') or '-' + detail = '' + if call is not None: + location = '{}:{}'.format(call.file, call.line) + detail = call.api + if call.function: + detail += ' in {}'.format(call.function) + if reason == 'CONSISTENCY_SCAN_ERROR': + detail = finding['error'] + elif reason == 'NO_MATCHING_PORT_DATAELEMENT': + detail += ' has no matching ARXML Port/DataElement' + elif reason == 'MISSING_ACCESS_POINT_IN_ARXML': + detail += ' is missing {} / {} for runnable {} in {}'.format( + finding['port'], finding['data_element'], finding['runnable'], + finding['arxml_file']) + elif reason == 'ACCESS_MODE_MISMATCH': + detail += ' expects {}; ARXML has {}'.format( + finding['expected'], ', '.join(finding['actual'])) + elif reason == 'INCONSISTENT_ARXML_ACCESS_MODE': + definitions = '; '.join( + '{}: {}'.format(path, ', '.join(modes)) + for path, modes in sorted(finding['files'].items())) + detail = '{} / {} / {} differs across {}'.format( + finding['runnable'], finding['port'], finding['data_element'], + definitions) + elif reason == 'ORPHAN_ACCESS': + detail = '{} / {} / {} ({}) has no matching generated RTE access'.format( + finding['runnable'], finding['port'], finding['data_element'], + finding['access']) + severity = finding['severity'].upper() + lines.append(' !! [{}] {} {} -- {}'.format( + severity, reason, location, detail)) + if check.skipped_accesses: + lines.append(' !! {} RTE access(es) were not checked because their ' + 'runnable could not be resolved.'.format( + len(check.skipped_accesses))) + if check.skipped_rules: + lines.append(' {} finding(s) skipped by rule configuration.'.format( + check.skipped_rules)) + if check.ignored_accesses: + lines.append(' {} RTE access(es) ignored by the configured port regex.' + .format(check.ignored_accesses)) + if not check.findings and not check.skipped_accesses: + lines.append(' No current-tree RTE/ARXML consistency findings.') + lines.append(' FAIL findings set exit code 1; consistency scan errors set ' + 'exit code 2; WARN findings do not change the exit code.') + return lines + + +def summary_lines(results, counts, terminal=False, current_check=None): """Scan summary as plain-text lines the CLI prints: counts, uncompared paths, modified files and the AUTOSAR/A2L semantic rollups. @@ -242,10 +303,12 @@ def summary_lines(results, counts, terminal=False): # 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: + if advisories or current_check is not None: lines.append('Consistency check:') for model, msg in advisories: lines.append(' !! {}: {}'.format(model, msg)) + if current_check is not None: + lines.extend(_current_consistency_lines(current_check)) return lines @@ -441,6 +504,19 @@ def _parser(): 'as real. Everything folded is labelled Assumed rename ' 'and the report and the terminal both carry a warning; ' 'do not sign a review off on a run that used this') + ap.add_argument('--check-consistency', action='store_true', + help='enable the CLI-only CURRENT-tree RTE/ARXML ' + 'consistency check; disabled by default and not ' + 'included in the viewer or HTML report') + ap.add_argument('--consistency-config', metavar='CONFIG.yaml', default=None, + help='settings for --check-consistency: forbidden ' + 'RTE prefixes, ignored port regexes and per-rule ' + 'fail/warn/skip/off severities. Uses a small YAML ' + 'subset and requires no third-party package') + ap.add_argument('--ignore-access-port-regex', metavar='REGEX', default=None, + help='CLI-only extra regex for RTE Port/DataElement names ' + 'that the current-tree consistency check should skip; ' + 'OR-ed with patterns from --consistency-config') 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, ' @@ -460,9 +536,9 @@ def _parser(): 'report and points at the viewer for the full diff') ap.add_argument('--exit-zero', action='store_true', help='always exit 0 even when real changes exist ' - '(report-only mode for CI pipelines); compare ' - 'errors still exit 2 -- an incomplete compare ' - 'must never look green') + '(report-only mode for CI pipelines); current-tree ' + 'consistency failures still exit 1 and compare or ' + 'consistency scan errors still exit 2') return ap @@ -483,7 +559,11 @@ def viewer_requested(argv): if any(a in ('-h', '--help') for a in argv): return False # the help text belongs on a console the user can read try: - return _wants_viewer(_parser().parse_args(argv)) + args = _parser().parse_args(argv) + if (args.check_consistency or args.consistency_config or + args.ignore_access_port_regex): + return False # CLI-only option: keep usage errors visible too + return _wants_viewer(args) except SystemExit: return False # bad usage: keep the console, argparse prints there @@ -555,7 +635,29 @@ def _run(ap, args, zip_temp): 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): + wants_viewer = _wants_viewer(args) + if wants_viewer and (args.check_consistency or args.consistency_config or + args.ignore_access_port_regex): + ap.error('--check-consistency and its options are CLI-only; give both ' + 'folders without --qt/--viewer') + if not args.check_consistency and (args.consistency_config or + args.ignore_access_port_regex): + ap.error('--consistency-config and --ignore-access-port-regex require ' + '--check-consistency') + if args.check_consistency and args.arxml_only: + ap.error('--check-consistency cannot be combined with --arxml-only ' + 'because generated C is outside that comparison scope') + checker_config = None + if args.check_consistency: + try: + checker_config = consistency.load_checker_config( + Path(args.consistency_config) + if args.consistency_config else None) + consistency.validate_checker_regex( + checker_config, args.ignore_access_port_regex) + except (OSError, UnicodeError, ValueError) as e: + ap.error('--check-consistency configuration is invalid: {}'.format(e)) + if wants_viewer: from .qtviewer import run_viewer # deferred: PySide6 may be absent old_dir = _viewer_source(ap, args.old_dir, zip_temp) new_dir = _viewer_source(ap, args.new_dir, zip_temp) @@ -602,13 +704,21 @@ def _run(ap, args, zip_temp): if args.skip_var_renames and args.arxml_only: print('note: --skip-var-renames has no effect with --arxml-only (it ' 'only ever folds C/C++ bindings)', file=sys.stderr) - out = Path(args.report) 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...') + current_check = None + if args.check_consistency: + try: + current_check = consistency.current_tree_check( + new_root, checker_config, exclude=args.exclude, + ignore_access_port_regex=args.ignore_access_port_regex) + except ValueError as e: + ap.error(str(e)) + def progress(done, total, rel): if done % 50 == 0 or done == total: print(' {}/{} {}'.format(done, total, rel)) @@ -627,7 +737,8 @@ def progress(done, total, rel): # what WAS scanned still goes to the terminal -- the compare itself may # have been fine, it is only the record that is missing if e.results is not None: - for line in summary_lines(e.results, e.counts): + for line in summary_lines(e.results, e.counts, + current_check=current_check): print(line) print('!! REPORT NOT WRITTEN -- {}'.format(e), file=sys.stderr) print('!! This run left no record: treat it as INCOMPLETE.', @@ -636,7 +747,8 @@ 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, terminal=args.no_report): + for line in summary_lines(results, counts, terminal=args.no_report, + current_check=current_check): print(line) if args.no_report: for line in diagnostic_lines( @@ -658,7 +770,7 @@ def progress(done, total, rel): elif out is not None: print('Report written: {}'.format(out.resolve())) - code = _exit_code(counts, args.exit_zero) + code = _exit_code(counts, args.exit_zero, current_check) # 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: @@ -675,15 +787,22 @@ def progress(done, total, rel): return code -def _exit_code(counts, exit_zero): +def _exit_code(counts, exit_zero, current_check=None): """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``. + An incomplete compare or consistency scan returns 2. A current-tree + consistency finding configured as ``fail`` returns 1. Neither condition + can be masked by ``--exit-zero``, which suppresses only file differences. """ if counts['error']: return 2 + if current_check is not None: + if any(item['reason'] == 'CONSISTENCY_SCAN_ERROR' + for item in current_check.findings): + return 2 + if any(item['severity'] == 'fail' + for item in current_check.findings): + return 1 if exit_zero: return 0 return 1 if counts['real-change'] or counts['added'] or counts['deleted'] else 0 diff --git a/docs/architecture.md b/docs/architecture.md index e33954b..4b61c29 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -111,7 +111,7 @@ A reorder is noise only when every line is a side-effect-free scalar assignment, `scanner.compare_file` attaches semantic data for real changes and one-sided files. The `summarize_*` functions aggregate interfaces, SWCs, RTE access and A2L objects. -`consistency.py` derives advisories from the complete result. Advisories never change verdicts or exit codes. +`consistency.py` derives old/new regeneration advisories from the complete result. When CLI callers opt in with `--check-consistency`, it also performs a standard-library-only check of CURRENT generated RTE calls against CURRENT ARXML access-point declarations. Current-tree `fail` findings set exit code `1`; read, listing and parse failures set `2`; `warn` findings do not gate. Consistency never rewrites file verdicts. The viewer and HTML renderer do not run or render this optional check. File move pairing runs after verdicts are settled. A pair is presentation metadata: Added and Deleted verdicts remain in the result and counts. diff --git a/docs/usage.md b/docs/usage.md index 4201bf7..317b993 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -194,7 +194,52 @@ The tool warns about combinations that may indicate incomplete regeneration, inc - A2L changes without corresponding generated C changes; - a model gaining RTE access while a related model remains identical. -These warnings require review but do not change verdicts or exit codes. +The CURRENT-tree check is disabled by default, so existing CLI, viewer and HTML-report workflows are unchanged. Enable it for a CLI comparison with: + +```bash +python -m compare_tool baseline current --no-report --check-consistency +``` + +The check validates generated C and ARXML inside the CURRENT folder: + +| Rule | Default | Finding | +|---|---|---| +| `forbidden_rte_api` | `fail` | An RTE call starts with a configured forbidden prefix. | +| `no_matching_port_dataelement` | `fail` | A generated RTE read/write cannot be matched to an ARXML Port/DataElement pair. | +| `missing_access_point_in_arxml` | `fail` | A duplicate ARXML runnable definition omits an access used by generated C. | +| `inconsistent_arxml_access_mode` | `fail` | Duplicate ARXML definitions disagree on direction or access mode. | +| `access_mode_mismatch` | `fail` | The generated API and ARXML disagree on read/write or explicit/implicit access. | +| `orphan_access` | `warn` | A resolved ARXML access point has no matching generated RTE access. | + +Use `--consistency-config CONFIG.yaml` to override the built-in CURRENT-tree policy without adding PyYAML. Every section is optional; omitted values keep their defaults: + +```yaml +forbidden_rte_prefixes: + - Rte_IRead_ + - Rte_IWrite_ +ignore_access_port_regex: + - ^Bsw_ +rules: + forbidden_rte_api: fail + no_matching_port_dataelement: fail + missing_access_point_in_arxml: fail + inconsistent_arxml_access_mode: fail + access_mode_mismatch: fail + orphan_access: warn +``` + +Rule severities are: + +- `fail`: print the finding and set exit code `1`; +- `warn`: print the finding without changing the exit code; +- `skip`: omit the finding and report how many configured findings were skipped; +- `off`: disable the rule without a skipped count. + +The ignore regex is matched against the API payload after `Rte__`, for example `Bsw_Port_Data`. It affects Port/DataElement matching but does not disable `forbidden_rte_api`. `--ignore-access-port-regex REGEX` adds one temporary pattern to the file configuration. + +`--consistency-config` and `--ignore-access-port-regex` require `--check-consistency`. The check is not available with `--arxml-only`, and its findings are not embedded in the HTML report or viewer. + +Old/new regeneration warnings remain advisory. A CURRENT-tree `fail` finding sets exit code `1`, even with `--exit-zero`; `warn` does not change the exit code. A file read, folder listing or ARXML parse failure makes the consistency scan incomplete and sets exit code `2`. Consistency results never rewrite file verdicts. ## HTML report @@ -209,17 +254,19 @@ Use `--max-diff-lines N` when a very large regenerate would otherwise create an ```bash python -m compare_tool baseline.zip current.zip \ --report compare_report.html \ + --check-consistency \ + --consistency-config codegen_checker.yaml \ --json compare_result.json \ --sarif compare_result.sarif ``` | Exit code | Meaning | |---:|---| -| `0` | No real changes | -| `1` | Real changes found | -| `2` | Comparison incomplete or failed | +| `0` | No real changes and no enabled consistency rule failed | +| `1` | Real changes found, or an enabled CURRENT-tree consistency rule failed | +| `2` | Comparison or enabled consistency scan incomplete/failed | -`--exit-zero` suppresses only exit code `1`. A read, scan, comparison or report-write failure always exits `2`. +`--exit-zero` suppresses exit code `1` only when it came from file differences. It never suppresses a CURRENT-tree consistency `fail`. A read, scan, comparison, consistency-scan or report-write failure always exits `2`. Publish the HTML report and any JSON/SARIF outputs as CI artifacts. diff --git a/docs/vi/README.md b/docs/vi/README.md index 6316443..ac83fa6 100644 --- a/docs/vi/README.md +++ b/docs/vi/README.md @@ -44,7 +44,7 @@ Chỉ in summary trên terminal: python -m compare_tool baseline current --no-report ``` -Terminal mode hiển thị Overview theo model, toàn bộ file cùng verdict, AUTOSAR/A2L changes và consistency warnings. Mode này không in source diff và không tạo HTML. +Terminal mode hiển thị Overview theo model, toàn bộ file cùng verdict, AUTOSAR/A2L changes và consistency warnings. Thêm `--check-consistency` để kiểm tra generated RTE call của CURRENT với ARXML declaration trong CURRENT. Mode này không in source diff và không tạo HTML. Mở desktop viewer: @@ -72,9 +72,10 @@ Comparison engine còn báo cáo: - port, port interface, runnable và event; - `Rte_*` access point; - A2L `CHARACTERISTIC` và `MEASUREMENT`; -- dấu hiệu regenerate chưa đầy đủ giữa ARXML, A2L và generated C. +- dấu hiệu regenerate chưa đầy đủ giữa ARXML, A2L và generated C; +- generated RTE call không khớp với access declaration trong ARXML của CURRENT khi bật `--check-consistency`. -Consistency warning chỉ là advisory, không thay đổi file verdict hoặc exit code. +Old/new regeneration warning vẫn là advisory. CURRENT-tree check mặc định tắt; khi được bật, finding mức `fail` trả exit code `1`, consistency scan không hoàn tất trả `2`, còn `warn` không đổi exit code. Consistency result không thay đổi file verdict. ## Verdict diff --git a/docs/vi/architecture.md b/docs/vi/architecture.md index 3bca78f..51a200b 100644 --- a/docs/vi/architecture.md +++ b/docs/vi/architecture.md @@ -102,7 +102,7 @@ Reorder chỉ là noise khi toàn bộ line là scalar assignment không side ef `scanner.compare_file` gắn semantic data cho real change và one-sided file. Các hàm `summarize_*` tổng hợp interface, SWC, RTE access và A2L object. -`consistency.py` tạo advisory từ toàn bộ result. Advisory không đổi verdict hoặc exit code. +`consistency.py` tạo old/new regeneration advisory từ toàn bộ result. Khi CLI caller opt in bằng `--check-consistency`, module này còn kiểm tra generated RTE call của CURRENT với access-point declaration trong ARXML của CURRENT mà chỉ dùng Python standard library. Current-tree finding mức `fail` trả exit code `1`; lỗi đọc, list hoặc parse trả `2`; finding mức `warn` không gate. Consistency không đổi file verdict. Viewer và HTML renderer không chạy hoặc render optional check này. File move được ghép sau khi verdict đã chốt. Pairing chỉ là presentation metadata; Added và Deleted vẫn giữ nguyên trong count và exit code. diff --git a/docs/vi/usage.md b/docs/vi/usage.md index ce30252..6b27d78 100644 --- a/docs/vi/usage.md +++ b/docs/vi/usage.md @@ -194,7 +194,52 @@ Tool cảnh báo các trường hợp có thể cho thấy regenerate chưa đ - A2L thay đổi nhưng generated C tương ứng không đổi; - một model có thêm RTE access trong khi model liên quan vẫn identical. -Các warning này cần được review nhưng không thay đổi verdict hoặc exit code. +CURRENT-tree check mặc định tắt nên CLI, viewer và HTML-report workflow hiện có không đổi. Bật check cho CLI comparison bằng: + +```bash +python -m compare_tool baseline current --no-report --check-consistency +``` + +Check đối chiếu generated C và ARXML bên trong folder CURRENT: + +| Rule | Default | Finding | +|---|---|---| +| `forbidden_rte_api` | `fail` | RTE call bắt đầu bằng forbidden prefix đã cấu hình. | +| `no_matching_port_dataelement` | `fail` | Generated RTE read/write không match được với cặp Port/DataElement trong ARXML. | +| `missing_access_point_in_arxml` | `fail` | Một duplicate ARXML runnable definition thiếu access mà generated C đang dùng. | +| `inconsistent_arxml_access_mode` | `fail` | Các duplicate ARXML definition không thống nhất direction hoặc access mode. | +| `access_mode_mismatch` | `fail` | Generated API và ARXML không khớp read/write hoặc explicit/implicit access. | +| `orphan_access` | `warn` | ARXML access point thuộc runnable đã resolve không có generated RTE access tương ứng. | + +Dùng `--consistency-config CONFIG.yaml` để override policy mặc định của CURRENT-tree mà không cần thêm PyYAML. Mọi section đều optional; giá trị bị bỏ qua sẽ giữ default: + +```yaml +forbidden_rte_prefixes: + - Rte_IRead_ + - Rte_IWrite_ +ignore_access_port_regex: + - ^Bsw_ +rules: + forbidden_rte_api: fail + no_matching_port_dataelement: fail + missing_access_point_in_arxml: fail + inconsistent_arxml_access_mode: fail + access_mode_mismatch: fail + orphan_access: warn +``` + +Rule severity gồm: + +- `fail`: in finding và trả exit code `1`; +- `warn`: in finding nhưng không đổi exit code; +- `skip`: bỏ finding và báo số finding đã skip theo config; +- `off`: tắt rule mà không tăng skipped count. + +Ignore regex match với API payload phía sau `Rte__`, ví dụ `Bsw_Port_Data`. Regex này ảnh hưởng Port/DataElement matching nhưng không tắt `forbidden_rte_api`. `--ignore-access-port-regex REGEX` thêm một pattern tạm thời vào cấu hình trong file. + +`--consistency-config` và `--ignore-access-port-regex` yêu cầu `--check-consistency`. Check không dùng được cùng `--arxml-only`, và finding chưa được đưa vào HTML report hoặc viewer. + +Old/new regeneration warning vẫn là advisory. CURRENT-tree finding mức `fail` trả exit code `1`, kể cả khi dùng `--exit-zero`; `warn` không đổi exit code. Lỗi đọc file, list folder hoặc parse ARXML làm consistency scan không hoàn tất và trả exit code `2`. Consistency result không thay đổi file verdict. ## HTML report @@ -209,17 +254,19 @@ Dùng `--max-diff-lines N` nếu một lần regenerate lớn có thể tạo re ```bash python -m compare_tool baseline.zip current.zip \ --report compare_report.html \ + --check-consistency \ + --consistency-config codegen_checker.yaml \ --json compare_result.json \ --sarif compare_result.sarif ``` | Exit code | Ý nghĩa | |---:|---| -| `0` | Không có real change | -| `1` | Có real change | -| `2` | Comparison không đầy đủ hoặc thất bại | +| `0` | Không có real change và không có consistency rule đã bật bị fail | +| `1` | Có real change hoặc CURRENT-tree consistency rule đã bật bị fail | +| `2` | Comparison hoặc consistency scan đã bật không đầy đủ/thất bại | -`--exit-zero` chỉ bỏ qua exit code `1`. Lỗi đọc, scan, compare hoặc ghi report luôn trả về `2`. +`--exit-zero` chỉ bỏ qua exit code `1` khi code đó đến từ file difference. Cờ này không bỏ qua CURRENT-tree consistency `fail`. Lỗi đọc, scan, compare, consistency scan hoặc ghi report luôn trả về `2`. Nên publish HTML report và JSON/SARIF output thành CI artifact. diff --git a/tests/test_cli_modes.py b/tests/test_cli_modes.py index e93427f..190f933 100644 --- a/tests/test_cli_modes.py +++ b/tests/test_cli_modes.py @@ -44,6 +44,10 @@ def test_cli_flags_do_not_count_as_folders(self): self.assertTrue(viewer_requested(['--report', 'out.html'])) self.assertFalse(viewer_requested(['old', 'new', '--arxml-only'])) + def test_consistency_flag_is_cli_only_and_keeps_the_console(self): + self.assertFalse(viewer_requested(['--check-consistency'])) + self.assertFalse(viewer_requested(['--qt', '--check-consistency'])) + def test_help_and_bad_usage_keep_the_console(self): # both print to stdout; hiding the console would swallow the message self.assertFalse(viewer_requested(['--help'])) @@ -290,6 +294,63 @@ def test_report_consistency_warnings_remain_visible_with_exit_zero(self): for model, message in advisories: self.assertIn('!! {}: {}'.format(model, message), output) + def test_current_tree_check_is_opt_in_and_fail_always_sets_exit_one(self): + c_text = '''void Step(void) +{ + (void)Rte_Read_Speed_Value(); +} +''' + arxml = ''' +Step + +/Ports/Speed +/Data/Value + +''' + for side in (self.old, self.new): + self._file(side, 'Step.c', c_text) + self._file(side, 'Step.arxml', arxml) + code, output, errors = self._run() + self.assertEqual(code, 0) + self.assertEqual(errors, '') + self.assertNotIn('Current tree:', output) + code, output, errors = self._run('--check-consistency') + self.assertEqual(code, 1) + self.assertEqual(errors, '') + self.assertIn('Consistency check:', output) + self.assertIn('[FAIL] ACCESS_MODE_MISMATCH Step.c:3', output) + self.assertIn('FAIL findings set exit code 1', output) + self.assertEqual( + self._run('--check-consistency', '--exit-zero')[0], 1) + + def test_consistency_scan_error_sets_exit_two(self): + for side in (self.old, self.new): + self._file(side, 'Step.c', 'void Step(void)\n{\n}\n') + self._file(side, 'Step.arxml', '') + code, output, errors = self._run( + '--check-consistency', '--exit-zero') + self.assertEqual(code, 2) + self.assertEqual(errors, '') + self.assertIn('[FAIL] CONSISTENCY_SCAN_ERROR Step.arxml', output) + + def test_invalid_consistency_regex_is_a_usage_error(self): + for side in (self.old, self.new): + self._file(side, 'Step.c', 'void Step(void)\n{\n}\n') + with self.assertRaises(SystemExit) as raised: + quiet(main, [str(self.old), str(self.new), '--no-report', + '--check-consistency', + '--ignore-access-port-regex', '[']) + self.assertEqual(raised.exception.code, 2) + + def test_consistency_options_require_the_enable_flag(self): + config = self.root / 'consistency.yaml' + config.write_text('rules:\n orphan_access: warn\n', encoding='utf-8') + for option in (('--consistency-config', str(config)), + ('--ignore-access-port-regex', '^Bsw_')): + with self.subTest(option=option), self.assertRaises(SystemExit) as raised: + quiet(main, [str(self.old), str(self.new), '--no-report', *option]) + self.assertEqual(raised.exception.code, 2) + 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') diff --git a/tests/test_consistency.py b/tests/test_consistency.py index 42ece4d..ca808bf 100644 --- a/tests/test_consistency.py +++ b/tests/test_consistency.py @@ -5,7 +5,9 @@ change is the ordinary case and is never flagged. """ +import tempfile import unittest +from pathlib import Path from compare_tool import consistency @@ -204,5 +206,101 @@ def test_peer_with_mixed_c_is_not_identical(self): _adv({'A': ['A.c'], 'B': ['B.c', 'B_data.c']}, results), []) +class TestCurrentTreeCheck(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.root = Path(temp.name) + + def _write(self, rel, text): + path = self.root / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding='utf-8') + + def _arxml(self, container='DATA-RECEIVE-POINT-BY-ARGUMENTS'): + return ''' + + Step + <{container}> + + /Ports/Speed + /Data/Value + + + + +'''.format(container=container) + + def _reasons(self, check): + return [item['reason'] for item in check.findings] + + def test_matching_explicit_access_is_clean(self): + self._write('Step.c', '''void Step(void) +{ + (void)Rte_Read_Speed_Value(); +} +''') + self._write('Step.arxml', self._arxml()) + check = consistency.current_tree_check(self.root) + self.assertEqual(check.findings, []) + self.assertEqual(check.rte_calls_detected, 1) + self.assertEqual(check.access_points_detected, 1) + + def test_forbidden_implicit_api_and_access_mode_are_reported(self): + self._write('Step.c', '''void Step(void) +{ + (void)Rte_IRead_Step_Speed_Value(); +} +''') + self._write('Step.arxml', self._arxml()) + check = consistency.current_tree_check(self.root) + self.assertIn('FORBIDDEN_RTE_API', self._reasons(check)) + self.assertIn('ACCESS_MODE_MISMATCH', self._reasons(check)) + + def test_duplicate_arxml_definition_must_contain_the_same_access(self): + self._write('Step.c', '''void Step(void) +{ + (void)Rte_Read_Speed_Value(); +} +''') + self._write('one.arxml', self._arxml()) + self._write('two.arxml', ''' +Step +''') + check = consistency.current_tree_check(self.root) + self.assertIn('MISSING_ACCESS_POINT_IN_ARXML', self._reasons(check)) + + def test_orphan_access_is_a_warning(self): + self._write('Step.c', 'void Step(void)\n{\n}\n') + self._write('Step.arxml', self._arxml()) + check = consistency.current_tree_check(self.root) + orphan = next(item for item in check.findings + if item['reason'] == 'ORPHAN_ACCESS') + self.assertEqual(orphan['severity'], 'warn') + + def test_malformed_arxml_is_loud_not_a_clean_check(self): + self._write('Step.c', 'void Step(void)\n{\n}\n') + self._write('Step.arxml', '') + check = consistency.current_tree_check(self.root) + self.assertIn('CONSISTENCY_SCAN_ERROR', self._reasons(check)) + + def test_config_can_disable_a_rule_without_third_party_yaml(self): + config_path = self.root / 'checker.yaml' + config_path.write_text('''rules: + forbidden_rte_api: off +''', encoding='utf-8') + self._write('Step.c', '''void Step(void) +{ + (void)Rte_IRead_Step_Speed_Value(); +} +''') + self._write( + 'Step.arxml', self._arxml('DATA-READ-ACCESSS')) + config = consistency.load_checker_config(config_path) + check = consistency.current_tree_check(self.root, config) + self.assertNotIn('FORBIDDEN_RTE_API', self._reasons(check)) + self.assertEqual(check.findings, []) + + if __name__ == '__main__': unittest.main()