mirror of
https://github.com/Cacti/cacti.git
synced 2026-10-28 15:47:39 +09:00
* refactor(themes): remove the Carrot and Hollyberry themes Both were near-copies of the classic stylesheet kept as examples; the 1.3.0 upgrade prunes their directories and moves anyone who selected them to Modern instead of the alphabetical fallback. Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * docs(changelog): add fragment for #8526 Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> * test: cover retired theme cleanup and handle deleted coverage files Signed-off-by: Thomas Vincent <thomasvincent@gmail.com> --------- Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
142 lines
6.5 KiB
Python
142 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Gate committed executable added lines using Xdebug and Chromium evidence.
|
|
|
|
Copyright (C) 2004-2026 The Cacti Group. GPL-2.0-or-later.
|
|
Run in the PR checkout, with --base set to the PR base commit. This reports
|
|
changed-line coverage, not whole-project, branch or path coverage.
|
|
"""
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
|
|
|
|
def added_lines(base):
|
|
base = subprocess.check_output(['git', 'merge-base', base, 'HEAD'], text=True).strip()
|
|
diff = subprocess.check_output(
|
|
['git', 'diff', '--no-ext-diff', '--no-textconv', '--unified=0', base, 'HEAD', '--', '*.php', '*.js', '*.css',
|
|
':(exclude)tests/**', ':(exclude)include/vendor/**'], text=True)
|
|
files = {}
|
|
name = None
|
|
line = 0
|
|
for record in diff.splitlines():
|
|
if record.startswith('diff --git '):
|
|
name = None
|
|
elif record.startswith('+++ b/'):
|
|
name = record[6:]
|
|
files[name] = set()
|
|
elif record.startswith('@@'):
|
|
line = int(re.search(r'\+(\d+)', record).group(1))
|
|
elif record.startswith('+') and name is not None:
|
|
files[name].add(line)
|
|
line += 1
|
|
elif not record.startswith(('-', '\\')):
|
|
line += 1
|
|
return {name: lines for name, lines in files.items() if lines}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--base', required=True)
|
|
parser.add_argument('--php-coverage', nargs='*', default=[])
|
|
parser.add_argument('--browser-root', type=Path)
|
|
parser.add_argument('--minimum', type=float, default=100)
|
|
parser.add_argument('--language', choices=['php', 'js', 'css'])
|
|
args = parser.parse_args()
|
|
if not 0 <= args.minimum <= 100:
|
|
parser.error('--minimum must be between 0 and 100')
|
|
root = Path.cwd().resolve()
|
|
changed = added_lines(args.base)
|
|
if args.language:
|
|
changed = {name: lines for name, lines in changed.items()
|
|
if Path(name).suffix == '.' + args.language}
|
|
php = {}
|
|
browser = []
|
|
for name in args.php_coverage:
|
|
for file, data in json.loads(Path(name).read_text()).items():
|
|
lines = data.get('lines', data)
|
|
target = php.setdefault(str(Path(file).resolve()), {})
|
|
for number, hits in lines.items():
|
|
target[int(number)] = max(hits, target.get(int(number), -2))
|
|
if args.browser_root:
|
|
for file in args.browser_root.rglob('changed-coverage.json'):
|
|
browser.append(json.loads(file.read_text()))
|
|
report = {}
|
|
for name, added in changed.items():
|
|
file = root / name
|
|
source = file.read_text()
|
|
lines = source.splitlines(keepends=True)
|
|
covered = set()
|
|
executable = set()
|
|
instrumented = False
|
|
if file.suffix == '.php':
|
|
evidence = php.get(str(file.resolve()), {})
|
|
instrumented = bool(evidence)
|
|
executable = {n for n in added if n in evidence and evidence[n] != -2}
|
|
# Source slices may instrument only one function in an entry point.
|
|
# Missing statements elsewhere in that same file must fail closed.
|
|
for number in added:
|
|
if number not in evidence and re.match(r'^\s*(?:\$|return\b|throw\b|if\b|elseif\b|foreach\b|while\b|for\b|[A-Za-z_\\][\w\\]*\()', lines[number - 1]):
|
|
executable.add(number)
|
|
covered = {n for n in executable if evidence.get(n, -1) > 0}
|
|
else:
|
|
offsets = []
|
|
offset = 0
|
|
in_comment = False
|
|
for number, text in enumerate(lines, 1):
|
|
stripped = text.strip()
|
|
if stripped.startswith('/*'):
|
|
in_comment = True
|
|
comment = in_comment
|
|
if '*/' in stripped:
|
|
in_comment = False
|
|
if number in added and stripped and not comment and not stripped.startswith('//') and stripped not in ['}', '});', '];', '};']:
|
|
executable.add(number)
|
|
offsets.append((number, offset + len(text) - len(text.lstrip())))
|
|
offset += len(text)
|
|
for session in browser:
|
|
entries = session['js' if file.suffix == '.js' else 'css']
|
|
for entry in entries:
|
|
content = entry.get('text', entry.get('source', ''))
|
|
if file.suffix == '.js':
|
|
if not entry.get('url', '').endswith('/' + name):
|
|
continue
|
|
ranges = [r for fn in entry['functions'] for r in fn['ranges']]
|
|
else:
|
|
if not content.startswith(source):
|
|
continue
|
|
ranges = [dict(startOffset=r['start'], endOffset=r['end'], count=1) for r in entry['ranges']]
|
|
instrumented = True
|
|
content_lines = content.splitlines(keepends=True)
|
|
content_offsets = [0]
|
|
for text in content_lines:
|
|
content_offsets.append(content_offsets[-1] + len(text))
|
|
for number, original_offset in offsets:
|
|
if number > len(content_lines):
|
|
continue
|
|
text = content_lines[number - 1]
|
|
if file.suffix == '.js' and text.strip() != lines[number - 1].strip():
|
|
continue
|
|
offset = content_offsets[number - 1] + len(text) - len(text.lstrip())
|
|
enclosing = [r for r in ranges if r['startOffset'] <= offset < r['endOffset']]
|
|
if enclosing and min(enclosing, key=lambda r: r['endOffset'] - r['startOffset'])['count'] > 0:
|
|
covered.add(number)
|
|
report[name] = {
|
|
'instrumented': instrumented,
|
|
'covered': len(covered), 'executable': len(executable),
|
|
'uncovered': sorted(executable - covered),
|
|
}
|
|
print(json.dumps(report, indent=2))
|
|
covered = sum(row['covered'] for row in report.values())
|
|
total = sum(row['executable'] for row in report.values())
|
|
# An empty report means the PR changed no files of this language, so there
|
|
# is nothing to gate; all() over no rows is already True, so only the
|
|
# redundant "report and" guard turned that into a failure.
|
|
complete = all(row['instrumented'] for row in report.values())
|
|
return 0 if complete and (total == 0 or covered * 100 >= args.minimum * total) else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|