Files
cacti/tests/tools/check_changed_shell_coverage.py
Thomas Vincent e2a5f32ee9 fix(ci): [develop] harden dependency installs and Docker build boundaries (#8294)
* security(ci): constrain dependency installs and container build boundaries

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(docker): retain shipped runtime libraries in build context

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(tooling): quote gettext paths and honor Docker base selection

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(auth): use certificate expiry truth in rendering assertions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(tooling): finish Docker layer and regex scan findings

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(ci): preserve documentation badge anchors without alternation

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test(ci): preserve contributor badges and enforce Python coverage

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: share changed-code coverage contracts and enforce shell policy

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(ci): enforce private review files and run path regressions

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: measure changed gettext shell coverage

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: pin coverage to the reviewed PR revision

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-10-14 22:52:37 -04:00

98 lines
3.9 KiB
Python

"""Require kcov evidence for every changed production shell script.
Copyright (C) 2026 The Cacti Group. GPL-2.0-or-later.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import subprocess
import xml.etree.ElementTree as ET
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', '--',
'*.sh', ':(exclude)tests/**'], text=True)
result = {}
name = None
number = 0
for record in diff.splitlines():
if record.startswith('diff --git '):
name = None
elif record.startswith('+++ b/'):
name = record[6:]
result.setdefault(name, set())
elif record.startswith('@@'):
number = int(re.search(r'\+(\d+)', record).group(1))
elif record.startswith('+') and name is not None:
result[name].add(number)
number += 1
elif not record.startswith(('-', '\\')):
number += 1
return {name: lines for name, lines in result.items() if lines}
def measure(changed, directory, root):
evidence = {}
for manifest in directory.glob('*/source.json'):
source = json.loads(manifest.read_text())
name = source['source']
if name not in changed:
continue
if hashlib.sha256((root / name).read_bytes()).hexdigest() != source['sha256']:
raise ValueError('Shell coverage source does not match ' + name)
reports = list(manifest.parent.rglob('cov.xml'))
if not reports:
raise ValueError('Missing native shell coverage report for ' + name)
for report in reports:
for entry in ET.parse(report).iter('class'):
filename = entry.attrib['filename']
if filename not in (name, str(root / name), source['native_source']):
continue
statements = evidence.setdefault(name, {})
for line in entry.findall('lines/line'):
number = int(line.attrib['number'])
statements[number] = max(statements.get(number, 0), int(line.attrib['hits']))
files = {}
for name, added in changed.items():
if name not in evidence:
files[name] = {'missing': True}
continue
statements = evidence[name]
executable = added & statements.keys()
covered = {number for number in executable if statements[number] > 0}
files[name] = {'covered': len(covered), 'executable': len(executable),
'uncovered': sorted(executable - covered)}
covered = sum(item.get('covered', 0) for item in files.values())
total = sum(item.get('executable', 0) for item in files.values())
return {'covered': covered, 'executable': total,
'percent': covered / total * 100 if total else None, 'files': files}
def passes(result, minimum):
return (not any(item.get('missing') for item in result['files'].values()) and
(result['executable'] == 0 or result['covered'] * 100 >= minimum * result['executable']))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--base', required=True)
parser.add_argument('--reports', type=Path, required=True)
parser.add_argument('--minimum', type=float, default=80)
parser.add_argument('--json-output', type=Path, required=True)
args = parser.parse_args()
if not 0 <= args.minimum <= 100:
parser.error('Minimum must be between 0 and 100')
result = measure(added_lines(args.base), args.reports, Path.cwd())
output = json.dumps(result, indent=2)
args.json_output.write_text(output + '\n')
print(output)
return 0 if passes(result, args.minimum) else 1
if __name__ == '__main__':
raise SystemExit(main())