mirror of
https://github.com/SCST-project/scst.git
synced 2026-10-22 06:02:33 +00:00
Track upstream imports, compare driver snapshots, audit remaining source differences and export patches mapped to the SCST tree. Keep review artifacts outside Git and expose unresolved changes for review. Move the newer QLogic kernel patch helpers into scripts/kernel with their callers and tests. Add the update skill and guide, preserving separate commits for upstream imports and SCST adaptations.
478 lines
28 KiB
Python
478 lines
28 KiB
Python
# SPDX-License-Identifier: GPL-2.0-only
|
|
"""Integration tests with disposable Git repositories, without kernel builds."""
|
|
|
|
from contextlib import redirect_stderr, redirect_stdout
|
|
import io
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
import qla2xxx
|
|
import qla2xxx_audit as audit
|
|
|
|
|
|
class QlaTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temporary = tempfile.TemporaryDirectory(prefix='qla test ')
|
|
self.addCleanup(self.temporary.cleanup)
|
|
self.root = Path(self.temporary.name)
|
|
self.linux = self.root / 'linux repo'
|
|
self.scst = self.root / 'scst repo'
|
|
self.env = dict(os.environ, GIT_CONFIG_NOSYSTEM='1', GIT_CONFIG_GLOBAL='/dev/null',
|
|
GIT_AUTHOR_NAME='Upstream Author', GIT_AUTHOR_EMAIL='author@example.com',
|
|
GIT_COMMITTER_NAME='Test Maintainer', GIT_COMMITTER_EMAIL='test@example.com',
|
|
GIT_AUTHOR_DATE='2025-01-02T03:04:05+00:00',
|
|
GIT_COMMITTER_DATE='2025-01-02T03:04:05+00:00')
|
|
self.environment = patch.dict(os.environ, self.env, clear=True)
|
|
self.environment.start()
|
|
self.addCleanup(self.environment.stop)
|
|
for repo in (self.linux, self.scst):
|
|
repo.mkdir()
|
|
self.git(repo, 'init', '-q', '--initial-branch=main')
|
|
self.base = self.commit(self.linux, 'Base', {
|
|
qla2xxx.UPSTREAM + 'qla.c': 'base\n',
|
|
qla2xxx.UPSTREAM + 'Makefile': 'obj-m := old.o\n',
|
|
qla2xxx.TRACE: 'trace old\n',
|
|
})
|
|
self.scst_base = self.commit(self.scst, 'qla2x00t-32gbit: Base\n\n'
|
|
f'[ commit {self.base[:12]} upstream ]', {
|
|
qla2xxx.DEST + 'qla.c': 'base\n',
|
|
qla2xxx.DEST + 'Makefile_in-tree': 'obj-m := old.o\n',
|
|
qla2xxx.DEST + 'Makefile': 'SCST out-of-tree rules\n',
|
|
qla2xxx.DEST + qla2xxx.TRACE: 'trace old\n',
|
|
qla2xxx.DEST + 'qla2x00-target/adapter.c': 'SCST adapter\n',
|
|
})
|
|
|
|
def git(self, repo, *args, check=True):
|
|
result = subprocess.run(['git', '-C', str(repo), *args], env=self.env,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
if check and result.returncode:
|
|
self.fail(result.stderr.decode(errors='replace'))
|
|
return result
|
|
|
|
def commit(self, repo, message, files):
|
|
for name, content in files.items():
|
|
path = repo / name
|
|
if content is None:
|
|
path.unlink()
|
|
else:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(content.encode() if isinstance(content, str) else content)
|
|
self.git(repo, 'add', '-A')
|
|
self.git(repo, 'commit', '-q', '-m', message)
|
|
return self.git(repo, 'rev-parse', 'HEAD').stdout.decode().strip()
|
|
|
|
def invoke(self, command, *options, ok=True):
|
|
stdout, stderr = io.StringIO(), io.StringIO()
|
|
with redirect_stdout(stdout), redirect_stderr(stderr):
|
|
status = qla2xxx.main([command, '--linux', str(self.linux), '--scst', str(self.scst),
|
|
'--to', 'HEAD', *map(str, options)])
|
|
if ok:
|
|
self.assertEqual(status, 0, stderr.getvalue())
|
|
else:
|
|
self.assertNotEqual(status, 0, stdout.getvalue())
|
|
return stdout.getvalue(), stderr.getvalue()
|
|
|
|
def history(self, *options):
|
|
return json.loads(self.invoke('history', '--json', *options)[0])
|
|
|
|
def output(self, name='export'):
|
|
return self.root / name
|
|
|
|
def export(self, *options):
|
|
return json.loads(self.invoke('export', '--json', '--output', self.output(), *options)[0])
|
|
|
|
def test_infers_marker_ignoring_later_local_change_and_detects_recorded_import(self):
|
|
first = self.commit(self.linux, 'scsi: qla2xxx: First', {qla2xxx.UPSTREAM + 'qla.c': 'one\n'})
|
|
self.commit(self.scst, f'qla2x00t-32gbit: First\n\n[ commit {first} upstream ]',
|
|
{qla2xxx.DEST + 'qla.c': 'one\n'})
|
|
self.commit(self.scst, 'qla2x00t-32gbit: Local adaptation',
|
|
{qla2xxx.DEST + 'local.c': 'local\n'})
|
|
second = self.commit(self.linux, 'scsi: qla2xxx: Second',
|
|
{qla2xxx.UPSTREAM + 'qla.c': 'two\n'})
|
|
report = self.history()
|
|
self.assertEqual(report['linux_base'], first)
|
|
self.assertEqual([e['upstream'] for e in report['commits']], [second])
|
|
report = self.history('--since', self.base)
|
|
self.assertEqual([e['status'] for e in report['commits']], ['recorded', 'pending'])
|
|
|
|
def test_export_applies_paths_binary_rename_author_date_and_provenance(self):
|
|
first = self.commit(self.linux, 'scsi: qla2xxx: Change all mappings\n\n'
|
|
'Keep the explanation.\n\nSigned-off-by: Upstream Author <author@example.com>', {
|
|
qla2xxx.UPSTREAM + 'qla.c': 'new\n',
|
|
qla2xxx.UPSTREAM + 'Makefile': 'obj-m := new.o\n',
|
|
qla2xxx.TRACE: 'trace new\n',
|
|
qla2xxx.UPSTREAM + 'firmware.bin': b'\0\xffbinary\0',
|
|
})
|
|
second = self.commit(self.linux, 'scsi: qla2xxx: Rename file', {
|
|
qla2xxx.UPSTREAM + 'qla.c': None,
|
|
qla2xxx.UPSTREAM + 'new name.c': 'new\n',
|
|
})
|
|
self.commit(self.linux, 'scsi: qla2xxx: Trace only', {qla2xxx.TRACE: 'trace latest\n'})
|
|
before = [self.git(repo, 'status', '--porcelain').stdout for repo in (self.linux, self.scst)]
|
|
report = self.export()
|
|
self.assertEqual(report['patch_count'], 3)
|
|
self.assertEqual(before, [self.git(repo, 'status', '--porcelain').stdout
|
|
for repo in (self.linux, self.scst)])
|
|
self.assertEqual(self.git(self.scst, 'rev-parse', 'HEAD').stdout.decode().strip(), self.scst_base)
|
|
for filename in (self.output() / 'series').read_text().splitlines():
|
|
self.git(self.scst, 'am', str(self.output() / filename))
|
|
self.assertEqual((self.scst / (qla2xxx.DEST + 'Makefile')).read_text(), 'SCST out-of-tree rules\n')
|
|
self.assertEqual((self.scst / (qla2xxx.DEST + 'Makefile_in-tree')).read_text(), 'obj-m := new.o\n')
|
|
self.assertEqual((self.scst / (qla2xxx.DEST + qla2xxx.TRACE)).read_text(), 'trace latest\n')
|
|
self.assertEqual((self.scst / (qla2xxx.DEST + 'firmware.bin')).read_bytes(), b'\0\xffbinary\0')
|
|
self.assertFalse((self.scst / (qla2xxx.DEST + 'qla.c')).exists())
|
|
self.assertEqual((self.scst / (qla2xxx.DEST + 'new name.c')).read_text(), 'new\n')
|
|
log = self.git(self.scst, 'log', '--format=%an <%ae>%n%aI%n%B', self.scst_base + '..HEAD').stdout.decode()
|
|
self.assertIn('Upstream Author <author@example.com>', log)
|
|
self.assertIn(self.git(self.linux, 'show', '-s', '--format=%aI', first).stdout.decode().strip(), log)
|
|
self.assertIn('qla2x00t-32gbit: Change all mappings', log)
|
|
self.assertIn(f'[ commit {first} upstream ]', log)
|
|
self.assertIn(f'[ commit {second} upstream ]', log)
|
|
self.assertIn('Signed-off-by: Upstream Author <author@example.com>', log)
|
|
self.assertEqual(self.history('--since', self.base)['counts'], {'recorded': 3})
|
|
|
|
def test_unicode_long_subject_and_author_round_trip(self):
|
|
self.env['GIT_AUTHOR_NAME'] = 'Автор Патча'
|
|
subject = 'Исправить ' + 'длинное описание ' * 10
|
|
self.commit(self.linux, 'scsi: qla2xxx: ' + subject + '\n\nОписание изменения.',
|
|
{qla2xxx.UPSTREAM + 'qla.c': 'unicode\n'})
|
|
self.export()
|
|
self.git(self.scst, 'am', str(next(self.output().glob('*.patch'))))
|
|
self.assertEqual(self.git(self.scst, 'show', '-s', '--format=%an').stdout.decode().strip(),
|
|
'Автор Патча')
|
|
self.assertEqual(self.git(self.scst, 'show', '-s', '--format=%s').stdout.decode().strip(),
|
|
'qla2x00t-32gbit: ' + subject.strip())
|
|
self.assertIn('Описание изменения.', self.git(self.scst, 'show', '-s', '--format=%B').stdout.decode())
|
|
|
|
def test_patch_header_mapping_never_rewrites_hunk_content(self):
|
|
# Deleted source text can look exactly like a --- patch header.
|
|
old = '-- a/qla2x00t-32gbit/Makefile\n'
|
|
self.commit(self.linux, 'scsi: qla2xxx: Add header-like text',
|
|
{qla2xxx.UPSTREAM + 'qla.c': old})
|
|
self.commit(self.linux, 'scsi: qla2xxx: Remove header-like text',
|
|
{qla2xxx.UPSTREAM + 'qla.c': 'ordinary text\n'})
|
|
self.export()
|
|
for filename in (self.output() / 'series').read_text().splitlines():
|
|
self.git(self.scst, 'am', str(self.output() / filename))
|
|
self.assertEqual((self.scst / (qla2xxx.DEST + 'qla.c')).read_text(), 'ordinary text\n')
|
|
|
|
def test_real_merge_resolution_is_exported_for_review(self):
|
|
self.git(self.linux, 'checkout', '-q', '-b', 'side')
|
|
side = self.commit(self.linux, 'scsi: qla2xxx: Side', {qla2xxx.UPSTREAM + 'qla.c': 'side\n'})
|
|
self.git(self.linux, 'checkout', '-q', 'main')
|
|
main = self.commit(self.linux, 'scsi: qla2xxx: Main', {qla2xxx.UPSTREAM + 'qla.c': 'main\n'})
|
|
result = self.git(self.linux, 'merge', '--no-ff', 'side', '-m', 'Merge side', check=False)
|
|
self.assertNotEqual(result.returncode, 0)
|
|
merge = self.commit(self.linux, 'Merge resolved', {qla2xxx.UPSTREAM + 'qla.c': 'main and side\n'})
|
|
report = self.export()
|
|
self.assertEqual({e['upstream'] for e in report['commits']}, {side, main, merge})
|
|
entry = report['commits'][-1]
|
|
self.assertTrue(entry['merge_resolution'])
|
|
self.assertEqual(report['review_count'], 1)
|
|
self.assertEqual(report['patch_count'], 2)
|
|
self.assertIn(b'main and side', (self.output() / entry['artifact']).read_bytes())
|
|
self.assertNotIn('remerge', (self.output() / 'series').read_text())
|
|
|
|
def test_clean_merge_is_still_reported(self):
|
|
self.git(self.linux, 'checkout', '-q', '-b', 'side')
|
|
self.commit(self.linux, 'scsi: qla2xxx: Side', {qla2xxx.UPSTREAM + 'other.c': 'side\n'})
|
|
self.git(self.linux, 'checkout', '-q', 'main')
|
|
self.commit(self.linux, 'scsi: qla2xxx: Main', {qla2xxx.UPSTREAM + 'qla.c': 'main\n'})
|
|
self.git(self.linux, 'merge', '--no-ff', 'side', '-m', 'Merge side')
|
|
merges = [e for e in self.history()['commits'] if e['kind'] == 'merge']
|
|
# Full-history may omit a TREESAME merge with no relevant contribution;
|
|
# this merge changes the selected subtree relative to both parents.
|
|
self.assertEqual(len(merges), 1)
|
|
self.assertFalse(merges[0]['merge_resolution'])
|
|
|
|
def test_merge_that_discards_a_side_change_remains_visible(self):
|
|
self.git(self.linux, 'checkout', '-q', '-b', 'side')
|
|
side = self.commit(self.linux, 'scsi: qla2xxx: Side', {qla2xxx.UPSTREAM + 'qla.c': 'side\n'})
|
|
self.git(self.linux, 'checkout', '-q', 'main')
|
|
main = self.commit(self.linux, 'scsi: qla2xxx: Main', {qla2xxx.UPSTREAM + 'qla.c': 'main\n'})
|
|
self.git(self.linux, 'merge', '--no-ff', 'side', '-m', 'Merge side', check=False)
|
|
merge = self.commit(self.linux, 'Keep main version', {qla2xxx.UPSTREAM + 'qla.c': 'main\n'})
|
|
report = self.history()
|
|
self.assertEqual({e['upstream'] for e in report['commits']}, {side, main, merge})
|
|
self.assertTrue(report['commits'][-1]['merge_resolution'])
|
|
|
|
def test_mixed_commit_is_not_in_automatic_series_and_keeps_original(self):
|
|
self.commit(self.linux, 'scsi: Shared API change', {
|
|
qla2xxx.UPSTREAM + 'qla.c': 'new API\n', 'include/scsi/api.h': 'external dependency\n'})
|
|
report = self.export()
|
|
self.assertEqual(report['patch_count'], 0)
|
|
entry = report['commits'][0]
|
|
self.assertEqual(entry['outside_paths'], ['include/scsi/api.h'])
|
|
self.assertIn('review/', entry['artifact'])
|
|
self.assertNotIn(b'include/scsi/api.h', (self.output() / entry['artifact']).read_bytes())
|
|
original = next((self.output() / 'review').glob('*-upstream.patch'))
|
|
self.assertIn(b'external dependency', original.read_bytes())
|
|
|
|
def test_diff_maps_makefile_and_classifies_committed_tree_only(self):
|
|
self.commit(self.linux, 'scsi: qla2xxx: Add', {qla2xxx.UPSTREAM + 'new.c': 'new\n'})
|
|
(self.scst / (qla2xxx.DEST + 'qla.c')).write_text('uncommitted\n')
|
|
report = json.loads(self.invoke('diff', '--json')[0])
|
|
statuses = {e['path']: e['status'] for e in report['files']}
|
|
self.assertEqual(statuses[qla2xxx.DEST + 'Makefile_in-tree'], 'identical')
|
|
self.assertEqual(statuses[qla2xxx.DEST + 'Makefile'], 'scst-only')
|
|
self.assertEqual(statuses[qla2xxx.DEST + 'qla.c'], 'identical')
|
|
self.assertEqual(statuses[qla2xxx.DEST + 'new.c'], 'linux-only')
|
|
limited = json.loads(self.invoke('diff', '--json', '--path', 'new.c')[0])
|
|
self.assertEqual(len(limited['files']), 1)
|
|
output = self.invoke('diff', '--patch', '--path', 'new.c')[0]
|
|
self.assertIn('-new\n', output)
|
|
self.invoke('diff', '--path', '../escape', ok=False)
|
|
self.invoke('diff', '--path', 'not-present.c', ok=False)
|
|
|
|
def test_decisions_require_reason_and_reachable_resolution(self):
|
|
oid = self.commit(self.linux, 'scsi: qla2xxx: Optional change',
|
|
{qla2xxx.UPSTREAM + 'qla.c': 'new\n'})
|
|
path = self.root / 'decisions.json'
|
|
document = {'version': 1, 'decisions': {oid: {'status': 'skip', 'reason': 'Not applicable'}}}
|
|
path.write_text(json.dumps(document))
|
|
report = self.export('--decisions', path)
|
|
self.assertEqual(report['counts'], {'skip': 1})
|
|
self.assertEqual(report['patch_count'], 0)
|
|
document['decisions'][oid] = {'status': 'resolved', 'reason': 'Adapted separately',
|
|
'scst_commit': self.scst_base}
|
|
path.write_text(json.dumps(document))
|
|
self.assertEqual(self.history('--decisions', path)['counts'], {'resolved': 1})
|
|
document['decisions'][oid]['reason'] = ''
|
|
path.write_text(json.dumps(document))
|
|
self.invoke('history', '--decisions', path, ok=False)
|
|
unmerged = self.commit(self.scst, 'Unmerged adaptation',
|
|
{qla2xxx.DEST + 'qla.c': 'unmerged\n'})
|
|
self.git(self.scst, 'checkout', '-q', self.scst_base)
|
|
document['decisions'][oid].update(reason='Unmerged', scst_commit=unmerged)
|
|
path.write_text(json.dumps(document))
|
|
self.invoke('history', '--decisions', path, ok=False)
|
|
|
|
def test_reverted_import_is_not_silently_skipped(self):
|
|
oid = self.commit(self.linux, 'scsi: qla2xxx: Fix', {qla2xxx.UPSTREAM + 'qla.c': 'fix\n'})
|
|
self.commit(self.scst, f'qla2x00t-32gbit: Fix\n\n[ commit {oid} upstream ]',
|
|
{qla2xxx.DEST + 'qla.c': 'fix\n'})
|
|
self.git(self.scst, 'revert', '--no-edit', 'HEAD')
|
|
self.invoke('history', ok=False)
|
|
report = self.export('--since', self.base)
|
|
self.assertEqual(report['commits'][0]['status'], 'review')
|
|
self.assertEqual(report['patch_count'], 0)
|
|
self.assertTrue((self.output() / report['commits'][0]['artifact']).is_file())
|
|
|
|
def test_output_is_never_replaced_even_when_empty_or_symlink(self):
|
|
self.output().mkdir()
|
|
self.invoke('export', '--output', self.output(), ok=False)
|
|
(self.output() / 'keep').write_text('previous run')
|
|
self.invoke('export', '--output', self.output(), ok=False)
|
|
self.assertEqual((self.output() / 'keep').read_text(), 'previous run')
|
|
link = self.root / 'broken-link'
|
|
link.symlink_to(self.root / 'missing')
|
|
self.invoke('export', '--output', link, ok=False)
|
|
self.assertTrue(link.is_symlink())
|
|
|
|
def test_empty_range_is_distinct_from_invalid_ref(self):
|
|
self.assertEqual(self.history()['commits'], [])
|
|
self.invoke('history', '--to', 'not-a-ref', ok=False)
|
|
self.invoke('history', '--since', 'not-a-ref', ok=False)
|
|
self.git(self.linux, 'checkout', '-q', '--orphan', 'unrelated')
|
|
self.commit(self.linux, 'Unrelated root', {qla2xxx.UPSTREAM + 'qla.c': 'other\n'})
|
|
self.invoke('history', '--since', self.base, ok=False)
|
|
|
|
def test_unresolved_latest_marker_requires_explicit_base(self):
|
|
self.commit(self.scst, 'qla2x00t-32gbit: Missing source\n\n'
|
|
'[ commit 0000000000000000000000000000000000000001 upstream ]',
|
|
{qla2xxx.DEST + 'qla.c': 'missing\n'})
|
|
self.invoke('history', ok=False)
|
|
report = self.history('--since', self.base)
|
|
self.assertEqual(len(report['unresolved_markers']), 1)
|
|
|
|
def test_shallow_history_is_rejected(self):
|
|
clone = self.root / 'shallow'
|
|
self.git(self.root, 'clone', '-q', '--depth=1', self.linux.as_uri(), str(clone))
|
|
self.invoke('history', '--linux', clone, ok=False)
|
|
|
|
def test_partial_clone_is_rejected_without_attempting_network_access(self):
|
|
self.git(self.linux, 'config', 'remote.origin.promisor', 'true')
|
|
_, error = self.invoke('history', ok=False)
|
|
self.assertIn('partial clone', error)
|
|
|
|
def test_latest_import_with_multiple_markers_needs_an_explicit_base(self):
|
|
oid = self.commit(self.linux, 'scsi: qla2xxx: Fix', {qla2xxx.UPSTREAM + 'qla.c': 'fix\n'})
|
|
self.commit(self.scst, 'qla2x00t-32gbit: Combined import\n\n'
|
|
f'[ commit {self.base} upstream ]\n[ commit {oid} upstream ]',
|
|
{qla2xxx.DEST + 'qla.c': 'fix\n'})
|
|
self.invoke('history', ok=False)
|
|
self.assertEqual(self.history('--since', self.base)['counts'], {'recorded': 1})
|
|
|
|
def test_mode_and_missing_final_newline_survive_export(self):
|
|
self.commit(self.linux, 'scsi: qla2xxx: No final newline',
|
|
{qla2xxx.UPSTREAM + 'qla.c': 'no newline'})
|
|
path = self.linux / (qla2xxx.UPSTREAM + 'qla.c')
|
|
path.chmod(0o755)
|
|
self.commit(self.linux, 'scsi: qla2xxx: Change mode', {})
|
|
self.export()
|
|
for filename in (self.output() / 'series').read_text().splitlines():
|
|
self.git(self.scst, 'am', str(self.output() / filename))
|
|
path = self.scst / (qla2xxx.DEST + 'qla.c')
|
|
self.assertEqual(path.read_bytes(), b'no newline')
|
|
self.assertTrue(path.stat().st_mode & 0o111)
|
|
|
|
def test_failure_does_not_publish_incomplete_bundle(self):
|
|
self.commit(self.linux, 'scsi: qla2xxx: Fix', {qla2xxx.UPSTREAM + 'qla.c': 'new\n'})
|
|
with patch.object(qla2xxx, 'import_patch', side_effect=qla2xxx.ToolError('test failure')):
|
|
self.invoke('export', '--output', self.output(), ok=False)
|
|
self.assertFalse(self.output().exists())
|
|
self.assertFalse(list(self.root.glob('.qla2xxx-*')))
|
|
|
|
def test_export_can_run_from_an_unrelated_directory(self):
|
|
result = subprocess.run([
|
|
sys.executable, str(Path(qla2xxx.__file__).resolve()), 'export',
|
|
'--linux', str(self.linux), '--scst', str(self.scst), '--to', 'HEAD',
|
|
'--output', str(self.output()), '--json'], cwd=self.root, env=self.env,
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
self.assertEqual(result.returncode, 0, result.stderr.decode())
|
|
self.assertEqual(json.loads(result.stdout)['patch_count'], 0)
|
|
|
|
def test_audit_keeps_raw_diff_literals_behavior_and_blame(self):
|
|
self.commit(self.linux, 'Base source', {qla2xxx.UPSTREAM + 'qla.c':
|
|
'int mode = 1;\nchar *name = "a b";\n'})
|
|
change = self.commit(self.scst, 'Change policy', {qla2xxx.DEST + 'qla.c':
|
|
'int mode = 0;\nchar *name = "ab";\n'})
|
|
report = json.loads(self.invoke('audit', '--blame', '--json', '--path', 'qla.c')[0])
|
|
entry = report['files'][0]
|
|
self.assertEqual(entry['assessment'], 'residual-review')
|
|
self.assertIn('-int mode = 1;', entry['raw_diff'])
|
|
self.assertIn('+int mode = 0;', entry['raw_diff'])
|
|
self.assertGreater(entry['residual_tokens']['added'], 0)
|
|
self.assertTrue(all(h['requires_review'] for h in entry['hunks']))
|
|
self.assertIn(change, {c['commit'] for h in entry['hunks'] for c in h['scst_last_editors']})
|
|
self.assertEqual(self.git(self.scst, 'status', '--porcelain').stdout, b'')
|
|
|
|
def test_audit_distinguishes_comment_noise_from_profile_equality(self):
|
|
self.commit(self.linux, 'Base source', {qla2xxx.UPSTREAM + 'qla.c': 'int f(void) { return 1; }\n'})
|
|
self.commit(self.scst, 'Reformat', {qla2xxx.DEST + 'qla.c':
|
|
'/* comment */\nint f(void)\n{\n\treturn 1; // explanation\n}\n'})
|
|
entry = json.loads(self.invoke('audit', '--json', '--path', 'qla.c')[0])['files'][0]
|
|
self.assertEqual(entry['assessment'], 'lexical-only')
|
|
self.assertEqual(entry['hunks'], [])
|
|
self.commit(self.scst, 'Backport', {qla2xxx.DEST + 'qla.c':
|
|
'#if LINUX_VERSION_CODE >= KERNEL_VERSION(7, 0, 0)\n'
|
|
'int f(void) { return 1; }\n#else\nint f(void) { return 2; }\n#endif\n'})
|
|
entry = json.loads(self.invoke('audit', '--json', '--kernel-version', '7.3',
|
|
'--path', 'qla.c')[0])['files'][0]
|
|
self.assertEqual(entry['assessment'], 'equal-under-profile')
|
|
self.assertIn('return 2;', entry['raw_diff'])
|
|
entry = json.loads(self.invoke('audit', '--json', '--kernel-version', '6.1',
|
|
'--path', 'qla.c')[0])['files'][0]
|
|
self.assertEqual(entry['assessment'], 'residual-review')
|
|
self.assertEqual(entry['hunks'][0]['scst']['start'], 4)
|
|
|
|
def test_audit_preserves_unknown_vendor_and_config_branches(self):
|
|
self.commit(self.linux, 'Base source', {qla2xxx.UPSTREAM + 'qla.c': 'int value;\n'})
|
|
self.commit(self.scst, 'Conditional', {qla2xxx.DEST + 'qla.c':
|
|
'#if LINUX_VERSION_CODE >= KERNEL_VERSION(7, 0, 0) && defined(VENDOR)\n'
|
|
'int other;\n#else\nint value;\n#endif\n'})
|
|
entry = json.loads(self.invoke('audit', '--json', '--kernel-version', '7.3',
|
|
'--path', 'qla.c')[0])['files'][0]
|
|
self.assertEqual(entry['assessment'], 'residual-review')
|
|
self.assertEqual(entry['scst_conditions'][0]['result'], 'unknown')
|
|
entry = json.loads(self.invoke('audit', '--json', '--kernel-version', '7.3',
|
|
'--undef', 'VENDOR', '--path', 'qla.c')[0])['files'][0]
|
|
self.assertEqual(entry['assessment'], 'equal-under-profile')
|
|
|
|
def test_audit_rejects_conflicting_profiles_and_excludes_worktree(self):
|
|
self.invoke('audit', '--kernel-version', '7.3', '--define', 'LINUX_VERSION_CODE=1', ok=False)
|
|
self.invoke('audit', '--define', 'VENDOR=1', '--undef', 'VENDOR', ok=False)
|
|
self.invoke('audit', '--kernel-version', 'v7.3', ok=False)
|
|
with self.assertRaises(SystemExit):
|
|
self.invoke('audit', '--json', '--patch')
|
|
(self.scst / (qla2xxx.DEST + 'qla.c')).write_text('uncommitted\n')
|
|
entry = json.loads(self.invoke('audit', '--json', '--path', 'qla.c')[0])['files'][0]
|
|
self.assertEqual(entry['assessment'], 'identical')
|
|
|
|
def test_audit_never_hides_modes_binary_or_non_c_files(self):
|
|
self.commit(self.scst, 'Add binary and documentation', {
|
|
qla2xxx.DEST + 'binary.h': b'\0\xff', qla2xxx.DEST + 'README': 'SCST\n',
|
|
qla2xxx.DEST + 'invalid.c': b'\xff'})
|
|
(self.scst / (qla2xxx.DEST + 'qla.c')).chmod(0o755)
|
|
self.commit(self.scst, 'Mode', {})
|
|
entries = {e['path'][len(qla2xxx.DEST):]: e for e in
|
|
json.loads(self.invoke('audit', '--json')[0])['files']}
|
|
self.assertEqual(entries['qla.c']['assessment'], 'mode-review')
|
|
self.assertEqual(entries['binary.h']['assessment'], 'binary-review')
|
|
self.assertEqual(entries['invalid.c']['assessment'], 'encoding-review')
|
|
self.assertEqual(entries['README']['assessment'], 'non-c-review')
|
|
|
|
|
|
class ProjectionTests(unittest.TestCase):
|
|
def project(self, source, *definitions):
|
|
return audit.project(audit.source_lines(source), audit.profile('7.3', list(definitions), []))
|
|
|
|
def test_strings_macro_spacing_and_token_boundaries_survive(self):
|
|
def value(source):
|
|
return [text for text, _ in audit.tokens(audit.source_lines(source))]
|
|
self.assertEqual(value('char *s = "/* a b */"; // outside\n'),
|
|
value('char*s="/* a b */"; /* outside */\n'))
|
|
self.assertNotEqual(value('char *s="/* a b */";'), value('char *s="/* ab */";'))
|
|
self.assertNotEqual(value('x + +y;'), value('x ++ y;'))
|
|
self.assertNotEqual(value('#define F(x) x\n'), value('#define F (x) x\n'))
|
|
self.assertEqual(value('double n = 1e+3;'), ['double', 'n', '=', '1e+3', ';'])
|
|
|
|
def test_splicing_precedes_line_comments_and_preserves_line_numbers(self):
|
|
source = '// ignored \\\n#if 0\nint value;\n'
|
|
projected, _, _ = self.project(source)
|
|
self.assertEqual(audit.tokens(projected), [('int', 3), ('value', 3), (';', 3)])
|
|
self.assertEqual(audit.tokens(audit.source_lines('\\\nint x;\n'))[0], ('int', 1))
|
|
self.assertIn('\\ No newline at end of file\n+new\n', audit.unified('old', 'new', 'qla.c'))
|
|
|
|
def test_unknown_outer_condition_keeps_decidable_nested_content(self):
|
|
source = ('#ifndef HEADER\n#define HEADER\n'
|
|
'#if LINUX_VERSION_CODE > KERNEL_VERSION(6, 0, 0)\nnew_api();\n'
|
|
'#else\nold_api();\n#endif\n#endif\n')
|
|
output, decisions, _ = self.project(source)
|
|
text = ''.join(line for line, _ in output)
|
|
self.assertIn('#ifndef HEADER', text)
|
|
self.assertIn('new_api();', text)
|
|
self.assertNotIn('old_api();', text)
|
|
self.assertEqual([d['result'] for d in decisions], ['unknown', 'selected'])
|
|
|
|
def test_local_redefinition_invalidates_external_facts(self):
|
|
output, decisions, invalid = self.project('#define VENDOR 0\n#if VENDOR\na();\n'
|
|
'#else\nb();\n#endif\n', 'VENDOR=1')
|
|
self.assertEqual(invalid, ['VENDOR'])
|
|
self.assertEqual(decisions[0]['result'], 'unknown')
|
|
self.assertIn('b();', ''.join(line for line, _ in output))
|
|
|
|
def test_elif_and_short_circuit_do_not_discard_uncertainty(self):
|
|
cases = [('#if 0\na();\n#elif 1\nb();\n#else\nc();\n#endif\n', 'b();'),
|
|
('#if 0 && UNKNOWN(x)\na();\n#else\nb();\n#endif\n', 'b();'),
|
|
('#if UNKNOWN\na();\n#elif 1\nb();\n#endif\n', '#if UNKNOWN')]
|
|
for source, expected in cases:
|
|
with self.subTest(source=source):
|
|
output, _, _ = self.project(source)
|
|
self.assertIn(expected, ''.join(line for line, _ in output))
|
|
for expr in ('UNKNOWN + 2', '1U < -1', '2147483647 + 1', '0xffffffffffffffff',
|
|
'__import__("os")', '1 << 10', 'KERNEL_VERSION(7, 3, 300)'):
|
|
self.assertIsNone(audit.Condition(expr, {}, True).value(), expr)
|
|
|
|
def test_bad_nesting_is_an_error_not_an_empty_success(self):
|
|
for source in ('#endif\n', '#if 1\nvalue\n', '#if 0\n#else\n#else\n#endif\n'):
|
|
with self.assertRaises(ValueError):
|
|
self.project(source)
|
|
|
|
def test_api_changes_remain_review_candidates(self):
|
|
self.assertEqual(audit.classify('timer_shutdown(&t);', 'timer_delete(&t);',
|
|
'qla_edif.c')[0], 'behavior-candidate')
|
|
self.assertEqual(audit.classify('struct bsg_job *job;', 'BSG_JOB_TYPE *job;',
|
|
'qla_bsg.c')[0], 'compatibility-candidate')
|
|
self.assertEqual(audit.scope_hint(audit.source_lines(
|
|
'static int f(void)\n{\nreturn 1;\n}\n'), 3), 'f')
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|