reqcov 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- reqcov/__init__.py +3 -0
- reqcov/cli.py +122 -0
- reqcov/config.py +121 -0
- reqcov/coverage.py +149 -0
- reqcov/files.py +38 -0
- reqcov/junit.py +63 -0
- reqcov/links.py +95 -0
- reqcov/models.py +153 -0
- reqcov/report.py +178 -0
- reqcov/requirements.py +310 -0
- reqcov/templates/report.html +139 -0
- reqcov-0.1.0.dist-info/METADATA +176 -0
- reqcov-0.1.0.dist-info/RECORD +17 -0
- reqcov-0.1.0.dist-info/WHEEL +5 -0
- reqcov-0.1.0.dist-info/entry_points.txt +2 -0
- reqcov-0.1.0.dist-info/licenses/LICENSE +21 -0
- reqcov-0.1.0.dist-info/top_level.txt +1 -0
reqcov/__init__.py
ADDED
reqcov/cli.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Command line interface.
|
|
2
|
+
|
|
3
|
+
reqcov check [--config reqcov.yml] [--root .] [--junit reports/*.xml] [--out dir] [--no-report]
|
|
4
|
+
reqcov report ... same options, never fails the build
|
|
5
|
+
reqcov init write an example reqcov.yml
|
|
6
|
+
reqcov list print requirements found
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
from .config import EXAMPLE_CONFIG, Config
|
|
17
|
+
from .coverage import analyze
|
|
18
|
+
from .report import render_markdown, write_reports
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _common(p: argparse.ArgumentParser) -> None:
|
|
22
|
+
p.add_argument("--config", "-c", help="path to reqcov.yml (default: auto-detect in root)")
|
|
23
|
+
p.add_argument("--root", "-r", default=".", help="repository root (default: .)")
|
|
24
|
+
p.add_argument("--junit", action="append", help="JUnit XML glob (repeatable, overrides config)")
|
|
25
|
+
p.add_argument("--out", "-o", help="report output directory (overrides config)")
|
|
26
|
+
p.add_argument("--format", "-f", action="append", choices=["html", "csv", "json", "md"], help="report formats (repeatable)")
|
|
27
|
+
p.add_argument("--no-report", action="store_true", help="do not write report files")
|
|
28
|
+
p.add_argument("--quiet", "-q", action="store_true")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
32
|
+
p = argparse.ArgumentParser(prog="reqcov", description="Requirements coverage for pull requests.")
|
|
33
|
+
p.add_argument("--version", action="version", version=f"reqcov {__version__}")
|
|
34
|
+
sub = p.add_subparsers(dest="cmd")
|
|
35
|
+
c = sub.add_parser("check", help="analyze and fail (exit 1) when rules are violated")
|
|
36
|
+
_common(c)
|
|
37
|
+
r = sub.add_parser("report", help="analyze and write reports, never fails")
|
|
38
|
+
_common(r)
|
|
39
|
+
i = sub.add_parser("init", help="write an example reqcov.yml")
|
|
40
|
+
i.add_argument("--root", "-r", default=".")
|
|
41
|
+
i.add_argument("--force", action="store_true")
|
|
42
|
+
l = sub.add_parser("list", help="list requirements found")
|
|
43
|
+
_common(l)
|
|
44
|
+
return p
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _load(args) -> Config:
|
|
48
|
+
cfg = Config.load(args.config, root=args.root)
|
|
49
|
+
if getattr(args, "junit", None):
|
|
50
|
+
cfg.junit = args.junit
|
|
51
|
+
if getattr(args, "out", None):
|
|
52
|
+
cfg.report.out_dir = args.out
|
|
53
|
+
if getattr(args, "format", None):
|
|
54
|
+
cfg.report.formats = args.format
|
|
55
|
+
return cfg
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
59
|
+
args = build_parser().parse_args(argv)
|
|
60
|
+
if args.cmd is None:
|
|
61
|
+
build_parser().print_help()
|
|
62
|
+
return 2
|
|
63
|
+
|
|
64
|
+
if args.cmd == "init":
|
|
65
|
+
path = os.path.join(args.root, "reqcov.yml")
|
|
66
|
+
if os.path.exists(path) and not args.force:
|
|
67
|
+
print(f"{path} already exists (use --force to overwrite)", file=sys.stderr)
|
|
68
|
+
return 1
|
|
69
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
70
|
+
fh.write(EXAMPLE_CONFIG)
|
|
71
|
+
print(f"wrote {path}")
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
cfg = _load(args)
|
|
75
|
+
report = analyze(cfg)
|
|
76
|
+
|
|
77
|
+
if args.cmd == "list":
|
|
78
|
+
for level, rows in report.by_level().items():
|
|
79
|
+
print(f"[{level}]")
|
|
80
|
+
for rc in rows:
|
|
81
|
+
r = rc.requirement
|
|
82
|
+
print(f" {r.id:<12} {rc.verification_status:<10} {r.title[:70]}")
|
|
83
|
+
return 0
|
|
84
|
+
|
|
85
|
+
if not args.no_report:
|
|
86
|
+
written = write_reports(report, cfg)
|
|
87
|
+
else:
|
|
88
|
+
written = {}
|
|
89
|
+
|
|
90
|
+
if not args.quiet:
|
|
91
|
+
print(render_markdown(report))
|
|
92
|
+
for fmt, p in written.items():
|
|
93
|
+
print(f"[reqcov] wrote {fmt}: {p}")
|
|
94
|
+
|
|
95
|
+
# GitHub Actions integration: job summary + annotations
|
|
96
|
+
summary_path = os.environ.get("GITHUB_STEP_SUMMARY")
|
|
97
|
+
if summary_path:
|
|
98
|
+
with open(summary_path, "a", encoding="utf-8") as fh:
|
|
99
|
+
fh.write(render_markdown(report))
|
|
100
|
+
if os.environ.get("GITHUB_ACTIONS") == "true":
|
|
101
|
+
for f in report.findings:
|
|
102
|
+
if f.severity == "info":
|
|
103
|
+
continue
|
|
104
|
+
level = "error" if f.severity == "error" else "warning"
|
|
105
|
+
loc = f" file={f.file}" + (f",line={f.line}" if f.line else "") if f.file else ""
|
|
106
|
+
print(f"::{level}{loc}::[{f.code}] {f.message}")
|
|
107
|
+
out = os.environ.get("GITHUB_OUTPUT")
|
|
108
|
+
if out:
|
|
109
|
+
with open(out, "a", encoding="utf-8") as fh:
|
|
110
|
+
fh.write(f"coverage={report.test_coverage_pct():.1f}\n")
|
|
111
|
+
fh.write(f"errors={len(report.errors)}\n")
|
|
112
|
+
fh.write(f"report_dir={os.path.join(cfg.root, cfg.report.out_dir)}\n")
|
|
113
|
+
|
|
114
|
+
if args.cmd == "check" and report.errors:
|
|
115
|
+
if not args.quiet:
|
|
116
|
+
print(f"[reqcov] FAILED with {len(report.errors)} error(s)", file=sys.stderr)
|
|
117
|
+
return 1
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
if __name__ == "__main__": # pragma: no cover
|
|
122
|
+
sys.exit(main())
|
reqcov/config.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Configuration loading (reqcov.yml)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
DEFAULT_CONFIG_NAMES = ("reqcov.yml", "reqcov.yaml", ".reqcov.yml")
|
|
11
|
+
|
|
12
|
+
DEFAULT_ID_PATTERN = r"[A-Z][A-Z0-9_]*-\d+"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Rules:
|
|
17
|
+
min_test_coverage: float = 100.0 # % of testable requirements with >= 1 linked test
|
|
18
|
+
min_verified: Optional[float] = None # % verified (needs JUnit); None = not enforced
|
|
19
|
+
fail_on_unknown_ids: bool = True
|
|
20
|
+
fail_on_orphan_tests: bool = False
|
|
21
|
+
fail_on_failing_tests: bool = True
|
|
22
|
+
require_parent_for: List[str] = field(default_factory=list) # levels that must have a parent
|
|
23
|
+
require_source_for: List[str] = field(default_factory=list) # levels that must have an implements link
|
|
24
|
+
allow_derived: bool = True # if False, missing parent is an error instead of warning
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class ReportConfig:
|
|
29
|
+
out_dir: str = "reqcov-report"
|
|
30
|
+
formats: List[str] = field(default_factory=lambda: ["html", "csv", "json", "md"])
|
|
31
|
+
title: str = "Requirements Traceability"
|
|
32
|
+
project: str = ""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class Config:
|
|
37
|
+
root: str = "."
|
|
38
|
+
id_pattern: str = DEFAULT_ID_PATTERN
|
|
39
|
+
requirements: List[str] = field(default_factory=lambda: ["docs/requirements/**/*.md", "docs/requirements/**/*.yml"])
|
|
40
|
+
sources: List[str] = field(default_factory=lambda: ["src/**/*"])
|
|
41
|
+
tests: List[str] = field(default_factory=lambda: ["tests/**/*", "test/**/*"])
|
|
42
|
+
junit: List[str] = field(default_factory=list)
|
|
43
|
+
exclude: List[str] = field(default_factory=lambda: ["**/node_modules/**", "**/.git/**", "**/build/**", "**/.venv/**"])
|
|
44
|
+
markers: List[str] = field(default_factory=lambda: ["req", "requirement", "requirements", "implements", "verifies", "satisfies", "trace", "traces"])
|
|
45
|
+
rules: Rules = field(default_factory=Rules)
|
|
46
|
+
report: ReportConfig = field(default_factory=ReportConfig)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def load(path: Optional[str] = None, root: Optional[str] = None) -> "Config":
|
|
50
|
+
root = root or "."
|
|
51
|
+
data: Dict[str, Any] = {}
|
|
52
|
+
cfg_path = path
|
|
53
|
+
if cfg_path is None:
|
|
54
|
+
for name in DEFAULT_CONFIG_NAMES:
|
|
55
|
+
candidate = os.path.join(root, name)
|
|
56
|
+
if os.path.exists(candidate):
|
|
57
|
+
cfg_path = candidate
|
|
58
|
+
break
|
|
59
|
+
if cfg_path and os.path.exists(cfg_path):
|
|
60
|
+
with open(cfg_path, "r", encoding="utf-8") as fh:
|
|
61
|
+
data = yaml.safe_load(fh) or {}
|
|
62
|
+
return Config.from_dict(data, root=root)
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def from_dict(data: Dict[str, Any], root: str = ".") -> "Config":
|
|
66
|
+
cfg = Config(root=root)
|
|
67
|
+
if "id_pattern" in data:
|
|
68
|
+
cfg.id_pattern = str(data["id_pattern"])
|
|
69
|
+
for key in ("requirements", "sources", "tests", "junit", "exclude", "markers"):
|
|
70
|
+
if key in data and data[key] is not None:
|
|
71
|
+
val = data[key]
|
|
72
|
+
if isinstance(val, str):
|
|
73
|
+
val = [val]
|
|
74
|
+
# accept list of {path: ...} too
|
|
75
|
+
cfg.__dict__[key] = [v["path"] if isinstance(v, dict) else str(v) for v in val]
|
|
76
|
+
rules = data.get("rules") or {}
|
|
77
|
+
for k, v in rules.items():
|
|
78
|
+
if hasattr(cfg.rules, k):
|
|
79
|
+
setattr(cfg.rules, k, v)
|
|
80
|
+
rep = data.get("report") or {}
|
|
81
|
+
for k, v in rep.items():
|
|
82
|
+
if hasattr(cfg.report, k):
|
|
83
|
+
setattr(cfg.report, k, v)
|
|
84
|
+
return cfg
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
EXAMPLE_CONFIG = """# reqcov configuration — see https://github.com/Antoine005/reqcov
|
|
88
|
+
version: 1
|
|
89
|
+
|
|
90
|
+
# Regex for requirement identifiers. Level = everything before the last dash (SYS, SRS, HLR, LLR...).
|
|
91
|
+
id_pattern: "[A-Z][A-Z0-9_]*-\\\\d+"
|
|
92
|
+
|
|
93
|
+
# Where requirements live (Markdown headings, YAML lists, or Doorstop items).
|
|
94
|
+
requirements:
|
|
95
|
+
- docs/requirements/**/*.md
|
|
96
|
+
- docs/requirements/**/*.yml
|
|
97
|
+
|
|
98
|
+
# Files scanned for `@implements REQ-1` style markers (traces to code).
|
|
99
|
+
sources:
|
|
100
|
+
- src/**/*
|
|
101
|
+
|
|
102
|
+
# Files scanned for `@req REQ-1` / `@verifies REQ-1` markers (traces to tests).
|
|
103
|
+
tests:
|
|
104
|
+
- tests/**/*
|
|
105
|
+
|
|
106
|
+
# Optional JUnit XML results: turns "covered" into "verified" / "failing".
|
|
107
|
+
junit:
|
|
108
|
+
- reports/**/*.xml
|
|
109
|
+
|
|
110
|
+
rules:
|
|
111
|
+
min_test_coverage: 100 # % of testable requirements that must have at least one test
|
|
112
|
+
fail_on_unknown_ids: true # a marker references an id that does not exist
|
|
113
|
+
fail_on_orphan_tests: false # a test has no requirement marker
|
|
114
|
+
fail_on_failing_tests: true # a linked test failed (needs junit)
|
|
115
|
+
require_parent_for: [SRS] # these levels must trace up to a parent requirement
|
|
116
|
+
|
|
117
|
+
report:
|
|
118
|
+
out_dir: reqcov-report
|
|
119
|
+
formats: [html, csv, json, md]
|
|
120
|
+
title: "Software Requirements Traceability"
|
|
121
|
+
"""
|
reqcov/coverage.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Build the coverage model and evaluate rules."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import datetime as _dt
|
|
5
|
+
import subprocess
|
|
6
|
+
from typing import Dict, List
|
|
7
|
+
|
|
8
|
+
from .config import Config
|
|
9
|
+
from .files import find_files
|
|
10
|
+
from .junit import load_results, match_results
|
|
11
|
+
from .links import scan_files
|
|
12
|
+
from .models import CoverageReport, Finding, Reference, RequirementCoverage, TestResult
|
|
13
|
+
from .requirements import load_requirements
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _git_sha(root: str) -> str:
|
|
17
|
+
try:
|
|
18
|
+
return subprocess.check_output(["git", "rev-parse", "--short", "HEAD"], cwd=root, stderr=subprocess.DEVNULL, text=True).strip()
|
|
19
|
+
except Exception: # pragma: no cover
|
|
20
|
+
return ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def analyze(cfg: Config) -> CoverageReport:
|
|
24
|
+
findings: List[Finding] = []
|
|
25
|
+
root = cfg.root
|
|
26
|
+
|
|
27
|
+
req_files = find_files(root, cfg.requirements, cfg.exclude)
|
|
28
|
+
if not req_files:
|
|
29
|
+
findings.append(Finding("error", "NO_REQUIREMENTS", f"no requirement files matched {cfg.requirements}"))
|
|
30
|
+
requirements = load_requirements(root, req_files, cfg.id_pattern, findings)
|
|
31
|
+
|
|
32
|
+
# don't scan requirement files themselves as sources/tests
|
|
33
|
+
src_files = [f for f in find_files(root, cfg.sources, cfg.exclude) if f not in req_files]
|
|
34
|
+
test_files = [f for f in find_files(root, cfg.tests, cfg.exclude) if f not in req_files]
|
|
35
|
+
src_set = set(src_files)
|
|
36
|
+
test_files = [f for f in test_files if f not in src_set] # tests win over sources only if not both
|
|
37
|
+
|
|
38
|
+
refs = scan_files(root, test_files, "test", cfg.markers, cfg.id_pattern)
|
|
39
|
+
refs += scan_files(root, src_files, "source", cfg.markers, cfg.id_pattern)
|
|
40
|
+
|
|
41
|
+
# junit results often live under build/ — never apply the exclude list to them
|
|
42
|
+
results = load_results(root, find_files(root, cfg.junit, ())) if cfg.junit else []
|
|
43
|
+
|
|
44
|
+
cov: Dict[str, RequirementCoverage] = {rid: RequirementCoverage(requirement=r) for rid, r in requirements.items()}
|
|
45
|
+
unknown: List[Reference] = []
|
|
46
|
+
for ref in refs:
|
|
47
|
+
rc = cov.get(ref.req_id)
|
|
48
|
+
if rc is None:
|
|
49
|
+
unknown.append(ref)
|
|
50
|
+
continue
|
|
51
|
+
if ref.kind == "test":
|
|
52
|
+
rc.tests.append(ref)
|
|
53
|
+
else:
|
|
54
|
+
rc.sources.append(ref)
|
|
55
|
+
|
|
56
|
+
# parent / child relationships
|
|
57
|
+
for rc in cov.values():
|
|
58
|
+
for p in rc.requirement.parents:
|
|
59
|
+
if p in cov:
|
|
60
|
+
cov[p].children.append(rc.requirement.id)
|
|
61
|
+
else:
|
|
62
|
+
rc.unknown_parents.append(p)
|
|
63
|
+
|
|
64
|
+
# test results
|
|
65
|
+
matched_result_ids = set()
|
|
66
|
+
for rc in cov.values():
|
|
67
|
+
seen = set()
|
|
68
|
+
for t in rc.tests:
|
|
69
|
+
for res in match_results(t.symbol, t.file, results):
|
|
70
|
+
key = id(res)
|
|
71
|
+
if key not in seen:
|
|
72
|
+
seen.add(key)
|
|
73
|
+
rc.results.append(res)
|
|
74
|
+
matched_result_ids.add(key)
|
|
75
|
+
referenced_symbols = {r.symbol.split(".")[-1] for r in refs if r.kind == "test" and r.symbol}
|
|
76
|
+
orphan_tests = [
|
|
77
|
+
r for r in results if id(r) not in matched_result_ids and r.name.split("[")[0] not in referenced_symbols
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
report = CoverageReport(
|
|
81
|
+
requirements=cov,
|
|
82
|
+
references=refs,
|
|
83
|
+
results=results,
|
|
84
|
+
findings=findings,
|
|
85
|
+
unknown_ids=unknown,
|
|
86
|
+
orphan_tests=orphan_tests,
|
|
87
|
+
generated_at=_dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
|
|
88
|
+
git_sha=_git_sha(root),
|
|
89
|
+
title=cfg.report.title,
|
|
90
|
+
)
|
|
91
|
+
evaluate_rules(report, cfg)
|
|
92
|
+
return report
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def evaluate_rules(report: CoverageReport, cfg: Config) -> None:
|
|
96
|
+
rules = cfg.rules
|
|
97
|
+
f = report.findings
|
|
98
|
+
|
|
99
|
+
for ref in report.unknown_ids:
|
|
100
|
+
f.append(
|
|
101
|
+
Finding(
|
|
102
|
+
"error" if rules.fail_on_unknown_ids else "warning",
|
|
103
|
+
"UNKNOWN_ID",
|
|
104
|
+
f"{ref.req_id} referenced by {ref.symbol or 'marker'} but not defined",
|
|
105
|
+
ref.file,
|
|
106
|
+
ref.line,
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
for rc in report.requirements.values():
|
|
111
|
+
r = rc.requirement
|
|
112
|
+
for p in rc.unknown_parents:
|
|
113
|
+
f.append(Finding("error", "UNKNOWN_PARENT", f"{r.id} traces to undefined parent {p}", r.file, r.line))
|
|
114
|
+
if r.level in rules.require_parent_for and not r.parents and r.status != "obsolete":
|
|
115
|
+
sev = "warning" if rules.allow_derived else "error"
|
|
116
|
+
f.append(Finding(sev, "DERIVED", f"{r.id} has no parent requirement (derived?)", r.file, r.line))
|
|
117
|
+
if r.level in rules.require_source_for and not rc.sources and r.status != "obsolete":
|
|
118
|
+
f.append(Finding("error", "NO_SOURCE", f"{r.id} is not implemented by any source marker", r.file, r.line))
|
|
119
|
+
if r.verification == "test" and r.status != "obsolete" and not rc.tests:
|
|
120
|
+
f.append(Finding("warning", "UNCOVERED", f"{r.id} has no linked test", r.file, r.line))
|
|
121
|
+
if rc.verification_status == "failing":
|
|
122
|
+
failed = [t for t in rc.results if t.status in ("failed", "error")]
|
|
123
|
+
f.append(
|
|
124
|
+
Finding(
|
|
125
|
+
"error" if rules.fail_on_failing_tests else "warning",
|
|
126
|
+
"TEST_FAILED",
|
|
127
|
+
f"{r.id}: {len(failed)} linked test(s) failed ({', '.join(t.name for t in failed[:3])})",
|
|
128
|
+
r.file,
|
|
129
|
+
r.line,
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
pct = report.test_coverage_pct()
|
|
134
|
+
if pct + 1e-9 < rules.min_test_coverage:
|
|
135
|
+
f.append(Finding("error", "COVERAGE", f"test coverage of requirements {pct:.1f}% is below the required {rules.min_test_coverage:.1f}%"))
|
|
136
|
+
if rules.min_verified is not None and report.results:
|
|
137
|
+
v = report.verified_pct()
|
|
138
|
+
if v + 1e-9 < rules.min_verified:
|
|
139
|
+
f.append(Finding("error", "VERIFIED", f"verified requirements {v:.1f}% is below the required {rules.min_verified:.1f}%"))
|
|
140
|
+
|
|
141
|
+
for t in report.orphan_tests:
|
|
142
|
+
f.append(
|
|
143
|
+
Finding(
|
|
144
|
+
"error" if rules.fail_on_orphan_tests else "info",
|
|
145
|
+
"ORPHAN_TEST",
|
|
146
|
+
f"test {t.full_name} is not linked to any requirement",
|
|
147
|
+
t.file,
|
|
148
|
+
)
|
|
149
|
+
)
|
reqcov/files.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""File discovery helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import fnmatch
|
|
5
|
+
import glob
|
|
6
|
+
import os
|
|
7
|
+
from typing import Iterable, List
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _excluded(rel: str, exclude: Iterable[str]) -> bool:
|
|
11
|
+
rel_posix = rel.replace(os.sep, "/")
|
|
12
|
+
for pat in exclude:
|
|
13
|
+
if fnmatch.fnmatch(rel_posix, pat) or fnmatch.fnmatch("/" + rel_posix, pat):
|
|
14
|
+
return True
|
|
15
|
+
# also match directory components ("**/build/**" should exclude "build/x")
|
|
16
|
+
parts = rel_posix.split("/")
|
|
17
|
+
core = pat.replace("**/", "").replace("/**", "")
|
|
18
|
+
if core and core in parts:
|
|
19
|
+
return True
|
|
20
|
+
return False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def find_files(root: str, patterns: Iterable[str], exclude: Iterable[str] = ()) -> List[str]:
|
|
24
|
+
"""Return sorted, de-duplicated file paths relative to root matching any pattern."""
|
|
25
|
+
found = set()
|
|
26
|
+
for pat in patterns:
|
|
27
|
+
abs_pat = os.path.join(root, pat)
|
|
28
|
+
for p in glob.glob(abs_pat, recursive=True):
|
|
29
|
+
if os.path.isfile(p):
|
|
30
|
+
rel = os.path.relpath(p, root)
|
|
31
|
+
if not _excluded(rel, exclude):
|
|
32
|
+
found.add(rel)
|
|
33
|
+
return sorted(found)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def read_text(root: str, rel: str) -> str:
|
|
37
|
+
with open(os.path.join(root, rel), "r", encoding="utf-8", errors="replace") as fh:
|
|
38
|
+
return fh.read()
|
reqcov/junit.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""JUnit XML parsing (pytest, Ceedling, GoogleTest, CTest, Jest, Maven... all emit it)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import xml.etree.ElementTree as ET
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
from .models import TestResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_junit(path: str) -> List[TestResult]:
|
|
12
|
+
results: List[TestResult] = []
|
|
13
|
+
try:
|
|
14
|
+
tree = ET.parse(path)
|
|
15
|
+
except ET.ParseError:
|
|
16
|
+
return results
|
|
17
|
+
root = tree.getroot()
|
|
18
|
+
for case in root.iter("testcase"):
|
|
19
|
+
name = case.get("name", "")
|
|
20
|
+
classname = case.get("classname", "") or ""
|
|
21
|
+
file_attr = case.get("file", "") or ""
|
|
22
|
+
status = "passed"
|
|
23
|
+
message = ""
|
|
24
|
+
for child in case:
|
|
25
|
+
tag = child.tag.lower()
|
|
26
|
+
if tag == "failure":
|
|
27
|
+
status, message = "failed", (child.get("message") or (child.text or "")).strip()[:500]
|
|
28
|
+
elif tag == "error":
|
|
29
|
+
status, message = "error", (child.get("message") or (child.text or "")).strip()[:500]
|
|
30
|
+
elif tag == "skipped":
|
|
31
|
+
status, message = "skipped", (child.get("message") or "").strip()[:500]
|
|
32
|
+
# GoogleTest puts "Suite.Name" in classname/name; Ceedling uses "file.c" classnames.
|
|
33
|
+
results.append(TestResult(name=name, classname=classname, status=status, file=file_attr, message=message))
|
|
34
|
+
return results
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def load_results(root: str, files: List[str]) -> List[TestResult]:
|
|
38
|
+
out: List[TestResult] = []
|
|
39
|
+
for rel in files:
|
|
40
|
+
out.extend(parse_junit(os.path.join(root, rel)))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def match_results(symbol: str, file: str, results: List[TestResult]) -> List[TestResult]:
|
|
45
|
+
"""Find results for a test symbol detected next to a marker.
|
|
46
|
+
|
|
47
|
+
Matching is name based: exact test name, ``Suite.Name`` for GoogleTest, or a
|
|
48
|
+
parametrised pytest id (``test_x[case]``). Ceedling reports ``classname`` as the
|
|
49
|
+
test file, so we also accept a file-stem match combined with the function name.
|
|
50
|
+
"""
|
|
51
|
+
if not symbol:
|
|
52
|
+
return []
|
|
53
|
+
short = symbol.split(".")[-1]
|
|
54
|
+
hits: List[TestResult] = []
|
|
55
|
+
for r in results:
|
|
56
|
+
rname = r.name
|
|
57
|
+
base = rname.split("[")[0]
|
|
58
|
+
if base == short or rname == symbol or f"{r.classname}.{rname}" == symbol:
|
|
59
|
+
hits.append(r)
|
|
60
|
+
continue
|
|
61
|
+
if "." in symbol and r.classname.endswith(symbol.split(".")[0]) and base == short:
|
|
62
|
+
hits.append(r)
|
|
63
|
+
return hits
|
reqcov/links.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Scan source and test files for requirement markers.
|
|
2
|
+
|
|
3
|
+
Recognised marker shapes (any language, inside comments, decorators, strings...)::
|
|
4
|
+
|
|
5
|
+
// @req SRS-001
|
|
6
|
+
# @req SRS-001, SRS-002
|
|
7
|
+
/* @verifies SRS-003 */
|
|
8
|
+
@pytest.mark.req("SRS-004")
|
|
9
|
+
@pytest.mark.req("SRS-004", "SRS-005")
|
|
10
|
+
// @implements SRS-006 (in source files -> code trace)
|
|
11
|
+
TEST_REQ(SRS-007) (any token followed by ids works if the verb is configured)
|
|
12
|
+
|
|
13
|
+
The verb list is configurable (``markers`` in reqcov.yml). Ids are matched with ``id_pattern``.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import List, Optional
|
|
19
|
+
|
|
20
|
+
from .files import read_text
|
|
21
|
+
from .models import Reference
|
|
22
|
+
|
|
23
|
+
_DEF_PATTERNS = [
|
|
24
|
+
re.compile(r"^\s*(?:async\s+)?def\s+(\w+)\s*\("), # python
|
|
25
|
+
re.compile(r"^\s*TEST(?:_F|_P)?\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)"), # googletest
|
|
26
|
+
re.compile(r"^\s*(?:static\s+)?void\s+(test_?\w*)\s*\("), # unity / ceedling
|
|
27
|
+
re.compile(r"^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)\s*[<(]"), # rust
|
|
28
|
+
re.compile(r"^\s*(?:it|test|describe)\s*\(\s*['\"`](.+?)['\"`]"), # js/ts
|
|
29
|
+
re.compile(r"^\s*(?:public\s+|private\s+)?(?:static\s+)?void\s+(\w+)\s*\("), # java / c#
|
|
30
|
+
re.compile(r"^\s*func\s+(Test\w+)\s*\("), # go
|
|
31
|
+
re.compile(r"^\s*(?:template\s*<[^>]*>\s*)?(?:class|struct)\s+(\w+)"), # C++ class / struct
|
|
32
|
+
# generic C/C++ definition: `static uint16_t crc16(`, `frame_status_t frame_validate(`, `Foo::bar(`
|
|
33
|
+
re.compile(r"^\s*(?:(?:static|inline|extern|constexpr|virtual)\s+)*[A-Za-z_][\w:<>]*(?:\s*\*+\s*|\s+)\**([A-Za-z_]\w*(?:::\w+)?)\s*\("),
|
|
34
|
+
]
|
|
35
|
+
_KEYWORDS = {"return", "else", "if", "while", "for", "switch", "case", "sizeof", "new", "delete", "throw", "goto", "do"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _symbol_from_line(line: str) -> Optional[str]:
|
|
39
|
+
for pat in _DEF_PATTERNS:
|
|
40
|
+
m = pat.match(line)
|
|
41
|
+
if m:
|
|
42
|
+
groups = [g for g in m.groups() if g]
|
|
43
|
+
if any(g in _KEYWORDS for g in groups) or line.lstrip().split(" ")[0] in _KEYWORDS:
|
|
44
|
+
continue
|
|
45
|
+
return ".".join(groups)
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_symbol(lines: List[str], idx: int) -> str:
|
|
50
|
+
# forward first (marker as decorator / leading comment)
|
|
51
|
+
for j in range(idx, min(len(lines), idx + 8)):
|
|
52
|
+
s = _symbol_from_line(lines[j])
|
|
53
|
+
if s:
|
|
54
|
+
return s
|
|
55
|
+
# then backwards (marker inside body)
|
|
56
|
+
for j in range(idx - 1, max(-1, idx - 60), -1):
|
|
57
|
+
s = _symbol_from_line(lines[j])
|
|
58
|
+
if s:
|
|
59
|
+
return s
|
|
60
|
+
return ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_marker_regex(markers: List[str], id_pattern: str) -> re.Pattern:
|
|
64
|
+
verbs = "|".join(re.escape(m) for m in sorted(markers, key=len, reverse=True))
|
|
65
|
+
# verb, optional punctuation, then a span that must start with an id
|
|
66
|
+
return re.compile(
|
|
67
|
+
r"(?<![A-Za-z0-9_])@?(?P<verb>" + verbs + r")\b\s*[:=(\[]?\s*(?P<span>[\"'`]?" + id_pattern + r".*)$",
|
|
68
|
+
re.IGNORECASE,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scan_files(root: str, files: List[str], kind: str, markers: List[str], id_pattern: str) -> List[Reference]:
|
|
73
|
+
marker_re = build_marker_regex(markers, id_pattern)
|
|
74
|
+
id_re = re.compile(id_pattern)
|
|
75
|
+
refs: List[Reference] = []
|
|
76
|
+
for rel in files:
|
|
77
|
+
try:
|
|
78
|
+
text = read_text(root, rel)
|
|
79
|
+
except (OSError, UnicodeDecodeError):
|
|
80
|
+
continue
|
|
81
|
+
if "\x00" in text[:4096]: # binary
|
|
82
|
+
continue
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
for i, line in enumerate(lines):
|
|
85
|
+
for m in marker_re.finditer(line):
|
|
86
|
+
span = m.group("span")
|
|
87
|
+
# cut the span at a closing bracket / comment end to avoid grabbing trailing prose ids
|
|
88
|
+
span = re.split(r"\*/|-->|\)\s*$", span)[0]
|
|
89
|
+
ids = id_re.findall(span)
|
|
90
|
+
if not ids:
|
|
91
|
+
continue
|
|
92
|
+
symbol = _find_symbol(lines, i)
|
|
93
|
+
for rid in dict.fromkeys(ids): # unique, ordered
|
|
94
|
+
refs.append(Reference(req_id=rid, file=rel, line=i + 1, kind=kind, marker=m.group("verb").lower(), symbol=symbol))
|
|
95
|
+
return refs
|