cseq 0.0.1__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.
cseq/compile_db.py ADDED
@@ -0,0 +1,98 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ from pathlib import Path
6
+ import shlex
7
+ from typing import Any
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class CompileCommand:
12
+ file: Path
13
+ directory: Path
14
+ arguments: tuple[str, ...]
15
+
16
+
17
+ class CompilationDatabase:
18
+ def __init__(self, entries: dict[Path, CompileCommand], source_path: Path) -> None:
19
+ self._entries = entries
20
+ self.source_path = source_path
21
+
22
+ @classmethod
23
+ def load(cls, path: str | Path) -> "CompilationDatabase":
24
+ p = Path(path).resolve()
25
+ raw = json.loads(p.read_text(encoding="utf-8"))
26
+ if not isinstance(raw, list):
27
+ raise ValueError("compile_commands.json must contain a JSON array")
28
+ entries: dict[Path, CompileCommand] = {}
29
+ for item in raw:
30
+ if not isinstance(item, dict) or "file" not in item:
31
+ continue
32
+ directory = Path(item.get("directory") or p.parent)
33
+ if not directory.is_absolute():
34
+ directory = (p.parent / directory).resolve()
35
+ else:
36
+ directory = directory.resolve()
37
+ file_path = Path(str(item["file"]))
38
+ if not file_path.is_absolute():
39
+ file_path = (directory / file_path).resolve()
40
+ else:
41
+ file_path = file_path.resolve()
42
+ args = item.get("arguments")
43
+ if isinstance(args, list):
44
+ tokens = [str(x) for x in args]
45
+ elif isinstance(item.get("command"), str):
46
+ tokens = shlex.split(str(item["command"]), posix=True)
47
+ else:
48
+ continue
49
+ cleaned = _clean_compile_arguments(tokens, file_path, directory)
50
+ entries[file_path] = CompileCommand(file=file_path, directory=directory, arguments=tuple(cleaned))
51
+ return cls(entries, p)
52
+
53
+ def lookup(self, file: str | Path) -> CompileCommand | None:
54
+ try:
55
+ key = Path(file).resolve()
56
+ except OSError:
57
+ key = Path(file)
58
+ return self._entries.get(key)
59
+
60
+ def __len__(self) -> int:
61
+ return len(self._entries)
62
+
63
+
64
+ def _same_path_token(token: str, file_path: Path, directory: Path) -> bool:
65
+ p = Path(token)
66
+ try:
67
+ candidate = (directory / p).resolve() if not p.is_absolute() else p.resolve()
68
+ return candidate == file_path
69
+ except OSError:
70
+ return False
71
+
72
+
73
+ def _clean_compile_arguments(tokens: list[str], file_path: Path, directory: Path) -> list[str]:
74
+ """Keep semantic compile flags while dropping build-output/dependency side effects."""
75
+ if tokens:
76
+ # The first token is normally the compiler executable.
77
+ tokens = tokens[1:]
78
+ out: list[str] = []
79
+ i = 0
80
+ takes_value_and_drop = {"-o", "-MF", "-MT", "-MQ", "-MJ"}
81
+ standalone_drop = {"-c", "-MD", "-MMD", "-MP", "-MG", "-MM", "-M", "-E", "-S"}
82
+ while i < len(tokens):
83
+ tok = tokens[i]
84
+ if tok in takes_value_and_drop:
85
+ i += 2
86
+ continue
87
+ if tok in standalone_drop:
88
+ i += 1
89
+ continue
90
+ if any(tok.startswith(prefix) and tok != prefix for prefix in ("-o", "-MF", "-MT", "-MQ", "-MJ")):
91
+ i += 1
92
+ continue
93
+ if _same_path_token(tok, file_path, directory):
94
+ i += 1
95
+ continue
96
+ out.append(tok)
97
+ i += 1
98
+ return out
cseq/config.py ADDED
@@ -0,0 +1,138 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ import tomllib
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class CpuPreprocessorRule:
10
+ when_defined: str
11
+ assign_group: str | None = None
12
+ assign_cpu: str | None = None
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class CpuPathRule:
17
+ glob: str
18
+ assign_group: str | None = None
19
+ assign_cpu: str | None = None
20
+
21
+
22
+ @dataclass(frozen=True, slots=True)
23
+ class CpuOverrideRule:
24
+ file: str
25
+ assign_group: str | None = None
26
+ assign_cpu: str | None = None
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class CseqConfig:
31
+ path: Path | None = None
32
+ cpu_groups: dict[str, tuple[str, ...]] = field(default_factory=dict)
33
+ preprocessor_rules: list[CpuPreprocessorRule] = field(default_factory=list)
34
+ path_rules: list[CpuPathRule] = field(default_factory=list)
35
+ override_rules: list[CpuOverrideRule] = field(default_factory=list)
36
+ configurations: dict[str, tuple[str, ...]] = field(default_factory=dict)
37
+ plugins_enabled: tuple[str, ...] = ()
38
+
39
+ @classmethod
40
+ def empty(cls) -> "CseqConfig":
41
+ return cls()
42
+
43
+ @classmethod
44
+ def load(cls, path: str | Path) -> "CseqConfig":
45
+ p = Path(path)
46
+ data = tomllib.loads(p.read_text(encoding="utf-8"))
47
+ cfg = cls(path=p.resolve())
48
+ for name, value in (data.get("cpu_groups") or {}).items():
49
+ cfg.cpu_groups[str(name)] = tuple(str(x) for x in (value or {}).get("members", []))
50
+ cpu_rules = data.get("cpu_rules") or {}
51
+ for r in cpu_rules.get("preprocessor", []) or []:
52
+ cfg.preprocessor_rules.append(CpuPreprocessorRule(str(r["when_defined"]), _opt(r, "assign_group"), _opt(r, "assign_cpu")))
53
+ for r in cpu_rules.get("path", []) or []:
54
+ cfg.path_rules.append(CpuPathRule(str(r["glob"]), _opt(r, "assign_group"), _opt(r, "assign_cpu")))
55
+ for r in cpu_rules.get("override", []) or []:
56
+ cfg.override_rules.append(CpuOverrideRule(str(r["file"]), _opt(r, "assign_group"), _opt(r, "assign_cpu")))
57
+ for name, value in (data.get("configurations") or {}).items():
58
+ cfg.configurations[str(name)] = tuple(str(x) for x in (value or {}).get("defines", []))
59
+ plugins = data.get("plugins") or {}
60
+ cfg.plugins_enabled = tuple(str(x) for x in (plugins.get("enabled") or []))
61
+ return cfg
62
+
63
+ def defines_for(self, configuration: str | None, extra_defines: tuple[str, ...] = ()) -> tuple[str, ...]:
64
+ merged: list[str] = []
65
+ if configuration:
66
+ if configuration not in self.configurations:
67
+ raise ValueError(f"unknown configuration: {configuration}")
68
+ merged.extend(self.configurations[configuration])
69
+ merged.extend(extra_defines)
70
+ return tuple(dict.fromkeys(merged))
71
+
72
+
73
+ def _opt(mapping: dict, key: str) -> str | None:
74
+ value = mapping.get(key)
75
+ return str(value) if value is not None else None
76
+
77
+
78
+ def validate_config(config: CseqConfig) -> list[dict[str, str]]:
79
+ """Return structured configuration diagnostics without mutating the config."""
80
+ issues: list[dict[str, str]] = []
81
+ known_groups = set(config.cpu_groups)
82
+ for kind, rules in (
83
+ ("preprocessor", config.preprocessor_rules),
84
+ ("path", config.path_rules),
85
+ ("override", config.override_rules),
86
+ ):
87
+ seen: set[tuple[str, str | None, str | None]] = set()
88
+ for rule in rules:
89
+ selector = getattr(rule, "when_defined", None) or getattr(rule, "glob", None) or getattr(rule, "file", None) or ""
90
+ key = (str(selector), getattr(rule, "assign_group", None), getattr(rule, "assign_cpu", None))
91
+ if key in seen:
92
+ issues.append({"severity": "warning", "code": "DUPLICATE_RULE", "message": f"duplicate {kind} rule: {selector}"})
93
+ seen.add(key)
94
+ group = getattr(rule, "assign_group", None)
95
+ cpu = getattr(rule, "assign_cpu", None)
96
+ if group and group not in known_groups:
97
+ issues.append({"severity": "error", "code": "UNKNOWN_CPU_GROUP", "message": f"{kind} rule references unknown group: {group}"})
98
+ if group and cpu:
99
+ issues.append({"severity": "error", "code": "AMBIGUOUS_ASSIGNMENT", "message": f"{kind} rule assigns both group and cpu: {selector}"})
100
+ if not group and not cpu:
101
+ issues.append({"severity": "error", "code": "MISSING_ASSIGNMENT", "message": f"{kind} rule has no assignment: {selector}"})
102
+ for name, members in config.cpu_groups.items():
103
+ if not members:
104
+ issues.append({"severity": "warning", "code": "EMPTY_CPU_GROUP", "message": f"CPU group has no members: {name}"})
105
+ return issues
106
+
107
+
108
+ def default_config_text(*, compile_commands_present: bool = False) -> str:
109
+ lines = [
110
+ '# cseq configuration',
111
+ '# Generated conservatively. Company-specific CPU/RTOS semantics are not guessed.',
112
+ '',
113
+ '[project]',
114
+ 'entries = ["main"]',
115
+ '',
116
+ '[parser]',
117
+ 'dialect = "auto"',
118
+ f'compile_commands = "{"auto" if compile_commands_present else "none"}"',
119
+ 'tolerant = true',
120
+ '',
121
+ '[analysis]',
122
+ 'indirect_calls = true',
123
+ '',
124
+ '[sequence]',
125
+ 'external_calls = "self"',
126
+ '',
127
+ '[html]',
128
+ 'mode = "auto"',
129
+ '',
130
+ '[plugins]',
131
+ 'enabled = []',
132
+ '',
133
+ '# Example only; uncomment and rename if needed.',
134
+ '# [cpu_groups.CPU_GROUP_A]',
135
+ '# members = ["CPU_A1", "CPU_A2"]',
136
+ '',
137
+ ]
138
+ return '\n'.join(lines)
cseq/cpu_rules.py ADDED
@@ -0,0 +1,48 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import PurePosixPath
4
+
5
+ from .config import CseqConfig
6
+ from .model import CpuEvidenceLayer, CpuEvidenceRecord, CpuValueKind, ProjectIndex
7
+
8
+
9
+ def apply_cpu_rules(index: ProjectIndex, config: CseqConfig, defined_macros: tuple[str, ...]) -> None:
10
+ if not config.path_rules and not config.override_rules and not config.preprocessor_rules:
11
+ return
12
+ defined_names = {_macro_name(x) for x in defined_macros}
13
+
14
+ for fn in index.functions():
15
+ path = fn.source_path.replace("\\", "/")
16
+ static_records: list[CpuEvidenceRecord] = []
17
+ for rule in config.path_rules:
18
+ if PurePosixPath(path).match(rule.glob):
19
+ rec = _record(fn.qualified_id, CpuEvidenceLayer.STATIC_AFFINITY, rule.assign_group, rule.assign_cpu, f"path:{rule.glob}")
20
+ if rec:
21
+ static_records.append(rec)
22
+
23
+ # An explicit file override wins *within the static-affinity layer*.
24
+ override_records: list[CpuEvidenceRecord] = []
25
+ for rule in config.override_rules:
26
+ if path == rule.file.replace("\\", "/"):
27
+ rec = _record(fn.qualified_id, CpuEvidenceLayer.STATIC_AFFINITY, rule.assign_group, rule.assign_cpu, f"override:{rule.file}", True)
28
+ if rec:
29
+ override_records.append(rec)
30
+ index.cpu_evidence.extend(override_records or static_records)
31
+
32
+ for rule in config.preprocessor_rules:
33
+ if rule.when_defined in defined_names:
34
+ rec = _record(fn.qualified_id, CpuEvidenceLayer.BUILD_DOMAIN, rule.assign_group, rule.assign_cpu, f"define:{rule.when_defined}")
35
+ if rec:
36
+ index.cpu_evidence.append(rec)
37
+
38
+
39
+ def _record(function_id: str, layer: CpuEvidenceLayer, group: str | None, cpu: str | None, provenance: str, override: bool = False) -> CpuEvidenceRecord | None:
40
+ if cpu:
41
+ return CpuEvidenceRecord(function_id, layer, CpuValueKind.CPU, cpu, provenance, override)
42
+ if group:
43
+ return CpuEvidenceRecord(function_id, layer, CpuValueKind.CPU_GROUP, group, provenance, override)
44
+ return None
45
+
46
+
47
+ def _macro_name(value: str) -> str:
48
+ return value.split("=", 1)[0]
cseq/dependencies.py ADDED
@@ -0,0 +1,202 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from hashlib import sha256
5
+ from pathlib import Path
6
+ import json
7
+ import re
8
+
9
+ from .model import TranslationUnit
10
+ from .source_index import SourceFingerprintIndex
11
+
12
+ _INCLUDE_RE = re.compile(r'^\s*#\s*include\s*([<"])([^>"]+)[>"]', re.MULTILINE)
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class IncludeDependency:
17
+ spelling: str
18
+ resolved_path: str | None
19
+ content_hash: str | None
20
+ quoted: bool = False
21
+ absolute_path: str | None = None
22
+
23
+
24
+ def _hash_file(path: Path) -> str:
25
+ h = sha256()
26
+ with path.open('rb') as f:
27
+ for chunk in iter(lambda: f.read(1024 * 1024), b''):
28
+ h.update(chunk)
29
+ return h.hexdigest()
30
+
31
+
32
+ def _include_dirs(tu: TranslationUnit) -> list[Path]:
33
+ result: list[Path] = []
34
+ args = list(tu.arguments)
35
+ i = 0
36
+ while i < len(args):
37
+ arg = args[i]
38
+ value: str | None = None
39
+ if arg in {'-I', '-isystem'} and i + 1 < len(args):
40
+ value = args[i + 1]
41
+ i += 1
42
+ elif arg.startswith('-I') and len(arg) > 2:
43
+ value = arg[2:]
44
+ elif arg.startswith('-isystem') and len(arg) > len('-isystem'):
45
+ value = arg[len('-isystem'):]
46
+ if value:
47
+ p = Path(value)
48
+ if not p.is_absolute():
49
+ p = (tu.working_directory or tu.source.path.parent) / p
50
+ result.append(p.resolve())
51
+ i += 1
52
+ return result
53
+
54
+
55
+ def _resolve_include(name: str, quoted: bool, current: Path, tu: TranslationUnit) -> Path | None:
56
+ candidates: list[Path] = []
57
+ if quoted:
58
+ candidates.append(current.parent / name)
59
+ candidates.extend(p / name for p in _include_dirs(tu))
60
+ if tu.working_directory is not None:
61
+ candidates.append(tu.working_directory / name)
62
+ for candidate in candidates:
63
+ try:
64
+ if candidate.is_file():
65
+ return candidate.resolve()
66
+ except OSError:
67
+ continue
68
+ return None
69
+
70
+
71
+ def _context_hash(tu: TranslationUnit) -> str:
72
+ payload = {
73
+ 'working_directory': str((tu.working_directory or tu.source.path.parent).resolve()),
74
+ 'arguments': list(tu.arguments),
75
+ 'configuration': tu.configuration_name,
76
+ }
77
+ return sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode('utf-8')).hexdigest()
78
+
79
+
80
+ def collect_include_dependencies(
81
+ tu: TranslationUnit,
82
+ fingerprint_index: SourceFingerprintIndex | None = None,
83
+ ) -> tuple[IncludeDependency, ...]:
84
+ """Collect reachable textual include dependencies for cache invalidation."""
85
+ visited: set[Path] = set()
86
+ records: dict[tuple[str, str | None], IncludeDependency] = {}
87
+
88
+ def visit(path: Path) -> None:
89
+ try:
90
+ real = path.resolve()
91
+ except OSError:
92
+ real = path
93
+ if real in visited:
94
+ return
95
+ visited.add(real)
96
+ try:
97
+ if real == tu.source.path.resolve() and tu.source_text is not None:
98
+ text = tu.source_text
99
+ else:
100
+ text = real.read_text(encoding='utf-8', errors='replace')
101
+ except OSError:
102
+ return
103
+ for m in _INCLUDE_RE.finditer(text):
104
+ quoted = m.group(1) == '"'
105
+ spelling = m.group(2).strip()
106
+ resolved = _resolve_include(spelling, quoted, real, tu)
107
+ if resolved is None:
108
+ rec = IncludeDependency(
109
+ spelling=spelling, resolved_path=None, content_hash=None,
110
+ quoted=quoted, absolute_path=None,
111
+ )
112
+ records[(spelling, None)] = rec
113
+ continue
114
+ try:
115
+ rel = resolved.relative_to(tu.working_directory).as_posix() if tu.working_directory else str(resolved)
116
+ except ValueError:
117
+ rel = str(resolved)
118
+ digest = fingerprint_index.content_hash(resolved) if fingerprint_index is not None else _hash_file(resolved)
119
+ rec = IncludeDependency(
120
+ spelling=spelling, resolved_path=rel, content_hash=digest,
121
+ quoted=quoted, absolute_path=str(resolved),
122
+ )
123
+ records[(spelling, rel)] = rec
124
+ visit(resolved)
125
+
126
+ visit(tu.source.path)
127
+ return tuple(sorted(records.values(), key=lambda r: (r.resolved_path or '', r.spelling)))
128
+
129
+
130
+ def _fingerprint(deps: tuple[IncludeDependency, ...]) -> str:
131
+ h = sha256()
132
+ for dep in deps:
133
+ h.update(dep.spelling.encode('utf-8', errors='surrogatepass'))
134
+ h.update(b'\0')
135
+ h.update((dep.resolved_path or '<unresolved>').encode('utf-8', errors='surrogatepass'))
136
+ h.update(b'\0')
137
+ h.update((dep.content_hash or '<missing>').encode('ascii'))
138
+ h.update(b'\n')
139
+ return h.hexdigest()
140
+
141
+
142
+ def _snapshot_records(deps: tuple[IncludeDependency, ...]) -> list[dict]:
143
+ return [
144
+ {
145
+ 'spelling': d.spelling,
146
+ 'resolved_path': d.resolved_path,
147
+ 'absolute_path': d.absolute_path,
148
+ 'content_hash': d.content_hash,
149
+ 'quoted': d.quoted,
150
+ }
151
+ for d in deps
152
+ ]
153
+
154
+
155
+ def _snapshot_valid(tu: TranslationUnit, records: list[dict], index: SourceFingerprintIndex) -> bool:
156
+ for rec in records:
157
+ spelling = str(rec.get('spelling') or '')
158
+ quoted = bool(rec.get('quoted'))
159
+ absolute = rec.get('absolute_path')
160
+ old_hash = rec.get('content_hash')
161
+ if absolute:
162
+ path = Path(str(absolute))
163
+ try:
164
+ if not path.is_file() or index.content_hash(path) != old_hash:
165
+ return False
166
+ except OSError:
167
+ return False
168
+ else:
169
+ # Missing includes are part of cache identity. If they become
170
+ # resolvable, invalidate the old snapshot without reading source.
171
+ if _resolve_include(spelling, quoted, tu.source.path, tu) is not None:
172
+ return False
173
+ return True
174
+
175
+
176
+ def include_dependency_fingerprint(
177
+ tu: TranslationUnit,
178
+ fingerprint_index: SourceFingerprintIndex | None = None,
179
+ ) -> str:
180
+ if fingerprint_index is not None:
181
+ context_hash = _context_hash(tu)
182
+ snapshot = fingerprint_index.dependency_snapshot(
183
+ tu.source.project_relative_path, context_hash, tu.source.content_hash
184
+ )
185
+ if snapshot is not None:
186
+ fingerprint, records = snapshot
187
+ if _snapshot_valid(tu, records, fingerprint_index):
188
+ fingerprint_index.note_dependency_hit()
189
+ return fingerprint
190
+ fingerprint_index.note_dependency_miss()
191
+ deps = collect_include_dependencies(tu, fingerprint_index)
192
+ fingerprint = _fingerprint(deps)
193
+ fingerprint_index.put_dependency_snapshot(
194
+ tu.source.project_relative_path,
195
+ context_hash,
196
+ tu.source.content_hash,
197
+ fingerprint,
198
+ _snapshot_records(deps),
199
+ )
200
+ fingerprint_index.commit()
201
+ return fingerprint
202
+ return _fingerprint(collect_include_dependencies(tu))
cseq/docs.py ADDED
@@ -0,0 +1,21 @@
1
+ from __future__ import annotations
2
+ from importlib import resources
3
+ from pathlib import Path
4
+ import sys
5
+ _DOCS={"readme":("README.md","README.md"),"design":("design.md","シーケンス解析_設計書.md"),"report":("verification_report.html","実装・検証成績書.html")}
6
+ def document_bytes(kind:str)->bytes:
7
+ try: resource_name,_=_DOCS[kind]
8
+ except KeyError as exc: raise ValueError(f"unknown document kind: {kind}") from exc
9
+ return resources.files("cseq").joinpath("resources",resource_name).read_bytes()
10
+ def export_document(kind:str,output:str|Path|None=None)->Path|None:
11
+ data=document_bytes(kind)
12
+ if output is None:
13
+ if hasattr(sys.stdout,"buffer"): sys.stdout.buffer.write(data)
14
+ else: sys.stdout.write(data.decode("utf-8"))
15
+ return None
16
+ out=Path(output); out.parent.mkdir(parents=True,exist_ok=True); out.write_bytes(data); return out
17
+ def export_all(output_dir:str|Path)->list[Path]:
18
+ root=Path(output_dir); root.mkdir(parents=True,exist_ok=True); out=[]
19
+ for kind,(_,filename) in _DOCS.items():
20
+ p=root/filename; export_document(kind,p); out.append(p)
21
+ return out
cseq/dwarf.py ADDED
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ import bisect
6
+ import re
7
+ import subprocess
8
+ import shutil
9
+ import os
10
+
11
+ from .model import DwarfEvidenceRecord, ProjectIndex
12
+
13
+ _LINE_RE = re.compile(r"^(.+?)\s+(\d+|-)\s+0x([0-9A-Fa-f]+)(?:\s+.*)?$")
14
+
15
+
16
+ @dataclass(frozen=True, slots=True)
17
+ class DwarfLineEntry:
18
+ file: str
19
+ line: int | None
20
+ address: int
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class DwarfLineIndex:
25
+ path: str
26
+ entries: tuple[DwarfLineEntry, ...]
27
+
28
+ def resolve_address(self, runtime_address: int, *, load_bias: int = 0) -> DwarfLineEntry | None:
29
+ address = int(runtime_address) - int(load_bias)
30
+ starts = [e.address for e in self.entries]
31
+ pos = bisect.bisect_right(starts, address) - 1
32
+ if pos < 0:
33
+ return None
34
+ entry = self.entries[pos]
35
+ if entry.line is None:
36
+ return None
37
+ if pos + 1 < len(self.entries) and address >= self.entries[pos + 1].address:
38
+ return None
39
+ return entry
40
+
41
+
42
+ def _existing_executable_path(path: str | Path) -> Path:
43
+ p = Path(path)
44
+ if p.exists():
45
+ return p
46
+ if os.name == "nt":
47
+ candidate = Path(str(p) + ".exe")
48
+ if candidate.exists():
49
+ return candidate
50
+ return p
51
+
52
+
53
+ def load_dwarf_lines(path: str | Path, *, readelf: str = "readelf") -> DwarfLineIndex:
54
+ p = _existing_executable_path(path)
55
+ is_pe = p.read_bytes()[:2] == b"MZ"
56
+ if is_pe:
57
+ tool = shutil.which("objdump") or shutil.which("llvm-objdump") or "objdump"
58
+ command = [tool, "--dwarf=decodedline", str(p)]
59
+ else:
60
+ tool = shutil.which(readelf) or (shutil.which("llvm-readelf") if readelf == "readelf" else None) or readelf
61
+ command = [tool, "--debug-dump=decodedline", str(p)]
62
+ proc = subprocess.run(
63
+ command,
64
+ stdout=subprocess.PIPE,
65
+ stderr=subprocess.PIPE,
66
+ text=True,
67
+ check=False,
68
+ )
69
+ if proc.returncode != 0:
70
+ raise RuntimeError(f"DWARF line reader failed for {p}: {proc.stderr.strip()}")
71
+ entries: list[DwarfLineEntry] = []
72
+ for raw in proc.stdout.splitlines():
73
+ m = _LINE_RE.match(raw.rstrip())
74
+ if not m:
75
+ continue
76
+ file = m.group(1).strip()
77
+ line = None if m.group(2) == "-" else int(m.group(2))
78
+ entries.append(DwarfLineEntry(file=file, line=line, address=int(m.group(3), 16)))
79
+ entries.sort(key=lambda x: x.address)
80
+ return DwarfLineIndex(str(p), tuple(entries))
81
+
82
+
83
+ def apply_dwarf_evidence(index: ProjectIndex, dwarf: DwarfLineIndex) -> list[DwarfEvidenceRecord]:
84
+ """Bind DWARF line rows to source functions only when file/range is unambiguous."""
85
+ functions = list(index.functions())
86
+ out: list[DwarfEvidenceRecord] = []
87
+ seen: set[tuple[str, int]] = set()
88
+ for entry in dwarf.entries:
89
+ if entry.line is None:
90
+ continue
91
+ basename = Path(entry.file).name
92
+ matches = [
93
+ f for f in functions
94
+ if Path(f.source_path).name == basename
95
+ and f.source_range.start_line is not None
96
+ and f.source_range.end_line is not None
97
+ and f.source_range.start_line <= entry.line <= f.source_range.end_line
98
+ ]
99
+ if len(matches) != 1:
100
+ continue
101
+ fn = matches[0]
102
+ key = (fn.qualified_id, entry.address)
103
+ if key in seen:
104
+ continue
105
+ seen.add(key)
106
+ out.append(DwarfEvidenceRecord(
107
+ function_id=fn.qualified_id,
108
+ source_path=fn.source_path,
109
+ line=entry.line,
110
+ address=entry.address,
111
+ dwarf_file=entry.file,
112
+ provenance=f"dwarf:{dwarf.path}",
113
+ ))
114
+ index.dwarf_evidence.extend(out)
115
+ return out