master
py 156 lines 4.58 KB
Raw
1 #!/usr/bin/env python3
2
3 import argparse
4 import re
5 import subprocess
6 import sys
7 from pathlib import Path
8
9 from ruamel.yaml import YAML, YAMLError
10
11 from gen_taxonomy import FATAL, Finding, build_taxonomy, relpath
12 from _common import REPO_PATH
13
14 HUNK_RE = re.compile(r'^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@')
15
16
17 def run_git(*args):
18 return subprocess.check_output(['git', '-C', str(REPO_PATH), *args], text=True)
19
20
21 def metadata_metrics_spans(path):
22 text = path.read_text()
23 lines = text.splitlines()
24 yaml = YAML(typ='rt')
25 try:
26 data = yaml.load(text)
27 except YAMLError:
28 return []
29
30 spans = []
31 modules = data.get('modules', []) if isinstance(data, dict) else []
32 for module_index, module in enumerate(modules):
33 if not isinstance(module, dict) or 'metrics' not in module:
34 continue
35 try:
36 start = module.lc.key('metrics')[0] + 1
37 except (AttributeError, KeyError, TypeError):
38 continue
39
40 next_lines = []
41 for key in module:
42 if key == 'metrics':
43 continue
44 try:
45 key_line = module.lc.key(key)[0] + 1
46 except (AttributeError, KeyError, TypeError):
47 continue
48 if key_line > start:
49 next_lines.append(key_line)
50
51 if next_lines:
52 end = min(next_lines) - 1
53 else:
54 next_module_line = None
55 try:
56 if module_index + 1 < len(modules):
57 next_module_line = modules.lc.item(module_index + 1)[0] + 1
58 except (AttributeError, KeyError, TypeError):
59 next_module_line = None
60 end = (next_module_line - 1) if next_module_line else len(lines)
61
62 spans.append((start, end))
63 return spans
64
65
66 def range_intersects_spans(start, length, spans):
67 if length == 0:
68 changed_start = start
69 changed_end = start
70 else:
71 changed_start = start
72 changed_end = start + length - 1
73 return any(changed_start <= span_end and changed_end >= span_start for span_start, span_end in spans)
74
75
76 def metadata_metrics_touched(diff_range, path):
77 if not path.exists():
78 return True
79
80 diff = run_git('diff', '--unified=0', diff_range, '--', relpath(path))
81 if not diff.strip():
82 return False
83
84 spans = metadata_metrics_spans(path)
85 if not spans:
86 return True
87
88 for line in diff.splitlines():
89 match = HUNK_RE.match(line)
90 if not match:
91 continue
92 start = int(match.group(1))
93 length = int(match.group(2) or '1')
94 if range_intersects_spans(start, length, spans):
95 return True
96 return False
97
98
99 def touched_collectors(diff_range):
100 output = run_git('diff', '--name-status', diff_range)
101 touched = set()
102 for line in output.splitlines():
103 if not line.strip():
104 continue
105 fields = line.split('\t')
106 status = fields[0]
107 path = REPO_PATH / fields[-1]
108 name = path.name
109
110 if name == 'taxonomy.yaml':
111 touched.add(path.parent)
112 elif name == 'metadata.yaml':
113 if status.startswith(('A', 'D')):
114 touched.add(path.parent)
115 elif metadata_metrics_touched(diff_range, path):
116 touched.add(path.parent)
117 return sorted(touched)
118
119
120 def check_touched_coverage(diff_range):
121 findings = []
122 for collector_dir in touched_collectors(diff_range):
123 taxonomy_path = collector_dir / 'taxonomy.yaml'
124 metadata_path = collector_dir / 'metadata.yaml'
125 if not taxonomy_path.exists() and not metadata_path.exists():
126 continue
127 if not taxonomy_path.exists():
128 findings.append(Finding(
129 code='TAX030',
130 severity=FATAL,
131 path=taxonomy_path,
132 message='Collector metrics or taxonomy changed, but taxonomy.yaml is missing.',
133 ))
134 return findings
135
136
137 def main():
138 parser = argparse.ArgumentParser(description='Validate collector taxonomy coverage and taxonomy artifact generation.')
139 parser.add_argument('--pr-diff', help='Git diff range for touched-collector coverage, for example origin/master...HEAD.')
140 args = parser.parse_args()
141
142 findings = []
143 if args.pr_diff:
144 findings.extend(check_touched_coverage(args.pr_diff))
145
146 _, taxonomy_findings = build_taxonomy()
147 findings.extend(taxonomy_findings)
148
149 for finding in findings:
150 print(finding.render(), file=sys.stderr)
151
152 return 1 if any(finding.severity == FATAL for finding in findings) else 0
153
154
155 if __name__ == '__main__':
156 sys.exit(main())