diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aa4f1f9..b23aef7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,7 +75,7 @@ jobs: fi - name: Install the viewer dependency - run: python -m pip install "PySide6>=6.5" + run: python -m pip install "PySide6>=6.5,!=6.12.0" # main is already green, but a release is the one build where "the tests # ran" is worth paying a minute for diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0618dd..ea42c84 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -93,7 +93,7 @@ jobs: - name: Install the viewer dependency if: matrix.python-version == '3.11' - run: python -m pip install "PySide6>=6.5" + run: python -m pip install "PySide6>=6.5,!=6.12.0" # A wheel that installs but cannot load (a missing system library) would # otherwise turn straight back into silent skips. Fail here instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index 84a2139..f658018 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project are documented here. Versions follow ## [Unreleased] +## [1.19.0] — 2026-10-16 + +### Added + +- Print the tool version at the start of CLI comparisons and CURRENT-only checks. +- Check one CURRENT folder or ZIP before delivery without a baseline or HTML, with coverage, delivery policy, documented helper mappings and scoped exceptions. + +### Fixed + +- Exclude PySide6 6.12.0 from viewer installs and builds after reproducing Qt crashes on Python 3.11. +- Prevent missing consistency inputs from passing; resolve runnable symbols and component ownership, and check orphan accesses per runnable without borrowing another component's declarations. + +### Changed + +- Identify unsupported or unresolved RTE checks explicitly and gate them under delivery policy while retaining existing CLI syntax and YAML settings. +- Present unchanged-peer regeneration warnings as dependency-review suggestions, without claiming regeneration history from file identity. + ## [1.18.0] — 2026-09-27 ### Added diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index c04ca61..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,2 +0,0 @@ -# Project instructions -Read and follow AGENTS.md in this directory. It is the shared source of project rules for Codex and Claude. Global working preferences apply unless this project overrides them. diff --git a/README.md b/README.md index dc4becb..52906d1 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,14 @@ cd codegen-compare-tool For downloaded builds, replace `python -m compare_tool` with `compare-tool.exe` or `python compare_tool.pyz`. +Check consistency before delivery using one folder or ZIP (new in source; not released yet): + +```bash +python -m compare_tool --check-current current +``` + +This mode runs no diff and writes no HTML. The `delivery` policy gates missing input and unchecked items; existing comparison commands and YAML remain supported. See [usage](docs/usage.md#consistency-check) for mappings and exceptions. + Create a self-contained HTML report: ```bash @@ -62,6 +70,7 @@ Folders and ZIP archives are accepted as inputs. |---|---| | Desktop viewer | Interactive side-by-side review | | HTML report | Sharing or publishing as a CI artifact | +| `--check-current PATH` | CURRENT consistency and coverage before delivery | | `--no-report` | Detailed terminal review and diagnostics without generated files | | `--json` | Structured pipeline processing | | `--sarif` | Code-scanning annotations | @@ -96,7 +105,7 @@ Old/new regeneration warnings remain advisory. The optional CURRENT-tree check i | `1` | Real changes found | | `2` | Comparison incomplete or failed | -`--exit-zero` converts exit code `1` to `0`. It never suppresses exit code `2`. +`--exit-zero` suppresses only file-difference failures, never consistency failures or incomplete scans. `--check-current` returns `0` for a policy pass, `1` for failed rules and `2` for incomplete input/scanning or unchecked items under delivery policy. ## Requirements diff --git a/build.ps1 b/build.ps1 index 8ed18f0..6ef37a2 100644 --- a/build.ps1 +++ b/build.ps1 @@ -50,7 +50,7 @@ sys.exit(main()) # --- PyInstaller onefile (.exe): CLI + viewer in one binary --- if (-not $PyzOnly) { - python -m pip install --upgrade pyinstaller "PySide6>=6.5" + python -m pip install --upgrade pyinstaller "PySide6>=6.5,!=6.12.0" if ($LASTEXITCODE -ne 0) { throw 'installing build dependencies failed' } python -m PyInstaller --noconfirm --clean packaging\compare-tool.spec if ($LASTEXITCODE -ne 0) { throw 'pyinstaller build failed' } diff --git a/compare_tool/__init__.py b/compare_tool/__init__.py index d887692..0cc83c6 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.18.0" +__version__ = "1.19.0" diff --git a/compare_tool/consistency.py b/compare_tool/consistency.py index 8b60d37..48f0463 100644 --- a/compare_tool/consistency.py +++ b/compare_tool/consistency.py @@ -22,13 +22,9 @@ not is the ordinary case: an internal logic or gain edit touches no interface and no calibration variable, so there is nothing for them to follow. -A second, cross-*model* advisory rides along here. When one model's generated C -gains an RTE access point (``+ Rte_Write_...``) while a *peer* model's C stayed -byte-identical, the batch was a single-model quick regen, not a full one: a full -regenerate rewrites at least a timestamp banner in every model, so an identical -peer is proof it was left untouched. A new RTE access widens the model's -interface, and the RTE layer plus the peer SWCs have to be regenerated before -the code will integrate -- so this ``+RTE`` cannot be shipped on its own. +A cross-model advisory reports a new RTE access alongside a byte-identical +peer. This is a prompt to inspect dependencies and generation records, not +proof of stale code: unchanged output alone cannot establish generation history. This is an **advisory, never a verdict**. It never folds a file, moves a count or changes the exit code. It only reports which artifact families of a model @@ -46,9 +42,10 @@ from __future__ import annotations import fnmatch +import json import re import xml.etree.ElementTree as ET -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Dict, List, Optional, Sequence, Tuple @@ -81,6 +78,19 @@ class FunctionInfo: """Generated C function used to resolve an AUTOSAR runnable.""" name: str + file: str = '' + + +@dataclass(frozen=True) +class RunnableInfo: + """One definition, identified by its complete AUTOSAR short-name path.""" + + identity: str + name: str + swc: str + symbol: str + prefix: str + source_file: str @dataclass(frozen=True) @@ -94,6 +104,9 @@ class AccessPoint: access_mode: str container: str source_file: str + swc: str = '' + port_ref: str = '' + data_ref: str = '' @dataclass @@ -109,10 +122,33 @@ class CurrentTreeCheck: functions_detected: int rte_calls_detected: int access_points_detected: int + policy: str = 'standard' + checked_calls: int = 0 + exemptions: List[Dict[str, Any]] = field(default_factory=list) + notes: List[str] = field(default_factory=list) + + @property + def exit_code(self): + if any(f['reason'] in ('CONSISTENCY_SCAN_ERROR', 'CONSISTENCY_INPUT_INCOMPLETE') + for f in self.findings): + return 2 + if self.policy == 'delivery' and self.skipped_accesses: + return 2 + return 1 if any(f['severity'] == 'fail' for f in self.findings) else 0 + + @property + def status(self): + if self.exit_code == 2 or self.skipped_accesses: + return 'INCOMPLETE' + if self.exit_code == 1: + return 'FAIL' + if self.ignored_accesses or self.skipped_rules or self.exemptions or self.notes: + return 'PASS WITH EXCEPTIONS' + return 'PASS' _RTE_CALL_RE = re.compile( - r'\b(Rte_(?:IRead|IWrite|Read|Write|Call)_[A-Za-z0-9_]+)\s*\(' + r'\b(Rte_[A-Za-z0-9_]+)\s*\(' ) _AUTOSAR_FUNCTION_RE = re.compile( r'\bFUNC\s*\([^)]*\)\s*([A-Za-z_][A-Za-z0-9_]*)' @@ -161,6 +197,9 @@ def _checker_defaults(): 'ignore_access_port_regex': list( DEFAULT_CHECKER_CONFIG['ignore_access_port_regex']), 'rules': dict(DEFAULT_CHECKER_CONFIG['rules']), + 'function_mappings': [], + 'exceptions': [], + 'no_rte_expected': '', } @@ -183,6 +222,22 @@ def load_checker_config(path: Optional[Path]) -> Dict[str, Any]: if path is None: return config raw = Path(path).read_text(encoding='utf-8') + if raw.lstrip().startswith('{') or Path(path).suffix.lower() == '.json': + supplied = json.loads(raw) + if not isinstance(supplied, dict): + raise ValueError('consistency config must be an object') + unknown = set(supplied) - set(config) + if unknown: + raise ValueError('unknown config sections: {}'.format(', '.join(sorted(unknown)))) + for key, value in supplied.items(): + if key == 'rules': + if not isinstance(value, dict): + raise ValueError('rules must be an object') + config['rules'].update(value) + else: + config[key] = value + _validate_config(config) + return config section = None for raw_line in raw.splitlines(): line = raw_line.strip() @@ -222,6 +277,42 @@ def load_checker_config(path: Optional[Path]) -> Dict[str, Any]: return config +def _validate_config(config): + for key in ('forbidden_rte_prefixes', 'ignore_access_port_regex'): + if not isinstance(config[key], list) or not all( + isinstance(value, str) for value in config[key]): + raise ValueError('{} must be a list of strings'.format(key)) + for name, severity in config['rules'].items(): + if name not in _CHECKER_RULES or severity not in ('fail', 'warn', 'skip', 'off'): + raise ValueError('invalid consistency rule or severity: {}'.format(name)) + if not isinstance(config.get('no_rte_expected', ''), str): + raise ValueError('no_rte_expected must be a reason string') + for key, required, allowed in ( + ('function_mappings', {'file', 'function', 'runnable', 'reason'}, + {'file', 'function', 'runnable', 'reason'}), + ('exceptions', {'rule', 'reason'}, + {'rule', 'reason', 'file', 'swc', 'runnable', 'port'}), + ): + entries = config.get(key, []) + if not isinstance(entries, list): + raise ValueError('{} must be a list'.format(key)) + for entry in entries: + if (not isinstance(entry, dict) or not required <= set(entry) + or set(entry) - allowed or not all( + isinstance(v, str) and v.strip() for v in entry.values())): + raise ValueError('invalid {} entry; scopes and reason must be nonempty'.format(key)) + if key == 'exceptions': + if not set(entry) & {'file', 'swc', 'runnable', 'port'}: + raise ValueError('exception requires a file, swc, runnable or port scope') + if entry['rule'] not in _CHECKER_RULES | { + 'unsupported_rte_api', 'runnable_not_resolved', + 'ambiguous_port_dataelement', 'runnable_not_implemented'}: + raise ValueError('unknown exception rule: {}'.format(entry['rule'])) + for scope in ('swc', 'runnable'): + if scope in entry and not entry[scope].startswith('/'): + raise ValueError('{} must be a full AUTOSAR path'.format(scope)) + + def _local_name(tag): return tag.rsplit('}', 1)[-1] @@ -231,10 +322,7 @@ def _ref_short_name(ref): def _api_type(api): - for name in ('IRead', 'IWrite', 'Read', 'Write', 'Call'): - if api.startswith('Rte_{}_'.format(name)): - return name - return 'Unknown' + return api.split('_', 2)[1] def _expected_access(api_type): @@ -333,7 +421,7 @@ def _scan_code(root, rels): errors.append(_scan_error(rel, exc)) continue detected = _discover_functions(content) - functions.extend(FunctionInfo(item['name']) for item in detected) + functions.extend(FunctionInfo(item['name'], rel) for item in detected) searchable = _mask_c_literals(strip_c_comments(content)) for match in _RTE_CALL_RE.finditer(searchable): api = match.group(1) @@ -347,243 +435,238 @@ def _scan_code(root, rels): return calls, functions, errors +def _child_text(element, name): + return next(((child.text or '').strip() for child in element + if _local_name(child.tag) == name), '') + + def _parse_variable_access(variable_access): - port_ref = data_ref = None + refs = {} 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) + if tag in ('PORT-PROTOTYPE-REF', 'TARGET-DATA-PROTOTYPE-REF'): + refs[tag] = (node.text or '').strip().rstrip('/') + return refs.get('PORT-PROTOTYPE-REF', ''), refs.get('TARGET-DATA-PROTOTYPE-REF', '') def _parse_arxml(base, rels): accesses = [] - runnable_files = {} + runnables = {} errors = [] for rel in rels: - path = Path(base) / rel try: + path = Path(base) / rel if looks_binary(path): raise UnicodeError('file is binary') xml_root = ET.fromstring(read_text(path)) - except (OSError, UnicodeError, ET.ParseError) as exc: + if _local_name(xml_root.tag) != 'AUTOSAR': + raise ValueError('expected AUTOSAR root element') + except (OSError, UnicodeError, ET.ParseError, ValueError) 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: + # Named ancestors preserve package, component and behavior ownership. + stack = [(xml_root, (), '', '')] + while stack: + element, ancestors, swc, prefix = stack.pop() + tag = _local_name(element.tag) + name = _child_text(element, 'SHORT-NAME') + names = ancestors + (name,) if name else ancestors + identity = '/' + '/'.join(names) + if tag.endswith('SW-COMPONENT-TYPE'): + swc = identity + prefix = next((_child_text(child, 'SYMBOL') for child in element + if _local_name(child.tag) == 'SYMBOL-PROPS'), '') + if tag == 'RUNNABLE-ENTITY': + if not name: + errors.append(_scan_error(rel, ValueError('runnable has no SHORT-NAME'))) continue - direction, mode = _ARXML_ACCESS_CONTAINERS[container] - for variable_access in container_elem.iter(): - if _local_name(variable_access.tag) != 'VARIABLE-ACCESS': + info = RunnableInfo(identity, name, swc, + _child_text(element, 'SYMBOL'), prefix, rel) + runnables.setdefault(identity, []).append(info) + for container_elem in element: + container = _local_name(container_elem.tag) + if container not in _ARXML_ACCESS_CONTAINERS: continue - port, data_element = _parse_variable_access(variable_access) - if port and data_element: + direction, mode = _ARXML_ACCESS_CONTAINERS[container] + for variable in container_elem.iter(): + if _local_name(variable.tag) != 'VARIABLE-ACCESS': + continue + port_ref, data_ref = _parse_variable_access(variable) + if not port_ref or not data_ref: + errors.append(_scan_error(rel, ValueError( + 'incomplete VARIABLE-ACCESS in {}'.format(identity)))) + continue accesses.append(AccessPoint( - runnable, port, data_element, direction, mode, - container, rel)) - return accesses, runnable_files, errors + identity, _ref_short_name(port_ref), _ref_short_name(data_ref), + direction, mode, container, rel, swc, port_ref, data_ref)) + continue + stack.extend((child, names, swc, prefix) for child in reversed(list(element))) + for identity, definitions in runnables.items(): + if len({(item.symbol, item.prefix) for item in definitions}) > 1: + errors.append(_scan_error(definitions[0].source_file, ValueError( + 'conflicting runnable symbols for {}'.format(identity)))) + return accesses, runnables, errors def _scan_error(rel, exc): return { - 'reason': 'CONSISTENCY_SCAN_ERROR', - 'severity': 'fail', - 'file': rel, + 'reason': 'CONSISTENCY_SCAN_ERROR', 'severity': 'fail', 'file': rel, 'error': '{}: {}'.format(type(exc).__name__, exc), } -def _find_runnable(function_name, runnable_names): +def _find_runnable(function_name, file, runnables, config): 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))) + mapped = {entry['runnable'] for entry in config.get('function_mappings', []) + if entry['function'] == function_name and _matches(file, [entry['file']])} + if mapped: + return next(iter(mapped)) if len(mapped) == 1 and mapped <= set(runnables) else None + matches = set() + for identity, definitions in runnables.items(): + for info in definitions: + # An explicit symbol is authoritative. Do not guess with suffixes. + symbol = info.symbol or info.name + candidates = {info.prefix + symbol} + if not info.symbol and not info.prefix and info.swc: + candidates.add(_ref_short_name(info.swc) + '_' + info.name) + if function_name in candidates: + matches.add(identity) + return next(iter(matches)) if len(matches) == 1 else None + + +def _api_matches_access(call, access): + payload = call.api[len('Rte_{}_'.format(call.api_type)):] + suffix = '{}_{}'.format(access.port, access.data_element) + return payload == suffix or (call.api_type in ('IRead', 'IWrite') + and payload.endswith('_' + suffix)) + + +def _candidate_pairs(call, access_points, runnable, runnables): + swc = runnables[runnable][0].swc + # Only the resolved component can supply a candidate. For legacy minimal + # ARXML without component ancestors, stay inside the resolved runnable. + scoped = [a for a in access_points if + (a.swc == swc if swc else a.runnable == runnable)] + return {(a.port_ref, a.data_ref) for a in scoped if _api_matches_access(call, a)} 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) + key = (access.runnable, access.port_ref, access.data_ref) + 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 + for (runnable, port_ref, data_ref), file_map in grouped.items(): file_modes = { - source: sorted({'{}/{}'.format(item.direction, item.access_mode) - for item in accesses}) + source: sorted({'{}/{}'.format(a.direction, a.access_mode) for a in accesses}) for source, accesses in file_map.items() } if len({tuple(modes) for modes in file_modes.values()}) > 1: + sample = next(iter(file_map.values()))[0] findings.append({ - 'reason': 'INCONSISTENT_ARXML_ACCESS_MODE', - 'runnable': runnable, - 'port': port, - 'data_element': data_element, - 'files': file_modes, + 'reason': 'INCONSISTENT_ARXML_ACCESS_MODE', 'runnable': runnable, + 'swc': sample.swc, 'port': _ref_short_name(port_ref), + 'data_element': _ref_short_name(data_ref), 'files': file_modes, }) return findings -def _check_access_points(calls, access_points, runnable_files, ignore_pattern): +def _check_access_points(calls, access_points, runnables, ignore_pattern, config): issues = _duplicate_inconsistencies(access_points) - ignored = 0 + ignored = checked = 0 skipped = [] - by_file_runnable_pair, all_pairs = _access_indexes(access_points) - runnable_names = set(runnable_files) - issue_keys = set() + used = set() for call in calls: + runnable = _find_runnable(call.function, call.file, runnables, config) + context = {'call': call, 'runnable': runnable, + 'swc': runnables[runnable][0].swc if runnable else ''} if call.api_type not in ('Read', 'Write', 'IRead', 'IWrite'): + skipped.append(dict(context, reason='UNSUPPORTED_RTE_API')) 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], - }) + # Preserve the existing missing-pair failure even without ownership. + # A global absence proves a mismatch; a global match never proves + # ownership, so this call still remains explicitly unchecked. + if not any(_api_matches_access(call, a) for a in access_points): + issues.append(dict(context, reason='NO_MATCHING_PORT_DATAELEMENT')) + skipped.append(dict(context, reason='RUNNABLE_NOT_RESOLVED')) 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'): + pairs = _candidate_pairs(call, access_points, runnable, runnables) + if len(pairs) > 1: + skipped.append(dict(context, reason='AMBIGUOUS_PORT_DATAELEMENT')) continue - payload = call.api[len('Rte_{}_'.format(call.api_type)):] - if ignore_pattern and ignore_pattern.search(payload): + checked += 1 + if not pairs: + issues.append(dict(context, reason='NO_MATCHING_PORT_DATAELEMENT')) 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])) + port_ref, data_ref = next(iter(pairs)) + expected = _expected_access(call.api_type) + used.add((runnable, port_ref, data_ref, expected)) + context.update(port=_ref_short_name(port_ref), data_element=_ref_short_name(data_ref)) + for source_file in sorted({r.source_file for r in runnables[runnable]}): + accesses = [a for a in access_points if a.runnable == runnable + and a.source_file == source_file + and (a.port_ref, a.data_ref) == (port_ref, data_ref)] + if not accesses: + issues.append(dict(context, reason='MISSING_ACCESS_POINT_IN_ARXML', + arxml_file=source_file)) + elif expected not in {(a.direction, a.access_mode) for a in accesses}: + issues.append(dict( + context, reason='ACCESS_MODE_MISMATCH', arxml_file=source_file, + expected='/'.join(expected), + actual=['{}/{} [{}]'.format(a.direction, a.access_mode, a.container) + for a in accesses])) + return issues, ignored, skipped, checked, used + + +def _orphan_accesses(functions, access_points, runnables, ignore_pattern, config, used): + resolved = {_find_runnable(f.name, f.file, runnables, config) for f in functions} findings = [] + skipped = [] + for identity, definitions in runnables.items(): + if identity not in resolved: + skipped.append({'reason': 'RUNNABLE_NOT_IMPLEMENTED', + 'runnable': identity, 'swc': definitions[0].swc, + 'arxml_file': definitions[0].source_file}) seen = set() for access in access_points: - key = (access.direction, access.port, access.data_element) + key = (access.runnable, access.port_ref, access.data_ref, + (access.direction, access.access_mode)) + report_key = (access.source_file, key) 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, + 'reason': 'ORPHAN_ACCESS', 'arxml_file': access.source_file, + 'runnable': access.runnable, 'swc': access.swc, 'port': access.port, 'data_element': access.data_element, - 'access': '{}/{} [{}]'.format( - access.direction, access.access_mode, access.container), + 'access': '{}/{} [{}]'.format(access.direction, access.access_mode, access.container), }) - return findings + return findings, skipped + + +def _apply_exceptions(items, exceptions): + kept, waived = [], [] + for item in items: + call = item.get('call') + context = dict(item) + context['file'] = call.file if call else item.get('arxml_file', item.get('file', '')) + match = next((entry for entry in exceptions if entry['rule'] == item['reason'].lower() + and all(_matches(context.get(key, '') or '', [value]) if key == 'file' + else context.get(key) == value + for key, value in entry.items() if key not in ('rule', 'reason'))), None) + if match: + waived.append(dict(item, justification=match['reason'])) + else: + kept.append(item) + return kept, waived def _compile_ignore_pattern(config, cli_pattern): @@ -609,7 +692,7 @@ def _classify(findings, config): out = [] skipped = 0 for finding in findings: - if finding['reason'] == 'CONSISTENCY_SCAN_ERROR': + if finding['reason'] in ('CONSISTENCY_SCAN_ERROR', 'CONSISTENCY_INPUT_INCOMPLETE'): out.append(finding) continue severity = config['rules'].get(finding['reason'].lower(), 'fail') @@ -626,7 +709,8 @@ def _classify(findings, config): def current_tree_check(root: Path, config: Optional[Dict[str, Any]] = None, exclude: Sequence[str] = (), - ignore_access_port_regex: Optional[str] = None + ignore_access_port_regex: Optional[str] = None, + policy: str = 'standard' ) -> CurrentTreeCheck: """Validate CURRENT generated C calls against CURRENT ARXML declarations. @@ -636,6 +720,9 @@ def current_tree_check(root: Path, config: Optional[Dict[str, Any]] = None, """ root = Path(root) config = config or _checker_defaults() + _validate_config(config) + if policy not in ('standard', 'delivery'): + raise ValueError('unknown consistency policy: {}'.format(policy)) listing_errors: List[Tuple[str, str]] = [] rels = sorted(rel for rel in list_files(root, listing_errors) if not _matches(rel, exclude)) @@ -643,29 +730,73 @@ def current_tree_check(root: Path, config: Optional[Dict[str, Any]] = None, 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) + access_points, runnables, 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 + notes = [] + no_rte_reason = config.get('no_rte_expected', '').strip() + if no_rte_reason and not calls and not access_points: + notes.append('No RTE access expected: {}'.format(no_rte_reason)) + missing = [] + if not c_rels: + missing.append('no C files in the selected scope') + if not notes: + if not arxml_rels: + missing.append('no ARXML files in the selected scope') + elif not runnables: + missing.append('no ARXML runnable definitions in the selected scope') + if not calls and not access_points: + missing.append('no RTE calls or access points; declare no_rte_expected if intentional') + if missing: + raw.append({'reason': 'CONSISTENCY_INPUT_INCOMPLETE', 'severity': 'fail', + 'file': '.', 'error': '; '.join(missing)}) + for entry in config.get('function_mappings', []): + if entry['runnable'] not in runnables: + raw.append({'reason': 'CONSISTENCY_INPUT_INCOMPLETE', 'severity': 'fail', + 'file': entry['file'], 'error': 'mapping target does not exist: {}'.format( + entry['runnable'])}) + if any(f.name == entry['function'] and _matches(f.file, [entry['file']]) + for f in functions): + notes.append('Function mapping {} / {} -> {}: {}'.format( + entry['file'], entry['function'], entry['runnable'], entry['reason'])) 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)) + identity = _find_runnable(call.function, call.file, runnables, config) + finding = {'reason': 'FORBIDDEN_RTE_API', 'call': call, + 'runnable': identity, + 'swc': runnables[identity][0].swc if identity else ''} + if identity and call.api_type in ('Read', 'Write', 'IRead', 'IWrite'): + pairs = _candidate_pairs(call, access_points, identity, runnables) + if len(pairs) == 1: + finding['port'] = _ref_short_name(next(iter(pairs))[0]) + raw.append(finding) + issues, ignored, skipped, checked, used = _check_access_points( + calls, access_points, runnables, ignore_pattern, config) + raw.extend(issues) + orphans, unresolved = _orphan_accesses( + functions, access_points, runnables, ignore_pattern, config, used) + raw.extend(orphans) + skipped.extend(unresolved) + raw, exemptions = _apply_exceptions(raw, config.get('exceptions', [])) + skipped, waived_skips = _apply_exceptions(skipped, config.get('exceptions', [])) + exemptions.extend(waived_skips) + for name, severity in sorted(config['rules'].items()): + if severity in ('skip', 'off'): + notes.append('Rule {} configured {}'.format(name, severity)) + if exclude: + notes.append('Excluded file patterns: {}'.format(', '.join(exclude))) + if ignore_pattern: + notes.append('Ignored access regex: {}'.format(ignore_pattern.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)) + len(arxml_rels), len(functions), len(calls), len(access_points), + policy, checked, exemptions, notes) def _iface_changed(r): @@ -770,15 +901,11 @@ def _c_status(rels, results): def _rte_regen_advisories(groups, results, shared_group): - """``[(model, message)]`` for models that gained an RTE access point while a - peer model's C stayed byte-identical -- the tell of a single-model quick - regen. A ``+RTE`` cannot be integrated until the RTE layer and the peers are - regenerated too, so it is flagged even when the model's own files are all - self-consistent. Advisory only; see the module docstring. + """Suggest checking dependencies when a new RTE access has an unchanged peer. + + File statuses alone cannot establish whether regeneration was required or + performed. This heuristic never gates delivery. """ - # a model whose C is entirely identical was not regenerated in this batch: - # a real regenerate rewrites at least a timestamp banner (ignorable-only), - # so identical -- not merely noisy -- is the exact proof it was skipped. identical = set() rte_added = set() for model in groups: @@ -793,9 +920,9 @@ def _rte_regen_advisories(groups, results, shared_group): rte_added.add(model) break if not identical: - return [] # no skipped peer, so nothing here is evidence of a quick regen + return [] # no unchanged peer to mention # a model carrying a +RTE always has a changed .c (that is where the access # point was found), so it can never be one of the identical peers itself return [(model, 'gained an RTE access while a peer model stayed identical ' - '-- regenerate the architecture before integrating') + '-- review dependencies and generation records; regeneration need is unproven') for model in sorted(rte_added)] diff --git a/compare_tool/main.py b/compare_tool/main.py index 14d59c5..3ab3f79 100644 --- a/compare_tool/main.py +++ b/compare_tool/main.py @@ -146,6 +146,8 @@ def autosar_text(parts): def _current_consistency_lines(check): """Compact terminal rendering for the CURRENT-tree RTE/ARXML check.""" lines = [ + ' Consistency: {} (policy: {}, exit: {})'.format( + check.status, check.policy, check.exit_code), ' Current tree: {} C file(s), {} ARXML file(s), {} RTE call(s), ' '{} access point(s)'.format( check.c_files_scanned, check.arxml_files_scanned, @@ -161,7 +163,7 @@ def _current_consistency_lines(check): detail = call.api if call.function: detail += ' in {}'.format(call.function) - if reason == 'CONSISTENCY_SCAN_ERROR': + if reason in ('CONSISTENCY_SCAN_ERROR', 'CONSISTENCY_INPUT_INCOMPLETE'): detail = finding['error'] elif reason == 'NO_MATCHING_PORT_DATAELEMENT': detail += ' has no matching ARXML Port/DataElement' @@ -186,10 +188,27 @@ def _current_consistency_lines(check): 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))) + for item in check.skipped_accesses: + call = item.get('call') + location = '{}:{}'.format(call.file, call.line) if call else item.get('arxml_file', '-') + detail = call.api if call else item.get('runnable', '-') + lines.append(' !! [UNCHECKED] {} {} -- {}'.format(item['reason'], location, detail)) + lines.append(' Coverage: {} / {} RTE call(s) checked; {} unchecked item(s); ' + '{} scoped exception(s).'.format( + check.checked_calls, check.rte_calls_detected, + len(check.skipped_accesses), len(check.exemptions))) + lines.append(' Findings: {} FAIL, {} WARN.'.format( + sum(f['severity'] == 'fail' for f in check.findings), + sum(f['severity'] == 'warn' for f in check.findings))) + lines.append(' Scope: .c calls to Rte_Read/Write/IRead/IWrite against ARXML ' + 'Sender/Receiver access declarations. Other RTE APIs are UNCHECKED.') + for item in check.exemptions: + call = item.get('call') + target = '{}:{} {}'.format(call.file, call.line, call.api) if call else item.get('runnable', '-') + lines.append(' [EXCEPTION] {} {} -- {}'.format( + item['reason'], target, item['justification'])) + for note in check.notes: + lines.append(' Policy: {}'.format(note)) if check.skipped_rules: lines.append(' {} finding(s) skipped by rule configuration.'.format( check.skipped_rules)) @@ -200,6 +219,8 @@ def _current_consistency_lines(check): 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.') + lines.append(' Missing input always exits 2. UNCHECKED items exit 2 under ' + 'delivery policy; standard policy leaves them non-gating.') return lines @@ -508,11 +529,19 @@ def _parser(): 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('--check-current', metavar='PATH', default=None, + help='check one CURRENT folder or ZIP without a baseline, ' + 'diff or HTML; defaults to delivery policy') + ap.add_argument('--consistency-policy', choices=('standard', 'delivery'), + default=None, help='delivery gates unchecked items; standard ' + 'keeps them non-gating. Default: standard for comparisons, ' + 'delivery for --check-current') 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') + 'subset, or JSON with scoped exceptions and function ' + 'mappings; also accepted with --check-current') 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; ' @@ -546,7 +575,8 @@ def _wants_viewer(args): """The viewer is the default front end: the terminal compare runs only 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)) + return not args.check_current and not args.no_report and bool( + args.qt or not (args.old_dir and args.new_dir)) def viewer_requested(argv): @@ -560,7 +590,8 @@ def viewer_requested(argv): return False # the help text belongs on a console the user can read try: args = _parser().parse_args(argv) - if (args.check_consistency or args.consistency_config or + if (args.check_current or args.consistency_policy or + 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) @@ -598,6 +629,8 @@ def _viewer_source(ap, arg, zip_temp): def main(argv=None): ap = _parser() args = ap.parse_args(argv) + if not _wants_viewer(args): + print('codegen-compare-tool {}'.format(__version__)) # boxed so _resolve_source can create the extraction dir lazily; removed in # the finally whichever way this run exits zip_temp = [None] @@ -628,7 +661,45 @@ def _load_user_rules(ap, args): return tuple(rules) +def _run_current(ap, args, zip_temp): + """Single-source validation; old positional comparison syntax stays intact.""" + incompatible = { + 'old_dir/new_dir': args.old_dir or args.new_dir, + '--qt/--viewer': args.qt, '--report': args.report, + '--arxml-only': args.arxml_only, '--baseline-name': args.baseline_name, + '--review': args.review, '--rules': args.rules, + '--skip-var-renames': args.skip_var_renames, + '--max-diff-lines': args.max_diff_lines, + '--json/--sarif (CURRENT findings export is not implemented)': args.json or args.sarif, + } + conflicts = [name for name, value in incompatible.items() if value] + if conflicts: + ap.error('--check-current cannot be combined with {}'.format(', '.join(conflicts))) + try: + config = consistency.load_checker_config( + Path(args.consistency_config) if args.consistency_config else None) + consistency.validate_checker_regex(config, args.ignore_access_port_regex) + except (OSError, UnicodeError, ValueError) as exc: + ap.error('consistency configuration is invalid: {}'.format(exc)) + root, label = _resolve_source(ap, args.check_current, zip_temp) + if not root.is_dir(): + ap.error('CURRENT is not a directory or readable ZIP: {}'.format(args.check_current)) + for stream in (sys.stdout, sys.stderr): + if hasattr(stream, 'reconfigure'): + stream.reconfigure(errors='replace') + check = consistency.current_tree_check( + root, config, exclude=args.exclude, + ignore_access_port_regex=args.ignore_access_port_regex, + policy=args.consistency_policy or 'delivery') + print('CURRENT: {}'.format(args.current_name or label or args.check_current)) + for line in _current_consistency_lines(check): + print(line) + return check.exit_code + + def _run(ap, args, zip_temp): + if args.check_current: + return _run_current(ap, args, zip_temp) if args.no_report: if args.qt: ap.error('--no-report cannot be combined with --qt/--viewer') @@ -636,14 +707,14 @@ def _run(ap, args, zip_temp): ap.error('--no-report requires both old_dir and new_dir') user_rules = _load_user_rules(ap, args) wants_viewer = _wants_viewer(args) - if wants_viewer and (args.check_consistency or args.consistency_config or + if wants_viewer and (args.check_consistency or args.consistency_policy 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 + if not args.check_consistency and (args.consistency_policy or args.consistency_config or args.ignore_access_port_regex): - ap.error('--consistency-config and --ignore-access-port-regex require ' - '--check-consistency') + ap.error('--consistency-config, --consistency-policy and ' + '--ignore-access-port-regex require --check-consistency or --check-current') 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') @@ -715,7 +786,8 @@ def _run(ap, args, zip_temp): try: current_check = consistency.current_tree_check( new_root, checker_config, exclude=args.exclude, - ignore_access_port_regex=args.ignore_access_port_regex) + ignore_access_port_regex=args.ignore_access_port_regex, + policy=args.consistency_policy or 'standard') except ValueError as e: ap.error(str(e)) @@ -797,12 +869,8 @@ def _exit_code(counts, exit_zero, current_check=None): 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 current_check.exit_code: + return current_check.exit_code if exit_zero: return 0 return 1 if counts['real-change'] or counts['added'] or counts['deleted'] else 0 diff --git a/compare_tool/qtviewer/advisories.py b/compare_tool/qtviewer/advisories.py index c148eb5..fa40de6 100644 --- a/compare_tool/qtviewer/advisories.py +++ b/compare_tool/qtviewer/advisories.py @@ -5,7 +5,7 @@ Display only, exactly like the report's block -- it names the model and the caution, never folds a file, moves a count or changes the exit code. The text comes from :func:`compare_tool.report.consistency_advisories`, so all three -surfaces say the same thing (see CLAUDE.md, "one seam per shared decision"); the +surfaces say the same thing (see AGENTS.md, shared comparison/view-model decisions); the colours are theme roles, matching the report's ``if-chg`` marker, so a literal here cannot make the viewer and the report disagree. """ diff --git a/compare_tool/report.py b/compare_tool/report.py index 1519e1f..d46ac69 100644 --- a/compare_tool/report.py +++ b/compare_tool/report.py @@ -1003,7 +1003,7 @@ def _counts_html(rels, results): fallback still has to be TRUE of the scan, though -- a model whose files all changed by UUID/timestamp alone is the everyday regenerated case, and calling it 'Identical' would be the report claiming something the hunks - say otherwise (see CLAUDE.md, "the record is never the filtered view"). + say otherwise (see AGENTS.md, the raw-result invariant). 'Identical' is the verdict word the folder tree and the tags already use, so the two surfaces name the same state the same way.""" c = _group_counts(rels, results) diff --git a/docs/architecture.md b/docs/architecture.md index 4b61c29..93e4685 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -113,6 +113,8 @@ A reorder is noise only when every line is a side-effect-free scalar assignment, `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. +`--check-current` invokes the same checker directly on one source, bypassing diff scanning and HTML. `CurrentTreeCheck` owns status and consistency exit codes; `main._exit_code` combines them with comparison errors/differences. Standard policy leaves unchecked items non-gating; delivery returns 2. Missing input always returns 2. Existing YAML stays supported; JSON adds scoped mappings and exceptions with reasons. Parsing retains full runnable paths, SWC, SYMBOL/prefix and full access references; orphan matching is runnable-specific. CURRENT finding exports remain a [P2 TODO](todo.md). + File move pairing runs after verdicts are settled. A pair is presentation metadata: Added and Deleted verdicts remain in the result and counts. ## Shared presentation seams diff --git a/docs/todo.md b/docs/todo.md new file mode 100644 index 0000000..ae454c1 --- /dev/null +++ b/docs/todo.md @@ -0,0 +1,10 @@ +# TODO + +## P2: CURRENT consistency findings in JSON and SARIF + +- [ ] Export CURRENT findings and unchecked items with stable rule IDs, severity, SWC/runnable identity, C file/line and ARXML references. +- [ ] Include policy, checked/ignored/unchecked coverage, scoped exceptions and reasons; distinguish missing input from a clean result. +- [ ] Version the JSON extension without breaking existing comparison consumers; add SARIF consistency rules alongside file-difference results. +- [ ] Support exports from `--check-current` and comparison checks, with tests proving terminal findings, artifact contents and exit codes agree. + +Until implemented, comparison JSON/SARIF retain their existing schema and do not contain CURRENT findings. `--check-current` rejects these output flags instead of writing an incomplete delivery record. Capture terminal output when a delivery record is needed. diff --git a/docs/usage.md b/docs/usage.md index 317b993..b175d0b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -18,6 +18,8 @@ This guide is for people running CodeGen Compare Tool. Run `python -m compare_to Both inputs can be folders or ZIP archives. +CLI comparisons and `--check-current` print `codegen-compare-tool ` before processing inputs. Use `--version` to print only the tool version. + ### HTML report ```bash @@ -69,10 +71,12 @@ The diagnostics do not print source content and do not change verdicts or exit c ## Side-by-side viewer +PySide6 6.12.0 is excluded because Qt highlighting/teardown crashes with Python 3.11; the CLI does not depend on Qt. + Install PySide6, then start the viewer: ```bash -pip install PySide6 +pip install "PySide6>=6.5,!=6.12.0" python -m compare_tool ``` @@ -192,7 +196,7 @@ The tool warns about combinations that may indicate incomplete regeneration, inc - ARXML interface changes without corresponding generated C changes; - A2L changes without corresponding generated C changes; -- a model gaining RTE access while a related model remains identical. +- a model gaining RTE access while another model remains identical. 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: @@ -237,10 +241,83 @@ Rule severities are: 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. +`--consistency-config` and `--ignore-access-port-regex` require `--check-consistency` or `--check-current`. 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. +### Delivery check without a baseline + +```bash +python -m compare_tool --check-current current +python -m compare_tool --check-current delivery.zip --consistency-config project.json +``` + +`--check-current PATH` checks one folder or ZIP, without comparison, HTML or a viewer. It accepts `--exclude`, `--current-name`, `--consistency-config`, `--ignore-access-port-regex`, `--consistency-policy`, and the redundant `--no-report` / `--check-consistency` flags. `--exit-zero` never suppresses its failures. Comparison-only options and `--json` / `--sarif` are rejected; CURRENT finding exports are tracked in [TODO](todo.md). + +Existing two-folder commands and the YAML subset above remain valid. Comparison without `--check-consistency` is unchanged. Select stricter completeness checking on the old command explicitly: + +```bash +python -m compare_tool baseline current --no-report --check-consistency --consistency-policy delivery --exit-zero +``` + +| Condition | `standard` (comparison default) | `delivery` (`--check-current` default) | +|---|---:|---:| +| Complete check, no failed rule | 0 | 0 | +| Rule configured `fail` | 1 | 1 | +| Read/list/parse error, missing required input | 2 | 2 | +| Unsupported API, ambiguous reference, unresolved call/runnable | Non-gating; shown as INCOMPLETE | 2 | + +The terminal shows PASS, PASS WITH EXCEPTIONS, FAIL or INCOMPLETE, FAIL/WARN totals and coverage. In a comparison, file differences still affect the final process exit unless `--exit-zero` is used. Under standard policy INCOMPLETE coverage may be non-gating, so use delivery policy for completeness enforcement. WARN rules remain non-gating in both policies; set `orphan_access: fail` if your project requires it. + +Required input includes C files and ARXML runnable definitions. Empty folders, filters that remove all required input, or a tree with neither calls nor access declarations cannot silently pass. A documented `no_rte_expected` JSON reason permits a C-only utility with no detected RTE calls or access declarations; it cannot waive read/parse errors, absent C files, or real RTE usage. + +### Project policy and scoped exceptions + +The default prohibition on `Rte_IRead_` / `Rte_IWrite_` is an inherited **project policy**, not a universal AUTOSAR restriction. Keep existing YAML files, or use JSON for structured mappings and exceptions; both use only the standard library. Omitted settings retain existing defaults. This example allows implicit APIs, gates orphan accesses, and records two narrowly scoped decisions: + +```json +{ + "forbidden_rte_prefixes": [], + "rules": {"orphan_access": "fail"}, + "function_mappings": [ + { + "file": "Ctrl_autosar_rtw/Ctrl.c", + "function": "read_inputs", + "runnable": "/Pkg/Ctrl/IB/Step", + "reason": "Generated helper called only by Step; reviewed against generated C" + } + ], + "exceptions": [ + { + "rule": "unsupported_rte_api", + "file": "Ctrl_autosar_rtw/Ctrl.c", + "runnable": "/Pkg/Ctrl/IB/Step", + "reason": "Client/server contract verified separately by integration" + } + ] +} +``` + +Mappings require an exact function name, a file glob, an existing full runnable path and a reason. They are explicit project assertions, not inferred call-graph proof. Multiple matching targets remain unresolved. Mappings that refer to nonexistent runnables make the check incomplete. Applied mappings are printed in the terminal. + +Exceptions require a rule, a nonempty reason and at least one scope: `file` (glob), `swc` (exact full path), `runnable` (exact full path), or `port` (exact short name). All supplied scopes must match; unresolved fields cannot match a scope. Exceptions can waive the six configurable findings above, plus `unsupported_rte_api`, `runnable_not_resolved`, `runnable_not_implemented` and `ambiguous_port_dataelement`. They cannot waive scan errors or missing input. Each applied exception and reason is printed. Legacy ignore regexes, excluded files and disabled rules remain accepted and visible; scoped exceptions are preferable when the decision needs a reason. + +For a utility with no RTE contract: + +```json +{"no_rte_expected": "Standalone math utility, reviewed to have no RTE contract"} +``` + +### Matching and coverage boundaries + +Runnable identity includes its package, SWC and internal behavior. Explicit `SYMBOL` plus the component symbol prefix determines the C entry point. When no symbol is supplied, only unambiguous short-name/component-name candidates are used; suffix guesses cannot establish ownership. Duplicate definitions are compared within the same full runnable path. Full port/data references are retained, and ambiguous short-name or underscore combinations are UNCHECKED. Orphan matching includes runnable, reference, direction and access mode. + +The checker reads `.c` files and validates `Rte_Read`, `Rte_Write`, `Rte_IRead` and `Rte_IWrite` against Sender/Receiver access declarations. Other detected `Rte_*` calls, including `Rte_Call`, `Rte_Send` and `Rte_Receive`, are explicitly UNCHECKED. Declared runnables without resolved C functions are also UNCHECKED. Helper calls require a documented mapping when ownership cannot be resolved. + +This is a source consistency check, not a full AUTOSAR schema validator or C compiler: it does not preprocess conditional compilation, expand macros, scan inline header implementations or infer helper call graphs. Select the intended generated sources and record applicable policy decisions. A policy pass applies only to this declared scope. + +Old/new unchanged-peer advisories ask for dependency and generation-record review. Byte-identical output does not prove whether regeneration occurred or was required; these advisories never gate delivery and use the same wording in CLI, HTML and viewer. + ## HTML report The report includes the Overview, verdict filters, focused diffs, semantic changes and consistency warnings. Long unchanged regions and generator noise are collapsed so real changes remain visible. @@ -270,6 +347,8 @@ python -m compare_tool baseline.zip current.zip \ Publish the HTML report and any JSON/SARIF outputs as CI artifacts. +CURRENT findings currently remain terminal-only. JSON/SARIF comparison exports contain the comparison results and old/new advisories, not CURRENT findings; see [TODO](todo.md). + ## Single-file build The release page provides: diff --git a/docs/vi/README.md b/docs/vi/README.md index ac83fa6..dd5aec2 100644 --- a/docs/vi/README.md +++ b/docs/vi/README.md @@ -32,6 +32,14 @@ cd codegen-compare-tool Với single-file build, thay `python -m compare_tool` bằng `compare-tool.exe` hoặc `python compare_tool.pyz`. +Kiểm tra consistency trước delivery, chỉ cần một folder hoặc ZIP (tính năng mới trên source, chưa phát hành): + +```bash +python -m compare_tool --check-current current +``` + +Mode này không chạy diff hoặc tạo HTML. Policy `delivery` chặn khi thiếu dữ liệu hoặc còn mục chưa kiểm tra được; YAML và lệnh compare cũ vẫn được hỗ trợ. Xem [usage](usage.md#consistency-check) để cấu hình mapping và ngoại lệ. + Tạo HTML report độc lập: ```bash @@ -62,6 +70,7 @@ Input có thể là folder hoặc ZIP archive. |---|---| | Desktop viewer | Review side-by-side | | HTML report | Chia sẻ hoặc publish thành CI artifact | +| `--check-current PATH` | Kiểm tra consistency CURRENT trước delivery | | `--no-report` | Review nhanh trên terminal, không tạo file | | `--json` | Xử lý dữ liệu trong pipeline | | `--sarif` | Tạo code-scanning annotations | @@ -96,7 +105,7 @@ Old/new regeneration warning vẫn là advisory. CURRENT-tree check mặc địn | `1` | Có real change | | `2` | Comparison không đầy đủ hoặc thất bại | -`--exit-zero` chuyển exit code `1` thành `0`, nhưng không bao giờ bỏ qua exit code `2`. +`--exit-zero` chỉ bỏ qua real-change từ diff; không bỏ qua consistency failure hoặc incomplete. `--check-current` trả `0` khi policy pass, `1` khi rule fail và `2` khi scan thiếu dữ liệu hoặc còn mục UNCHECKED theo policy delivery. ## Yêu cầu diff --git a/docs/vi/architecture.md b/docs/vi/architecture.md index 51a200b..d714aae 100644 --- a/docs/vi/architecture.md +++ b/docs/vi/architecture.md @@ -104,6 +104,8 @@ Reorder chỉ là noise khi toàn bộ line là scalar assignment không side ef `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. +`--check-current` gọi trực tiếp cùng checker trên một source, bỏ qua scanner diff và HTML. `CurrentTreeCheck` sở hữu status và consistency exit code; `main._exit_code` kết hợp với comparison errors/differences. Policy standard giữ unchecked item không gate; delivery trả 2. Thiếu đầu vào luôn trả 2. YAML cũ giữ nguyên; JSON thêm mapping và ngoại lệ có scope/lý do. Parser giữ full runnable path, SWC, SYMBOL/prefix và full access references; orphan được xét theo runnable. CURRENT finding export vẫn là [P2 TODO](todo.md). + 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. ## Seam dùng chung diff --git a/docs/vi/todo.md b/docs/vi/todo.md new file mode 100644 index 0000000..4a7b07a --- /dev/null +++ b/docs/vi/todo.md @@ -0,0 +1,10 @@ +# TODO + +## P2: CURRENT consistency finding trong JSON và SARIF + +- [ ] Xuất CURRENT finding và mục chưa kiểm tra với rule ID ổn định, severity, SWC/runnable, C file/line và ARXML reference. +- [ ] Ghi policy, coverage checked/ignored/unchecked, ngoại lệ và lý do; phân biệt thiếu đầu vào với kết quả sạch. +- [ ] Version phần mở rộng JSON để giữ tương thích consumer hiện tại; thêm consistency rule vào SARIF bên cạnh file-difference result. +- [ ] Hỗ trợ export từ `--check-current` và comparison check; test để terminal finding, artifact và exit code thống nhất. + +Trong khi chưa triển khai, JSON/SARIF của comparison giữ schema cũ và chưa chứa CURRENT finding. `--check-current` từ chối các cờ output này để tránh tạo delivery record thiếu nội dung. Có thể lưu terminal output khi cần bằng chứng delivery. diff --git a/docs/vi/usage.md b/docs/vi/usage.md index 6b27d78..8ec1f1c 100644 --- a/docs/vi/usage.md +++ b/docs/vi/usage.md @@ -18,6 +18,8 @@ Tài liệu này dành cho người sử dụng CodeGen Compare Tool. Chạy `py Cả hai input đều có thể là folder hoặc ZIP archive. +CLI compare và `--check-current` in `codegen-compare-tool ` trước khi xử lý input. Dùng `--version` để chỉ in version của tool. + ### HTML report ```bash @@ -69,10 +71,12 @@ Diagnostics không in source content và không thay đổi verdict hoặc exit ## Viewer side-by-side +PySide6 6.12.0 bị loại khỏi dependency do crash trong Qt highlighting/teardown với Python 3.11; CLI không phụ thuộc Qt. + Cài PySide6 rồi mở viewer: ```bash -pip install PySide6 +pip install "PySide6>=6.5,!=6.12.0" python -m compare_tool ``` @@ -237,10 +241,83 @@ Rule severity gồm: 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. +`--consistency-config` và `--ignore-access-port-regex` yêu cầu `--check-consistency` hoặc `--check-current`. 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. +### Kiểm tra delivery không cần baseline + +```bash +python -m compare_tool --check-current current +python -m compare_tool --check-current delivery.zip --consistency-config project.json +``` + +`--check-current PATH` kiểm tra một folder hoặc ZIP, không chạy comparison, HTML hay viewer. Mode hỗ trợ `--exclude`, `--current-name`, `--consistency-config`, `--ignore-access-port-regex`, `--consistency-policy` và chấp nhận thêm `--no-report` / `--check-consistency` dù không bắt buộc. `--exit-zero` không bỏ qua lỗi. Các option chỉ dành cho comparison và `--json` / `--sarif` bị từ chối; export CURRENT finding được ghi trong [TODO](todo.md). + +Lệnh hai folder và YAML subset cũ vẫn hợp lệ. Comparison không bật `--check-consistency` giữ nguyên hành vi. Có thể bật kiểm tra completeness nghiêm ngặt trên lệnh cũ: + +```bash +python -m compare_tool baseline current --no-report --check-consistency --consistency-policy delivery --exit-zero +``` + +| Điều kiện | `standard` (mặc định comparison) | `delivery` (mặc định `--check-current`) | +|---|---:|---:| +| Kiểm tra đầy đủ, không có rule fail | 0 | 0 | +| Rule cấu hình `fail` | 1 | 1 | +| Lỗi đọc/list/parse hoặc thiếu đầu vào bắt buộc | 2 | 2 | +| API chưa hỗ trợ, reference mơ hồ, call/runnable chưa resolve | Không gate; hiện INCOMPLETE | 2 | + +Terminal hiển thị PASS, PASS WITH EXCEPTIONS, FAIL hoặc INCOMPLETE, tổng FAIL/WARN và coverage. Trong comparison, file difference vẫn ảnh hưởng process exit trừ khi dùng `--exit-zero`. Policy standard có thể không gate coverage INCOMPLETE; dùng delivery để bắt buộc kiểm tra đầy đủ. Rule WARN không gate trong cả hai policy; đặt `orphan_access: fail` nếu dự án yêu cầu. + +Đầu vào bắt buộc gồm C file và ARXML runnable definition. Folder rỗng, filter loại hết đầu vào cần thiết, hoặc cây không có cả call lẫn access declaration không thể âm thầm pass. Lý do JSON `no_rte_expected` cho phép utility chỉ có C, không phát hiện RTE call hoặc access declaration; không bỏ qua lỗi đọc/parse, thiếu C hay RTE usage thực tế. + +### Policy dự án và ngoại lệ có phạm vi + +Mặc định cấm `Rte_IRead_` / `Rte_IWrite_` là **policy dự án** được giữ từ trước, không phải hạn chế chung của AUTOSAR. Có thể giữ YAML cũ hoặc dùng JSON cho mapping và ngoại lệ có cấu trúc; cả hai chỉ dùng standard library. Setting không khai báo giữ default cũ. Ví dụ sau cho phép implicit API, gate orphan access và ghi hai quyết định có phạm vi cụ thể: + +```json +{ + "forbidden_rte_prefixes": [], + "rules": {"orphan_access": "fail"}, + "function_mappings": [ + { + "file": "Ctrl_autosar_rtw/Ctrl.c", + "function": "read_inputs", + "runnable": "/Pkg/Ctrl/IB/Step", + "reason": "Generated helper called only by Step; reviewed against generated C" + } + ], + "exceptions": [ + { + "rule": "unsupported_rte_api", + "file": "Ctrl_autosar_rtw/Ctrl.c", + "runnable": "/Pkg/Ctrl/IB/Step", + "reason": "Client/server contract verified separately by integration" + } + ] +} +``` + +Mapping cần tên function chính xác, file glob, full runnable path đang tồn tại và lý do. Đây là xác nhận của dự án, không phải bằng chứng call graph do tool tự suy luận. Nhiều target cùng match vẫn là unresolved. Target không tồn tại làm check incomplete. Mapping được áp dụng sẽ được in trên terminal. + +Ngoại lệ cần rule, lý do không rỗng và ít nhất một scope: `file` (glob), `swc` (full path chính xác), `runnable` (full path chính xác) hoặc `port` (short name chính xác). Tất cả scope khai báo phải match; field chưa resolve không match scope. Ngoại lệ áp dụng cho sáu finding có cấu hình ở trên và `unsupported_rte_api`, `runnable_not_resolved`, `runnable_not_implemented`, `ambiguous_port_dataelement`. Không được bỏ qua scan error hay thiếu đầu vào. Mỗi ngoại lệ được áp dụng cùng lý do đều được in. Ignore regex cũ, file exclude và rule bị tắt vẫn được hỗ trợ và hiển thị; nên dùng ngoại lệ có scope khi cần lưu lý do. + +Với utility không có RTE contract: + +```json +{"no_rte_expected": "Standalone math utility, reviewed to have no RTE contract"} +``` + +### Matching và giới hạn coverage + +Runnable identity gồm package, SWC và internal behavior. `SYMBOL` khai báo cùng component symbol prefix xác định C entry point. Khi thiếu symbol, chỉ dùng candidate short-name/component-name không mơ hồ; không dùng suffix để đoán ownership. Duplicate definition chỉ được so trong cùng full runnable path. Tool giữ đầy đủ port/data reference; short name hoặc tổ hợp dấu gạch dưới mơ hồ được đánh dấu UNCHECKED. Orphan matching xét runnable, reference, direction và access mode. + +Checker đọc `.c` và đối chiếu `Rte_Read`, `Rte_Write`, `Rte_IRead`, `Rte_IWrite` với Sender/Receiver access declaration. Các `Rte_*` khác phát hiện được, gồm `Rte_Call`, `Rte_Send`, `Rte_Receive`, được ghi rõ UNCHECKED. Runnable đã khai báo nhưng không resolve được C function cũng là UNCHECKED. Helper cần mapping có lý do khi chưa resolve được ownership. + +Đây là source consistency check, không phải AUTOSAR schema validator đầy đủ hay C compiler: không preprocess conditional compilation, expand macro, scan inline implementation trong header hay suy luận helper call graph. Chọn đúng generated source và ghi policy phù hợp. Kết quả pass chỉ áp dụng cho phạm vi đã khai báo này. + +Old/new advisory về peer giữ nguyên chỉ đề nghị xem dependency và generation record. Output byte-identical không chứng minh đã hoặc cần regenerate; advisory không gate delivery và dùng chung wording trên CLI, HTML, viewer. + ## HTML report Report gồm Overview, verdict filter, focused diff, semantic changes và consistency warnings. Các vùng unchanged dài và generator noise được collapse để real change dễ thấy. @@ -270,6 +347,8 @@ python -m compare_tool baseline.zip current.zip \ Nên publish HTML report và JSON/SARIF output thành CI artifact. +CURRENT finding hiện chỉ có trên terminal. JSON/SARIF comparison export chứa comparison result và old/new advisory, chưa chứa CURRENT finding; xem [TODO](todo.md). + ## Single-file build Release page cung cấp: diff --git a/pyproject.toml b/pyproject.toml index dda5a20..b0c6206 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,8 +26,9 @@ dynamic = ["version"] # core tool is stdlib-only; the side-by-side viewer (--qt) needs PySide6. # install with: pip install codegen-compare-tool[viewer] +# 6.12.0 crashes in Qt highlighting/teardown on Python 3.11, including the baseline. [project.optional-dependencies] -viewer = ["PySide6>=6.5"] +viewer = ["PySide6>=6.5,!=6.12.0"] [project.urls] Homepage = "https://github.com/longvo92/codegen-compare-tool" diff --git a/tests/test_cli_modes.py b/tests/test_cli_modes.py index 190f933..1d0d447 100644 --- a/tests/test_cli_modes.py +++ b/tests/test_cli_modes.py @@ -290,7 +290,7 @@ def test_report_consistency_warnings_remain_visible_with_exit_zero(self): 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)) + self.assertTrue(any('review dependencies and generation records' in msg for _, msg in advisories)) for model, message in advisories: self.assertIn('!! {}: {}'.format(model, message), output) diff --git a/tests/test_consistency.py b/tests/test_consistency.py index ca808bf..d1df8db 100644 --- a/tests/test_consistency.py +++ b/tests/test_consistency.py @@ -137,9 +137,7 @@ def test_deleted_arxml_counts_as_changed(self): class TestRteRegenAdvisories(unittest.TestCase): - """A +RTE in one model while a peer model's C is byte-identical means the - batch was a single-model quick regen -- the architecture (RTE layer + peer - SWCs) was not rebuilt, so the change cannot be integrated as-is.""" + """A +RTE alongside an unchanged peer suggests inspecting dependencies.""" def _rte(self, results, rel, added): results[rel]['rte'] = {'added': list(added), 'removed': []} @@ -152,11 +150,11 @@ def test_rte_added_with_identical_peer_is_flagged(self): adv = _adv({'A': ['A.c'], 'B': ['B.c']}, results) self.assertEqual(len(adv), 1) self.assertEqual(adv[0][0], 'A') - self.assertIn('regenerate the architecture', adv[0][1]) + self.assertIn('review dependencies and generation records', adv[0][1]) def test_rte_added_but_peer_regenerated_is_quiet(self): - # B's C churned a timestamp (ignorable-only) -> it WAS regenerated, so - # this is a normal full regen, not a stale one + # Only byte-identical peers trigger this heuristic. Timestamp churn is + # not itself proof of a complete or correct regeneration. results = self._rte( _results(**{'A.c': 'real-change', 'B.c': 'ignorable-only'}), 'A.c', ['Rte_Write_A_Torque']) diff --git a/tests/test_delivery_consistency.py b/tests/test_delivery_consistency.py new file mode 100644 index 0000000..174ba4c --- /dev/null +++ b/tests/test_delivery_consistency.py @@ -0,0 +1,315 @@ +"""Delivery regressions: coverage, AUTOSAR identity and legacy CLI contracts.""" + +import contextlib +import io +import json +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +from compare_tool import consistency +from compare_tool.main import main, viewer_requested + + +def runnable(name='Step', port='Speed', symbol='', mode='DATA-RECEIVE-POINT-BY-ARGUMENTS', + port_ref=None, data_ref='/Interfaces/Speed/Value'): + return '''{name}{symbol} +<{mode}> +{port_ref} +{data_ref} + +'''.format( + name=name, mode=mode, port_ref=port_ref or '/Pkg/Ctrl/' + port, + data_ref=data_ref, symbol='{}'.format(symbol) if symbol else '') + + +def component(name, body, prefix=''): + return '''{name} +{prefix} +IB +{body} +'''.format(name=name, prefix=prefix, body=body) + + +def arxml(body): + return ''' +Pkg{} +'''.format(body) + + +class TestDeliveryConsistency(unittest.TestCase): + def setUp(self): + temp = tempfile.TemporaryDirectory() + self.addCleanup(temp.cleanup) + self.base = Path(temp.name) + self.root = self.base / 'current' + self.root.mkdir() + + def write(self, name, text): + path = self.root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding='utf-8') + + def fixture(self, code='void Ctrl_Step(void) { Rte_Read_Speed_Value(); }', body=None): + self.write('Ctrl.c', code) + self.write('Ctrl.arxml', arxml(body or component('Ctrl', runnable()))) + + def check(self, config=None, policy='delivery', **kwargs): + return consistency.current_tree_check(self.root, config, policy=policy, **kwargs) + + def cli(self, *args): + output = io.StringIO() + with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): + code = main(list(args)) + return code, output.getvalue() + + def test_missing_inputs_never_pass_in_either_policy(self): + for policy in ('standard', 'delivery'): + with self.subTest(policy=policy, case='empty'): + self.assertEqual(self.check(policy=policy).exit_code, 2) + self.write('Ctrl.c', 'void Step(void) { Rte_Read_Speed_Value(); }') + self.assertEqual(self.check().exit_code, 2) + self.write('types.arxml', arxml('')) + self.assertEqual(self.check().exit_code, 2) + code, output = self.cli(str(self.root), str(self.root), '--no-report', + '--check-consistency', '--exit-zero') + self.assertEqual(code, 2) + self.assertIn('INCOMPLETE', output) + self.assertNotIn('No current-tree RTE/ARXML consistency findings.', output) + + def test_symbol_and_component_prefix_are_authoritative(self): + self.fixture('void App_Entry(void) { Rte_Read_Speed_Value(); }', + component('Ctrl', runnable(symbol='Entry'), prefix='App_')) + check = self.check() + self.assertEqual(check.exit_code, 0) + self.assertEqual(check.checked_calls, 1) + self.assertEqual(check.skipped_accesses, []) + self.write('Ctrl.c', 'void Ctrl_Step(void) { Rte_Read_Speed_Value(); }') + self.assertEqual(self.check().exit_code, 2) + + def test_symbol_resolution_preserves_real_mode_mismatch(self): + self.fixture('void Entry(void) { Rte_Write_Speed_Value(); }', + component('Ctrl', runnable(symbol='Entry'))) + check = self.check() + self.assertEqual(check.exit_code, 1) + self.assertIn('ACCESS_MODE_MISMATCH', [f['reason'] for f in check.findings]) + + def test_two_components_with_same_runnable_name_do_not_cross_match(self): + self.fixture(body=component('Ctrl', runnable())) + self.write('Other.c', 'void Other_Step(void) { Rte_Read_Torque_Value(); }') + self.write('Other.arxml', arxml(component('Other', runnable( + port='Torque', port_ref='/Pkg/Other/Torque')))) + self.assertEqual(self.check().findings, []) + self.assertEqual(self.check().exit_code, 0) + # Changing only Other's call must not borrow Ctrl's valid access. + self.write('Other.c', 'void Other_Step(void) { Rte_Read_Speed_Value(); }') + self.assertEqual(self.check().exit_code, 1) + + def test_same_short_names_in_different_packages_are_ambiguous(self): + self.fixture('void Step(void) { Rte_Read_Speed_Value(); }') + self.write('Other.arxml', arxml(component('Ctrl', runnable())).replace( + 'Pkg', 'OtherPkg')) + self.assertEqual(self.check().exit_code, 2) + self.assertIn('RUNNABLE_NOT_RESOLVED', [f['reason'] for f in self.check().skipped_accesses]) + + def test_orphan_is_per_runnable_and_access_mode(self): + self.fixture('void A(void) { Rte_Read_Speed_Value(); }\nvoid B(void) {}', + component('Ctrl', runnable('A') + runnable('B'))) + check = self.check() + orphans = [f for f in check.findings if f['reason'] == 'ORPHAN_ACCESS'] + self.assertEqual([f['runnable'] for f in orphans], ['/Pkg/Ctrl/IB/B']) + self.assertEqual(check.exit_code, 0) # existing warn policy preserved + config = consistency.load_checker_config(None) + config['rules']['orphan_access'] = 'fail' + self.assertEqual(self.check(config).exit_code, 1) + + def test_duplicate_arxml_must_use_same_full_references(self): + self.fixture() + self.write('duplicate.arxml', arxml(component('Ctrl', runnable( + data_ref='/DifferentInterface/Value')))) + check = self.check() + self.assertEqual(check.exit_code, 2) + self.assertIn('AMBIGUOUS_PORT_DATAELEMENT', [f['reason'] for f in check.skipped_accesses]) + + def test_underscore_collision_is_not_resolved_arbitrarily(self): + self.fixture('void Ctrl_Step(void) { Rte_Read_A_B_C(); }', component('Ctrl', + runnable(port='A_B', data_ref='/If/C') + runnable( + port='A', data_ref='/If/B_C'))) + self.assertEqual(self.check().exit_code, 2) + + def test_unresolved_helper_is_visible_and_delivery_gates_it(self): + self.fixture('void helper(void) { Rte_Read_Speed_Value(); }\n' + 'void Ctrl_Step(void) { helper(); }') + check = self.check(policy='standard') + self.assertEqual(check.exit_code, 0) + self.assertEqual(check.status, 'INCOMPLETE') + self.assertEqual(self.check().exit_code, 2) + + def test_unresolved_helper_cannot_weaken_existing_missing_pair_failure(self): + self.fixture('void helper(void) { Rte_Read_Unknown_Value(); }\n' + 'void Ctrl_Step(void) { helper(); }') + check = self.check(policy='standard') + self.assertEqual(check.exit_code, 1) + self.assertIn('NO_MATCHING_PORT_DATAELEMENT', [f['reason'] for f in check.findings]) + self.assertEqual(self.cli(str(self.root), str(self.root), '--no-report', + '--check-consistency', '--exit-zero')[0], 1) + self.assertEqual(self.check().exit_code, 2) + + def test_helper_mapping_is_scoped_and_has_a_reason(self): + self.fixture('void helper(void) { Rte_Read_Speed_Value(); }\n' + 'void Ctrl_Step(void) { helper(); }') + config = consistency.load_checker_config(None) + config['function_mappings'] = [{ + 'file': 'Ctrl.c', 'function': 'helper', + 'runnable': '/Pkg/Ctrl/IB/Step', 'reason': 'Generated helper called only by Step', + }] + check = self.check(config) + self.assertEqual(check.exit_code, 0) + self.assertEqual(check.findings, []) + self.assertTrue(check.notes) + self.write('Other.c', 'void helper(void) { Rte_Read_Speed_Value(); }') + self.assertEqual(self.check(config).exit_code, 2) + + def test_unknown_mapping_target_is_incomplete(self): + self.fixture() + config = consistency.load_checker_config(None) + config['function_mappings'] = [{'file': '*.c', 'function': 'helper', + 'runnable': '/missing', 'reason': 'test'}] + self.assertEqual(self.check(config).exit_code, 2) + + def test_unsupported_rte_api_is_counted_and_not_reported_clean(self): + self.fixture('void Ctrl_Step(void) { Rte_Call_Service_Run(); }') + check = self.check() + self.assertEqual(check.rte_calls_detected, 1) + self.assertEqual(check.checked_calls, 0) + self.assertEqual(check.exit_code, 2) + self.assertIn('UNSUPPORTED_RTE_API', [f['reason'] for f in check.skipped_accesses]) + + def test_exception_only_waives_matching_rule_and_scope(self): + self.fixture('void Ctrl_Step(void) { Rte_Call_Service_Run(); }') + config = consistency.load_checker_config(None) + config['exceptions'] = [{'rule': 'unsupported_rte_api', 'file': 'Ctrl.c', + 'runnable': '/Pkg/Ctrl/IB/Step', 'reason': 'Reviewed by integration'}] + check = self.check(config) + self.assertEqual(check.exit_code, 0) + self.assertEqual(check.status, 'PASS WITH EXCEPTIONS') + self.assertEqual(len(check.exemptions), 1) + self.write('Other.c', 'void Other(void) { Rte_Call_Service_Run(); }') + self.assertEqual(self.check(config).exit_code, 2) + + def test_no_rte_expected_requires_reason_and_does_not_hide_real_calls(self): + self.write('Utility.c', 'void Utility(void) {}') + self.assertEqual(self.check().exit_code, 2) + config = consistency.load_checker_config(None) + config['no_rte_expected'] = 'Standalone math utility; no RTE contract' + self.assertEqual(self.check(config).exit_code, 0) + self.write('Utility.c', 'void Utility(void) { Rte_Read_Speed_Value(); }') + self.assertEqual(self.check(config).exit_code, 2) + + def test_parse_errors_cannot_be_waived_by_no_rte_policy(self): + self.write('Utility.c', 'void Utility(void) {}') + self.write('bad.arxml', '') + config = consistency.load_checker_config(None) + config['no_rte_expected'] = 'No RTE' + self.assertEqual(self.check(config).exit_code, 2) + + def test_malformed_access_is_not_silently_dropped(self): + self.fixture(body=component('Ctrl', runnable().replace( + '/Pkg/Ctrl/Speed', ''))) + self.assertEqual(self.check().exit_code, 2) + + def test_comments_strings_and_character_literals_do_not_create_calls(self): + self.fixture('''void Ctrl_Step(void) { +/* Rte_Call_Fake(); */ +const char *message = "Rte_Receive_Fake();"; +char c = '{'; +Rte_Read_Speed_Value(); +}''') + self.assertEqual(self.check().exit_code, 0) + self.assertEqual(self.check().rte_calls_detected, 1) + + def test_json_config_validates_scopes_and_preserves_old_defaults(self): + path = self.base / 'policy.json' + path.write_text(json.dumps({'rules': {'orphan_access': 'fail'}})) + config = consistency.load_checker_config(path) + self.assertEqual(config['rules']['orphan_access'], 'fail') + self.assertEqual(config['forbidden_rte_prefixes'], ['Rte_IRead_', 'Rte_IWrite_']) + bad = [ + {'exceptions': [{'rule': 'unsupported_rte_api', 'reason': 'Too broad'}]}, + {'exceptions': [{'rule': 'consistency_scan_error', 'file': '*', 'reason': 'Invalid'}]}, + {'function_mappings': [{'file': '*.c', 'function': 'helper', 'runnable': 'Step', 'reason': 'Invalid'}]}, + {'rules': {'orphan_access': 'typo'}}, {'typo': []}, {'forbidden_rte_prefixes': 'Rte_'}, + ] + for value in bad: + with self.subTest(value=value): + path.write_text(json.dumps(value)) + with self.assertRaises(ValueError): + consistency.load_checker_config(path) + + def test_legacy_yaml_flags_and_two_folder_syntax_still_work(self): + self.fixture() + config = self.base / 'legacy.yaml' + config.write_text('''forbidden_rte_prefixes: + - Rte_IRead_ +ignore_access_port_regex: + - ^Bsw_ +rules: + orphan_access: warn +''') + code, output = self.cli(str(self.root), str(self.root), '--no-report', + '--check-consistency', '--consistency-config', str(config), + '--ignore-access-port-regex', '^Platform_', '--exit-zero') + self.assertEqual(code, 0) + self.assertIn('policy: standard', output) + + def test_current_only_does_not_compare_or_write_reports(self): + self.fixture() + report = self.root / 'compare_report.html' + report.write_text('existing') + with mock.patch('compare_tool.main.run_compare', side_effect=AssertionError('must not compare')): + code, output = self.cli('--check-current', str(self.root), '--no-report') + self.assertEqual(code, 0) + self.assertIn('policy: delivery', output) + self.assertNotIn('BASELINE:', output) + self.assertEqual(report.read_text(), 'existing') + self.assertFalse(viewer_requested(['--check-current', str(self.root)])) + + def test_current_zip_and_explicit_standard_policy(self): + self.fixture('void helper(void) { Rte_Read_Speed_Value(); }') + path = self.base / 'delivery.zip' + with zipfile.ZipFile(path, 'w') as archive: + for file in self.root.iterdir(): + archive.write(file, 'drop/' + file.name) + self.assertEqual(self.cli('--check-current', str(path))[0], 2) + code, output = self.cli('--check-current', str(path), '--consistency-policy', 'standard') + self.assertEqual(code, 0) + self.assertIn('INCOMPLETE', output) + self.assertIn('CURRENT: delivery.zip', output) + + def test_delivery_gating_cannot_be_suppressed_by_exit_zero(self): + self.fixture('void helper(void) { Rte_Read_Speed_Value(); }') + self.assertEqual(self.cli('--check-current', str(self.root), '--exit-zero')[0], 2) + self.assertEqual(self.cli(str(self.root), str(self.root), '--no-report', + '--check-consistency', '--consistency-policy', 'delivery', + '--exit-zero')[0], 2) + + def test_current_rejects_unsupported_outputs_without_touching_them(self): + self.fixture() + output = self.base / 'result.json' + output.write_text('existing') + for flags in (['--json', str(output)], ['--sarif', str(output)], ['--report', str(output)], + ['--qt'], ['--arxml-only'], [str(self.root), str(self.root)]): + with self.subTest(flags=flags), self.assertRaises(SystemExit) as caught: + self.cli('--check-current', str(self.root), *flags) + self.assertEqual(caught.exception.code, 2) + self.assertEqual(output.read_text(), 'existing') + + def test_filters_cannot_make_empty_check_pass(self): + self.fixture() + self.assertEqual(self.check(exclude=['*.c', '*.arxml']).exit_code, 2) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_demo.py b/tests/test_demo.py index fa7fbfc..e1c3b7a 100644 --- a/tests/test_demo.py +++ b/tests/test_demo.py @@ -53,9 +53,9 @@ def test_consistency_advisories_name_the_out_of_step_models(self): self.assertEqual(by_model['StaleGen'], 'ARXML and A2L changed but the generated C did not') # Ctrl's C gained an RTE access (Rte_Write_Out2_Diag) while StaleGen's - # C stayed identical -- the tell of a single-model quick regen that did - # not rebuild the architecture, so the +RTE cannot be integrated as-is. - self.assertIn('regenerate the architecture', by_model['Ctrl']) + # C stayed identical. This suggests checking dependencies, but does not + # prove that regeneration was skipped or that integration must fail. + self.assertIn('review dependencies and generation records', by_model['Ctrl']) # nothing else is out of step self.assertEqual(set(by_model), {'StaleGen', 'Ctrl'}) diff --git a/tests/test_stdlib_only.py b/tests/test_stdlib_only.py index 40e2e25..9b7a3dc 100644 --- a/tests/test_stdlib_only.py +++ b/tests/test_stdlib_only.py @@ -2,7 +2,7 @@ `compare_tool.pyz` is the documented fallback for machines where antivirus blocks the .exe: ~110 KB, nothing installed, stdlib only. That promise is -stated in the README and in CLAUDE.md, and until now nothing checked it -- a +stated in the README and in AGENTS.md, and until now nothing checked it -- a single third-party import in `scanner.py` would keep every test green here and break the tool on exactly the machines the zipapp exists for. @@ -21,7 +21,7 @@ # review store and gitsource -- everything except the Qt viewer CORE = sorted(p for p in PKG.glob('*.py')) ALL_MODULES = sorted(PKG.rglob('*.py')) -# named in CLAUDE.md as Qt-free on purpose, so the suite runs on a box with no +# named in AGENTS.md as Qt-free on purpose, so the suite runs on a box with no # Qt installed. They walk a model; only the widgets import PySide6. QT_FREE_IN_VIEWER = ('tree.py', 'summary_model.py')