|
16 | 16 | from __future__ import annotations |
17 | 17 |
|
18 | 18 | import argparse |
| 19 | +import importlib.util |
19 | 20 | import json |
20 | 21 | import os |
| 22 | +import sys |
21 | 23 | from pathlib import Path |
22 | 24 | from typing import Any |
23 | 25 |
|
| 26 | +# Ensure shared metric code under src/ is importable when executed directly. |
| 27 | +_REPO_ROOT = Path(__file__).resolve().parent.parent |
| 28 | +if str(_REPO_ROOT) not in sys.path: |
| 29 | + sys.path.insert(0, str(_REPO_ROOT)) |
24 | 30 |
|
25 | | -def _is_non_empty(value: Any) -> bool: |
26 | | - if value is None: |
27 | | - return False |
28 | | - if isinstance(value, str): |
29 | | - return bool(value.strip()) |
30 | | - if isinstance(value, (list, tuple, set, dict)): |
31 | | - return len(value) > 0 |
32 | | - return True |
| 31 | +# Import only the metrics module, avoid heavy __init__.py |
| 32 | +_metrics_path = _REPO_ROOT / "src/extensions/score_metamodel/traceability_metrics.py" |
| 33 | +_spec = importlib.util.spec_from_file_location("traceability_metrics", _metrics_path) |
| 34 | +if _spec is None or _spec.loader is None: |
| 35 | + raise ImportError(f"Failed to load metrics module from {_metrics_path}") |
| 36 | +traceability_metrics = importlib.util.module_from_spec(_spec) |
| 37 | +_spec.loader.exec_module(traceability_metrics) |
33 | 38 |
|
34 | | - |
35 | | -def _parse_need_id_list(value: Any) -> list[str]: |
36 | | - if value is None: |
37 | | - return [] |
38 | | - if isinstance(value, str): |
39 | | - return [item.strip() for item in value.split(",") if item.strip()] |
40 | | - if isinstance(value, list): |
41 | | - out: list[str] = [] |
42 | | - for item in value: |
43 | | - if isinstance(item, str) and item.strip(): |
44 | | - out.append(item.strip()) |
45 | | - return out |
46 | | - return [] |
47 | | - |
48 | | - |
49 | | -def _safe_percent(numerator: int, denominator: int) -> float: |
50 | | - if denominator == 0: |
51 | | - return 100.0 |
52 | | - return (numerator / denominator) * 100.0 |
| 39 | +compute_traceability_summary = traceability_metrics.compute_traceability_summary |
53 | 40 |
|
54 | 41 |
|
55 | 42 | def _load_needs(needs_json: Path) -> list[dict[str, Any]]: |
@@ -121,113 +108,6 @@ def _apply_argument_shortcuts(args: argparse.Namespace) -> None: |
121 | 108 | args.fail_on_broken_test_refs = True |
122 | 109 |
|
123 | 110 |
|
124 | | -def _filter_requirements( |
125 | | - all_needs: list[dict[str, Any]], |
126 | | - requirement_types: set[str], |
127 | | - include_not_implemented: bool, |
128 | | -) -> list[dict[str, Any]]: |
129 | | - """Extract and filter requirements from needs.""" |
130 | | - requirements: list[dict[str, Any]] = [] |
131 | | - for need in all_needs: |
132 | | - need_type = str(need.get("type", "")).strip() |
133 | | - if need_type not in requirement_types: |
134 | | - continue |
135 | | - if not include_not_implemented: |
136 | | - implemented = str(need.get("implemented", "")).upper().strip() |
137 | | - if implemented not in {"YES", "PARTIAL"}: |
138 | | - continue |
139 | | - requirements.append(need) |
140 | | - return requirements |
141 | | - |
142 | | - |
143 | | -def _calculate_requirement_metrics( |
144 | | - requirements: list[dict[str, Any]], |
145 | | -) -> tuple[int, int, int, int, list[str], list[str], list[str]]: |
146 | | - """Calculate traceability metrics for requirements.""" |
147 | | - req_total = len(requirements) |
148 | | - req_with_code = sum( |
149 | | - 1 for need in requirements if _is_non_empty(need.get("source_code_link")) |
150 | | - ) |
151 | | - req_with_test = sum( |
152 | | - 1 for need in requirements if _is_non_empty(need.get("testlink")) |
153 | | - ) |
154 | | - req_fully_linked = sum( |
155 | | - 1 |
156 | | - for need in requirements |
157 | | - if _is_non_empty(need.get("source_code_link")) |
158 | | - and _is_non_empty(need.get("testlink")) |
159 | | - ) |
160 | | - req_missing_code = [ |
161 | | - str(need.get("id", "")) |
162 | | - for need in requirements |
163 | | - if not _is_non_empty(need.get("source_code_link")) and need.get("id") |
164 | | - ] |
165 | | - req_missing_test = [ |
166 | | - str(need.get("id", "")) |
167 | | - for need in requirements |
168 | | - if not _is_non_empty(need.get("testlink")) and need.get("id") |
169 | | - ] |
170 | | - req_not_fully_linked = [ |
171 | | - str(need.get("id", "")) |
172 | | - for need in requirements |
173 | | - if ( |
174 | | - ( |
175 | | - not _is_non_empty(need.get("source_code_link")) |
176 | | - or not _is_non_empty(need.get("testlink")) |
177 | | - ) |
178 | | - and need.get("id") |
179 | | - ) |
180 | | - ] |
181 | | - return ( |
182 | | - req_total, |
183 | | - req_with_code, |
184 | | - req_with_test, |
185 | | - req_fully_linked, |
186 | | - req_missing_code, |
187 | | - req_missing_test, |
188 | | - req_not_fully_linked, |
189 | | - ) |
190 | | - |
191 | | - |
192 | | -def _calculate_test_metrics( |
193 | | - all_needs: list[dict[str, Any]], |
194 | | - requirement_ids: set[str], |
195 | | - filtered_test_types: set[str], |
196 | | -) -> tuple[int, int, list[dict[str, str]]]: |
197 | | - """Calculate test linkage metrics and find broken references.""" |
198 | | - testcases = [ |
199 | | - need for need in all_needs if str(need.get("type", "")).strip() == "testcase" |
200 | | - ] |
201 | | - if filtered_test_types: |
202 | | - testcases = [ |
203 | | - need |
204 | | - for need in testcases |
205 | | - if str(need.get("test_type", need.get("TestType", ""))).strip() |
206 | | - in filtered_test_types |
207 | | - ] |
208 | | - tests_total = len(testcases) |
209 | | - |
210 | | - tests_linked = 0 |
211 | | - broken_test_references: list[dict[str, str]] = [] |
212 | | - for test_need in testcases: |
213 | | - test_id = str(test_need.get("id", "<unknown_testcase>")) |
214 | | - partially = _parse_need_id_list( |
215 | | - test_need.get("partially_verifies", test_need.get("PartiallyVerifies")) |
216 | | - ) |
217 | | - fully = _parse_need_id_list( |
218 | | - test_need.get("fully_verifies", test_need.get("FullyVerifies")) |
219 | | - ) |
220 | | - refs = partially + fully |
221 | | - if refs: |
222 | | - tests_linked += 1 |
223 | | - for ref in refs: |
224 | | - if ref not in requirement_ids: |
225 | | - broken_test_references.append( |
226 | | - {"testcase": test_id, "missing_need": ref} |
227 | | - ) |
228 | | - return tests_total, tests_linked, broken_test_references |
229 | | - |
230 | | - |
231 | 111 | def _print_summary( |
232 | 112 | needs_json: Path, |
233 | 113 | req_total: int, |
@@ -432,56 +312,35 @@ def main() -> int: |
432 | 312 | needs_json = _find_needs_json(args.needs_json) |
433 | 313 | all_needs = _load_needs(needs_json) |
434 | 314 |
|
435 | | - requirements = _filter_requirements( |
436 | | - all_needs, requirement_types, args.include_not_implemented |
437 | | - ) |
438 | | - |
439 | | - requirement_ids = { |
440 | | - str(need.get("id", "")).strip() for need in requirements if need.get("id") |
441 | | - } |
442 | | - |
443 | | - ( |
444 | | - req_total, |
445 | | - req_with_code, |
446 | | - req_with_test, |
447 | | - req_fully_linked, |
448 | | - req_missing_code, |
449 | | - req_missing_test, |
450 | | - req_not_fully_linked, |
451 | | - ) = _calculate_requirement_metrics(requirements) |
452 | | - |
453 | | - tests_total, tests_linked, broken_test_references = _calculate_test_metrics( |
454 | | - all_needs, requirement_ids, filtered_test_types |
| 315 | + summary = compute_traceability_summary( |
| 316 | + all_needs=all_needs, |
| 317 | + requirement_types=requirement_types, |
| 318 | + include_not_implemented=args.include_not_implemented, |
| 319 | + filtered_test_types=filtered_test_types, |
455 | 320 | ) |
456 | 321 |
|
457 | | - req_code_pct = _safe_percent(req_with_code, req_total) |
458 | | - req_test_pct = _safe_percent(req_with_test, req_total) |
459 | | - req_fully_linked_pct = _safe_percent(req_fully_linked, req_total) |
460 | | - tests_linked_pct = _safe_percent(tests_linked, tests_total) |
461 | | - |
462 | | - summary = { |
| 322 | + req_total = int(summary["requirements"]["total"]) |
| 323 | + req_with_code = int(summary["requirements"]["with_code_link"]) |
| 324 | + req_with_test = int(summary["requirements"]["with_test_link"]) |
| 325 | + req_fully_linked = int(summary["requirements"]["fully_linked"]) |
| 326 | + req_code_pct = float(summary["requirements"]["with_code_link_pct"]) |
| 327 | + req_test_pct = float(summary["requirements"]["with_test_link_pct"]) |
| 328 | + req_fully_linked_pct = float(summary["requirements"]["fully_linked_pct"]) |
| 329 | + req_missing_code = list(summary["requirements"]["missing_code_link_ids"]) |
| 330 | + req_missing_test = list(summary["requirements"]["missing_test_link_ids"]) |
| 331 | + req_not_fully_linked = list(summary["requirements"]["not_fully_linked_ids"]) |
| 332 | + |
| 333 | + tests_total = int(summary["tests"]["total"]) |
| 334 | + tests_linked = int(summary["tests"]["linked_to_requirements"]) |
| 335 | + tests_linked_pct = float(summary["tests"]["linked_to_requirements_pct"]) |
| 336 | + broken_test_references = list(summary["tests"]["broken_references"]) |
| 337 | + |
| 338 | + summary_output = { |
463 | 339 | "needs_json": str(needs_json), |
464 | | - "requirement_types": sorted(requirement_types), |
465 | | - "include_not_implemented": bool(args.include_not_implemented), |
466 | | - "requirements": { |
467 | | - "total": req_total, |
468 | | - "with_code_link": req_with_code, |
469 | | - "with_test_link": req_with_test, |
470 | | - "fully_linked": req_fully_linked, |
471 | | - "with_code_link_pct": req_code_pct, |
472 | | - "with_test_link_pct": req_test_pct, |
473 | | - "fully_linked_pct": req_fully_linked_pct, |
474 | | - "missing_code_link_ids": sorted(req_missing_code), |
475 | | - "missing_test_link_ids": sorted(req_missing_test), |
476 | | - "not_fully_linked_ids": sorted(req_not_fully_linked), |
477 | | - }, |
478 | | - "tests": { |
479 | | - "total": tests_total, |
480 | | - "filtered_test_types": sorted(filtered_test_types), |
481 | | - "linked_to_requirements": tests_linked, |
482 | | - "linked_to_requirements_pct": tests_linked_pct, |
483 | | - "broken_references": broken_test_references, |
484 | | - }, |
| 340 | + "requirement_types": summary["requirement_types"], |
| 341 | + "include_not_implemented": summary["include_not_implemented"], |
| 342 | + "requirements": summary["requirements"], |
| 343 | + "tests": summary["tests"], |
485 | 344 | "thresholds": { |
486 | 345 | "min_req_code": float(args.min_req_code), |
487 | 346 | "min_req_test": float(args.min_req_test), |
@@ -512,7 +371,7 @@ def main() -> int: |
512 | 371 |
|
513 | 372 | if args.json_output: |
514 | 373 | out_file = Path(args.json_output) |
515 | | - out_file.write_text(json.dumps(summary, indent=2), encoding="utf-8") |
| 374 | + out_file.write_text(json.dumps(summary_output, indent=2), encoding="utf-8") |
516 | 375 | print(f"JSON summary written to: {out_file}") |
517 | 376 |
|
518 | 377 | failures = _check_thresholds( |
|
0 commit comments