Repository navigation
Expand file tree
/
Copy pathresolution.py
More file actions
598 lines (515 loc) · 24.2 KB
/
Copy pathresolution.py
File metadata and controls
598 lines (515 loc) · 24.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
"""Resolution of runtime frames to graph nodes.
A traced frame is identified by ``(absolute path, co_qualname, first line)``;
graph callables are identified by qualified name. The mapping strips runtime
artifacts (``<locals>`` scopes, ``@line`` duplicate markers) and falls back to
line containment when names alone are ambiguous.
"""
from __future__ import annotations
import posixpath
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
from .. import constants as cs
if TYPE_CHECKING:
from .records import FramePoint
def _repo_root_posix(repo_root: Path) -> str:
"""The resolved repo root as a POSIX prefix ending in a separator."""
return repo_root.resolve().as_posix().rstrip("/") + "/"
def _repo_relative(root_posix: str, frame_path: str) -> str | None:
"""The repo-relative POSIX path of an in-repo frame, or None if outside.
Frame paths arrive either POSIX (the pprof/V8 converters emit forward
slashes, since production build paths are POSIX) or in the host's native
form (the in-process tracers emit ``co_filename`` with ``os.sep``). Both are
normalised to POSIX before the containment check so a separator mismatch
cannot read an in-repo frame as outside the repository on Windows.
``posixpath.normpath`` collapses any ``..`` first, so a frame that walks out
of and back into the repo still resolves and one that walks out stays out.
The returned path only ever keys ``_callables_by_path`` (a table of known
in-repo node paths), so a stray relative fragment fails to match rather than
escaping anywhere. Case-insensitive drives and symlink aliases are left as
the identity match the graph itself uses, since the indexer keys nodes by
the same lexical relative paths without resolving either.
"""
frame_posix = posixpath.normpath(Path(frame_path).as_posix())
if not frame_posix.startswith(root_posix):
return None
return frame_posix[len(root_posix) :]
@dataclass(frozen=True, slots=True)
class CallableNode:
"""A graph node a traced frame may resolve to."""
label: str
qualified_name: str
path: str
start_line: int | None
end_line: int | None
@dataclass(frozen=True, slots=True)
class ResolvedFrame:
label: str
qualified_name: str
@dataclass(slots=True)
class ResolutionStats:
"""Counts of unresolvable frames, keyed by reason."""
unresolved: dict[str, int] = field(default_factory=dict)
def record(self, reason: cs.TraceUnresolvedReason) -> None:
self.unresolved[reason.value] = self.unresolved.get(reason.value, 0) + 1
@property
def total(self) -> int:
return sum(self.unresolved.values())
def _natural_qualified_name(qualified_name: str) -> str:
"""Strip the duplicate-definition marker (``qn@line`` or ``qn@line_col``)."""
base, _, suffix = qualified_name.rpartition(cs.DUP_QN_MARKER)
if not base:
return qualified_name
plain = suffix.replace(cs.DUP_QN_COLUMN_MARKER, "")
return base if plain.isdigit() else qualified_name
class FrameResolver:
"""Maps runtime frame identities of one project to graph nodes."""
def __init__(self, repo_root: Path, nodes: list[CallableNode]) -> None:
self._root_posix = _repo_root_posix(repo_root)
self._callables_by_path: dict[str, list[CallableNode]] = {}
self._modules_by_path: dict[str, CallableNode] = {}
for node in nodes:
if node.label == cs.NodeLabel.MODULE:
self._modules_by_path[node.path] = node
else:
self._callables_by_path.setdefault(node.path, []).append(node)
def resolve(
self, frame: FramePoint, stats: ResolutionStats
) -> ResolvedFrame | None:
rel_path = _repo_relative(self._root_posix, frame.path)
if rel_path is None:
stats.record(cs.TraceUnresolvedReason.OUTSIDE_REPO)
return None
parts = [
p
for p in frame.qualname.split(cs.SEPARATOR_DOT)
if p != cs.TRACE_QUALNAME_LOCALS
]
if parts == [cs.TRACE_QUALNAME_MODULE]:
module = self._modules_by_path.get(rel_path)
if module is None:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
return ResolvedFrame(
label=module.label, qualified_name=module.qualified_name
)
if any(p.startswith(cs.TRACE_SYNTHETIC_PREFIX) for p in parts):
stats.record(cs.TraceUnresolvedReason.SYNTHETIC)
return None
candidates = self._callables_by_path.get(rel_path)
if not candidates:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
suffix = cs.SEPARATOR_DOT + cs.SEPARATOR_DOT.join(parts)
by_name = [
n
for n in candidates
if _natural_qualified_name(n.qualified_name).endswith(suffix)
]
# Prefer a name match whose span contains the runtime line; among name
# matches without span data, take the first by qualified name so
# resolution stays deterministic. Only when no candidate matches by
# name does the line span alone decide.
chosen = (
self._innermost_span_containing_line(by_name, frame.line)
or (min(by_name, key=lambda n: n.qualified_name) if by_name else None)
or self._innermost_span_containing_line(candidates, frame.line)
)
if chosen is None:
stats.record(cs.TraceUnresolvedReason.NO_MATCH)
return None
return ResolvedFrame(label=chosen.label, qualified_name=chosen.qualified_name)
@staticmethod
def _innermost_span_containing_line(
candidates: list[CallableNode], line: int
) -> CallableNode | None:
return _innermost_span_containing_line(candidates, line)
def _innermost_span_containing_line(
candidates: list[CallableNode], line: int
) -> CallableNode | None:
containing = [
n
for n in candidates
if n.start_line is not None
and n.end_line is not None
and n.start_line <= line <= n.end_line
]
if not containing:
return None
# The innermost span wins: a nested function's span sits inside
# its parent's, and the runtime line points at the inner def.
return max(containing, key=lambda n: n.start_line or 0)
def _signatureless(qualified_name: str) -> str:
"""Strip a Java/C#-style trailing parameter signature, e.g. ``bar(String)``."""
if not qualified_name.endswith(")"):
return qualified_name
head, sep, _ = qualified_name.rpartition("(")
return head if sep else qualified_name
class JsFrameResolver:
"""Maps V8 profile frames of one project to graph nodes.
V8 reports bare function names, never dotted scope chains, so the
suffix-matching the Python resolver uses cannot work. Resolution anchors
on the repo-relative path, narrows to nodes whose final qualified-name
part equals the frame's name, and picks the innermost span containing
the declaration line (the converter already made V8's 0-based lines
1-based, aligning them with node start lines). Anonymous frames resolve
by span alone; module toplevels map to the file's Module node.
"""
def __init__(self, repo_root: Path, nodes: list[CallableNode]) -> None:
self._root_posix = _repo_root_posix(repo_root)
self._callables_by_path: dict[str, list[CallableNode]] = {}
self._modules_by_path: dict[str, CallableNode] = {}
for node in nodes:
if node.label == cs.NodeLabel.MODULE:
self._modules_by_path[node.path] = node
else:
self._callables_by_path.setdefault(node.path, []).append(node)
def resolve(
self, frame: FramePoint, stats: ResolutionStats
) -> ResolvedFrame | None:
rel_path = _repo_relative(self._root_posix, frame.path)
if rel_path is None:
stats.record(cs.TraceUnresolvedReason.OUTSIDE_REPO)
return None
if frame.qualname == cs.TRACE_QUALNAME_MODULE:
module = self._modules_by_path.get(rel_path)
if module is None:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
return ResolvedFrame(
label=module.label, qualified_name=module.qualified_name
)
candidates = self._callables_by_path.get(rel_path)
if not candidates:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
by_name: list[CallableNode] = []
if not frame.qualname.startswith(cs.TRACE_SYNTHETIC_PREFIX):
by_name = [
n
for n in candidates
if _natural_qualified_name(n.qualified_name).rsplit(
cs.SEPARATOR_DOT, 1
)[-1]
== frame.qualname
]
chosen = (
_innermost_span_containing_line(by_name, frame.line)
or (min(by_name, key=lambda n: n.qualified_name) if by_name else None)
or _innermost_span_containing_line(candidates, frame.line)
)
if chosen is None:
stats.record(cs.TraceUnresolvedReason.NO_MATCH)
return None
return ResolvedFrame(label=chosen.label, qualified_name=chosen.qualified_name)
_DOTNET_ARITY = re.compile(r"`\d+")
_PHP_CLOSURE = re.compile(r"^\{closure:(?P<path>.+):(?P<start>\d+)-\d+\}$")
class PhpFrameResolver:
"""Maps Xdebug frames of one project to graph nodes.
PHP qualified names are path-derived (the namespace declaration is
ignored), so resolution is span-first: frames carrying a file position
(call sites, recovered defining positions, closure names embedding
their file and lines) resolve to the innermost containing node. Leaf
callees whose defining file could not be recovered fall back to the
runtime name's ``Class.method`` tail, which drops the namespace exactly
as the graph does.
"""
def __init__(self, repo_root: Path, nodes: list[CallableNode]) -> None:
self._root_posix = _repo_root_posix(repo_root)
self._callables_by_path: dict[str, list[CallableNode]] = {}
self._modules_by_path: dict[str, CallableNode] = {}
for node in nodes:
if node.label == cs.NodeLabel.MODULE:
self._modules_by_path[node.path] = node
else:
self._callables_by_path.setdefault(node.path, []).append(node)
def resolve(
self, frame: FramePoint, stats: ResolutionStats
) -> ResolvedFrame | None:
closure = _PHP_CLOSURE.match(frame.qualname)
if closure:
return self._resolve_position(
closure.group("path"), int(closure.group("start")), stats
)
if frame.path:
if frame.qualname == cs.TRACE_XDEBUG_MAIN:
return self._resolve_module(frame.path, stats)
return self._resolve_position(frame.path, frame.line, stats)
return self._resolve_by_name_tail(frame.qualname, stats)
def _relative(self, path: str) -> str | None:
return _repo_relative(self._root_posix, path)
def _resolve_module(
self, path: str, stats: ResolutionStats
) -> ResolvedFrame | None:
rel_path = self._relative(path)
if rel_path is None:
stats.record(cs.TraceUnresolvedReason.OUTSIDE_REPO)
return None
module = self._modules_by_path.get(rel_path)
if module is None:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
return ResolvedFrame(label=module.label, qualified_name=module.qualified_name)
def _resolve_position(
self, path: str, line: int, stats: ResolutionStats
) -> ResolvedFrame | None:
rel_path = self._relative(path)
if rel_path is None:
stats.record(cs.TraceUnresolvedReason.OUTSIDE_REPO)
return None
candidates = self._callables_by_path.get(rel_path)
if not candidates:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
chosen = _innermost_span_containing_line(candidates, line)
if chosen is None:
stats.record(cs.TraceUnresolvedReason.NO_MATCH)
return None
return ResolvedFrame(label=chosen.label, qualified_name=chosen.qualified_name)
def _resolve_by_name_tail(
self, qualname: str, stats: ResolutionStats
) -> ResolvedFrame | None:
for separator in (
cs.TRACE_PHP_INSTANCE_SEPARATOR,
cs.TRACE_PHP_STATIC_SEPARATOR,
):
if separator in qualname:
owner, _, method = qualname.partition(separator)
owner = owner.rsplit(cs.TRACE_PHP_NAMESPACE_SEPARATOR, 1)[-1]
tail = f"{cs.SEPARATOR_DOT}{owner}{cs.SEPARATOR_DOT}{method}"
break
else:
plain = qualname.rsplit(cs.TRACE_PHP_NAMESPACE_SEPARATOR, 1)[-1]
tail = f"{cs.SEPARATOR_DOT}{plain}"
matches = [
node
for nodes in self._callables_by_path.values()
for node in nodes
if _natural_qualified_name(node.qualified_name).endswith(tail)
]
if not matches:
stats.record(cs.TraceUnresolvedReason.NO_MATCH)
return None
if len(matches) > 1:
# The tail dropped the namespace, so several unrelated classes
# can collide; guessing would attach the edge (and its static
# classification) to the wrong declaration.
stats.record(cs.TraceUnresolvedReason.AMBIGUOUS)
return None
chosen = matches[0]
return ResolvedFrame(label=chosen.label, qualified_name=chosen.qualified_name)
_DOTNET_STATE_MACHINE = re.compile(r"^<(\w+)>d__\d+$")
_DOTNET_LAMBDA_BODY = re.compile(r"^<(\w+)>b__\w+$")
# A C# local function compiles to ``<EnclosingHost>g__LocalName|N_M``; unlike a
# lambda it keeps a source name, so it resolves to the node the static tier
# nests under its host: a method (``Run`` -> ``Run.Local``) or an accessor body
# (``get_X``/``set_X``, which is not its own scope, so the local nests at class
# level). A constructor host (``<.ctor>``) carries a dot that the CLR-name
# splitter consumes before this pattern is reached, so ctor-hosted locals stay
# unresolved rather than mis-resolved.
_DOTNET_LOCAL_FUNCTION = re.compile(r"^<(?P<host>\w+)>g__(?P<local>\w+)\|\w+$")
_DOTNET_DISPLAY_CLASS = re.compile(r"^<>c(__DisplayClass\w*)?$")
_DOTNET_ACCESSOR = re.compile(r"^(?:get|set)_(\w+)$")
def _owner_chain(owner: str) -> tuple[list[str], str | None]:
"""The declaring type chain and any state-machine source method.
Splits nested types on ``+``, dropping display classes (lambda hosts) and
unwrapping an async state machine (``<RunAsync>d__3``) to the method name it
stands for, which supersedes the frame's own method segment.
"""
chain: list[str] = []
state_machine_method: str | None = None
for part in owner.split(cs.TRACE_DOTNET_NESTED_MARKER):
machine = _DOTNET_STATE_MACHINE.match(part)
if machine:
state_machine_method = machine.group(1)
continue
if _DOTNET_DISPLAY_CLASS.match(part):
continue
chain.append(part)
return chain, state_machine_method
def _local_function_target(host: str, local: str) -> str:
"""The dotted name of a C# local function under its host.
A plain method host keeps its name (``Run`` -> ``Run.Local``). An accessor
body (``get_X``/``set_X``) has no scope of its own, so the static tier nests
its local directly under the class; the host is dropped (``Local``).
"""
if _DOTNET_ACCESSOR.match(host):
return local
return f"{host}{cs.SEPARATOR_DOT}{local}"
def _demangle_clr_name(name: str) -> str | None:
"""CLR runtime names as dotted source names, or None for pure synthetics.
``Ns.Worker+<RunAsync>d__3.MoveNext`` names the compiler's async state
machine; the source declaration is ``Ns.Worker.RunAsync``. Display
classes host lambda bodies whose best source anchor is the enclosing
method. Constructors (``..ctor``) take the class's own name, matching
how the static tier names them; type initialisers have no source
declaration at all.
"""
# dotnet-trace keeps CLR generic arity markers (``Dictionary`2``,
# ``Method`1``); the graph stores the source spelling, so strip them.
name = _DOTNET_ARITY.sub("", name)
if name.endswith(cs.TRACE_DOTNET_CCTOR):
return None
constructor = name.endswith(cs.TRACE_DOTNET_CTOR)
if constructor:
owner = name[: -len(cs.TRACE_DOTNET_CTOR)]
method = ""
else:
owner, separator, method = name.rpartition(cs.SEPARATOR_DOT)
if not separator:
return None
chain, state_machine_method = _owner_chain(owner)
if state_machine_method is not None:
method = state_machine_method
if not chain:
return None
if constructor:
method = chain[-1].rsplit(cs.SEPARATOR_DOT, 1)[-1]
lambda_body = _DOTNET_LAMBDA_BODY.match(method)
if lambda_body:
method = lambda_body.group(1)
local_function = _DOTNET_LOCAL_FUNCTION.match(method)
if local_function:
method = _local_function_target(
local_function.group("host"), local_function.group("local")
)
if not method or "<" in method or any("<" in part for part in chain):
return None
return cs.SEPARATOR_DOT.join([*chain, method])
class DotnetFrameResolver:
"""Maps sampled .NET frames of one project to graph nodes.
dotnet-trace frames carry no file paths or lines, but C# qualified names
embed the declared namespace, so a demangled ``Namespace.Class.Method``
joins as a qualified-name suffix with the parameter signature stripped
(runtime argument types are CLR names that never match the graph's
source-text spellings, so overloads collapse onto one deterministic
node). Property accessors (``get_X``/``set_X``) fall back to the
property node when no literal match exists.
"""
def __init__(self, nodes: list[CallableNode]) -> None:
self._nodes = [n for n in nodes if n.label != cs.NodeLabel.MODULE]
def resolve(
self, frame: FramePoint, stats: ResolutionStats
) -> ResolvedFrame | None:
demangled = _demangle_clr_name(frame.qualname)
if demangled is None:
stats.record(cs.TraceUnresolvedReason.SYNTHETIC)
return None
chosen = self._match(demangled)
if chosen is None:
head, _, leaf = demangled.rpartition(cs.SEPARATOR_DOT)
accessor = _DOTNET_ACCESSOR.match(leaf)
if head and accessor:
chosen = self._match(f"{head}{cs.SEPARATOR_DOT}{accessor.group(1)}")
if chosen is None:
stats.record(cs.TraceUnresolvedReason.NO_MATCH)
return None
return ResolvedFrame(label=chosen.label, qualified_name=chosen.qualified_name)
def _match(self, demangled: str) -> CallableNode | None:
suffix = cs.SEPARATOR_DOT + demangled
matches = [
n
for n in self._nodes
if _signatureless(_natural_qualified_name(n.qualified_name)).endswith(
suffix
)
]
if not matches:
return None
return min(matches, key=lambda n: n.qualified_name)
class JvmFrameResolver:
"""Maps JVM runtime frames of one project to graph nodes.
Runtime paths are package-derived (``com/example/Foo.java``) while node
paths are repo-relative and may carry a build-tool source root
(``src/main/java/com/example/Foo.java``), so paths join by suffix. Java
node qualified names end in a raw-source parameter signature that JVM
type descriptors cannot reproduce, so names match with the signature
stripped and overloads are disambiguated by line span. Lambda bodies
(``lambda$run$0``), Scala anonymous functions, and anonymous-class
methods (``Client$1.run``) have no name-addressable node; they resolve
purely by innermost containing span.
"""
def __init__(self, nodes: list[CallableNode]) -> None:
self._callables_by_path: dict[str, list[CallableNode]] = {}
for node in nodes:
if node.label != cs.NodeLabel.MODULE:
self._callables_by_path.setdefault(node.path, []).append(node)
self._paths_by_suffix: dict[str, list[str]] = {}
def resolve(
self, frame: FramePoint, stats: ResolutionStats
) -> ResolvedFrame | None:
class_parts, method = self._split_qualname(frame.qualname)
anonymous = any(part.isdigit() for part in class_parts)
if method == cs.TRACE_JVM_STATIC_INITIALIZER or (
# An anonymous class's constructor sits at the `new` expression
# line; span resolution would fabricate a self-edge on the
# enclosing method.
anonymous and method == cs.TRACE_JVM_CONSTRUCTOR
):
stats.record(cs.TraceUnresolvedReason.SYNTHETIC)
return None
candidates = self._candidates(frame.path)
if not candidates:
stats.record(cs.TraceUnresolvedReason.UNKNOWN_PATH)
return None
by_name: list[CallableNode] = []
if not anonymous and self._name_addressable(method):
if method == cs.TRACE_JVM_CONSTRUCTOR:
method = class_parts[-1]
suffix = cs.SEPARATOR_DOT + cs.SEPARATOR_DOT.join([*class_parts, method])
by_name = [
n
for n in candidates
if _signatureless(_natural_qualified_name(n.qualified_name)).endswith(
suffix
)
]
chosen = (
_innermost_span_containing_line(by_name, frame.line)
or (min(by_name, key=lambda n: n.qualified_name) if by_name else None)
or _innermost_span_containing_line(candidates, frame.line)
)
if chosen is None:
stats.record(cs.TraceUnresolvedReason.NO_MATCH)
return None
return ResolvedFrame(label=chosen.label, qualified_name=chosen.qualified_name)
@staticmethod
def _split_qualname(qualname: str) -> tuple[list[str], str]:
"""``Outer$Inner.bar`` becomes ``([Outer, Inner], bar)``.
A trailing ``$`` (Scala object classes) produces an empty part that
is dropped; anonymous-class ordinals stay so callers can detect them.
"""
simple, _, method = qualname.rpartition(cs.SEPARATOR_DOT)
parts = [part for part in simple.split(cs.TRACE_JVM_NESTED_MARKER) if part]
return parts, method
@staticmethod
def _name_addressable(method: str) -> bool:
"""Whether the graph can hold a node under this dotted name.
Compiler-generated lambda bodies exist only at runtime; the static
tier names their code through the enclosing method, so only the line
span can find them. The same goes for anonymous classes, handled by
the caller since they are a class-chain property.
"""
return not (
method.startswith(cs.TRACE_JVM_LAMBDA_PREFIX)
or method.startswith(cs.TRACE_JVM_ANONFUN_PREFIX)
)
def _candidates(self, frame_path: str) -> list[CallableNode]:
paths = self._paths_by_suffix.get(frame_path)
if paths is None:
paths = self._resolve_paths(frame_path)
self._paths_by_suffix[frame_path] = paths
return [node for path in paths for node in self._callables_by_path[path]]
def _resolve_paths(self, frame_path: str) -> list[str]:
# An exact graph path is unambiguous. Otherwise the frame carries only a
# source-relative suffix (the JVM records the source file, not its
# root): match by suffix, but a suffix shared by two source roots cannot
# be attributed to either -- merging their callables would let line-span
# selection cross files and misattribute the call. A ceiling yields
# nothing, never a wrong link (issue #1246).
if frame_path in self._callables_by_path:
return [frame_path]
suffix = cs.SEPARATOR_SLASH + frame_path
matches = [path for path in self._callables_by_path if path.endswith(suffix)]
return matches if len(matches) == 1 else []