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 ADDED
@@ -0,0 +1,3 @@
1
+ """reqcov — requirements coverage for pull requests."""
2
+
3
+ __version__ = "0.1.0"
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