fix: read a source_ref role per part, prose too

The role a part writes (source or upstream) was kept per path, first
met first kept, so a later part naming the other role was read in the
other repository. In prose the word source made a citation external
and the word upstream was dropped. The role now travels on each part.
This commit is contained in:
Abdessamad Derraz committed 2026-10-21 05:14:47 +02:00
1 parent 348c56e94c
commit 64b2967ffb
4 files changed
+201 -27

No files matched your search

+75 -25
View File
@@ -1305,12 +1305,22 @@ def _resolve_project_words(
if part.path not in resolved:
resolved[part.path] = resolve_project_word(part.path, tree_order())
if resolved[part.path]:
part = RefPart(resolved[part.path], part.start, part.end, part.raw)
part = replace(part, path=resolved[part.path])
rewritten.append(part)
out.append((display, ref, tokens, hashes, rewritten, citation))
return out
def _path_roles(refs: list) -> dict[str, set[str]]:
"""The roles each cited path is written under across the profile."""
roles: dict[str, set[str]] = {}
for *_, parts, _citation in refs:
for part in parts:
if part.role:
roles.setdefault(part.path, set()).add(part.role)
return roles
def build_report(
name: str, profile: dict, cache_dir: str, offline: bool = False,
text: str | None = None,
@@ -1328,20 +1338,20 @@ def build_report(
if (repo := upstream.parse_repo(url)) is not None
)
refs = []
# The repository a part says its lines are in (`upstream src/a.c:5`).
cited_roles: dict[str, str] = {}
for citation in collect_citations(profile, text):
if citation.kind == "ref":
for p in split_source_ref(citation.ref):
role, _bare = ref_role(p.path)
if role:
cited_roles.setdefault(strip_repo_word(p.path, known), role)
entry = citation.entry
display = str(entry.get("name") or "") or citation.field
if citation.label:
display += f" [{citation.label}]"
# The repository a part says its lines are in (`upstream
# src/a.c:5`) stays on that part: the same path can be cited
# under both roles in one profile.
parts = [
RefPart(strip_repo_word(p.path, known), p.start, p.end, p.raw)
RefPart(
strip_repo_word(p.path, known), p.start, p.end, p.raw,
ref_role(p.path)[0],
)
for p in split_source_ref(citation.ref)
]
refs.append((
@@ -1353,6 +1363,19 @@ def build_report(
citation.field, citation.ref, [], [],
list(citation.parts), citation,
))
# A part that names no repository is looked up first where its path is
# cited elsewhere: a note citing `src/a.c:12` beside source_refs that
# all read `upstream src/a.c` means that tree.
# A path cited under both roles lends neither, and such a part is
# compared in every repository rather than in whichever comes first.
path_roles = _path_roles(refs)
def role_of(part: RefPart) -> str:
if part.role:
return part.role
lent = path_roles.get(part.path, ())
return next(iter(lent)) if len(lent) == 1 else ""
# Directories the profile already cites, deepest first, to settle a bare
# filename that several trees of one repository carry.
cited_dirs: set[str] = set()
@@ -1448,7 +1471,7 @@ def build_report(
pin_trees: dict[str, list[str] | None] = {}
def _locate(
path: str, start: int | None = None, tokens: tuple = ()
path: str, start: int | None = None, tokens: tuple = (), wanted: str = ""
) -> tuple[RepoView, str]:
"""Repository and real path carrying a cited path, HEAD before pin.
@@ -1539,7 +1562,6 @@ def build_report(
return 1 if line.strip() else 0
# A part that names its repository is looked up there first.
wanted = cited_roles.get(path, "")
ordered = sorted(views, key=lambda v: _view_role(v) != wanted) if wanted else views
best = None
for candidate in candidates:
@@ -1572,11 +1594,11 @@ def build_report(
return (best[1], best[2]) if best else (primary, path)
def resolve_path(
path: str, start: int | None = None, tokens: tuple = ()
path: str, start: int | None = None, tokens: tuple = (), role: str = ""
) -> tuple[RepoView, str]:
key = (path, start, tokens)
key = (path, start, tokens, role)
if key not in owners:
owners[key] = _locate(path, start, tokens)
owners[key] = _locate(path, start, tokens, role)
return owners[key]
def _pin_tree_for(view: RepoView) -> list[str] | None:
@@ -1634,14 +1656,14 @@ def build_report(
)
return context[key]
def rename_getter(path: str) -> tuple[str | None, list[str]]:
def rename_getter(path: str, role: str = "") -> tuple[str | None, list[str]]:
"""Resolved only when a cited file has vanished, then memoised.
Every declared repository is searched, not just the one that owns the
path: a profile citing both a port and its upstream may point at a file
that only the other one carries.
"""
owner, actual = resolve_path(path)
owner, actual = resolve_path(path, role=role)
ordered = [owner] + [v for v in views if v is not owner]
candidates: list[str] = []
for view in ordered:
@@ -1673,6 +1695,7 @@ def build_report(
start: int | None = None,
tokens: Iterable[str] = (),
forced: RepoView | None = None,
role: str = "",
) -> list[str] | None:
"""Lines for one path at one revision, read once per run.
@@ -1680,10 +1703,11 @@ def build_report(
on identity.
"""
tokens = tuple(tokens)
key = (which, path, start, tokens, forced and forced.repo.slug)
key = (which, path, start, tokens, forced and forced.repo.slug, role)
if key not in lines_cache:
view, actual = (
(forced, path) if forced else resolve_path(path, start, tokens)
(forced, path) if forced
else resolve_path(path, start, tokens, role)
)
sha = view.pin if which == PIN else view.head
found = fetch_from(view.repo, sha, actual)
@@ -1701,9 +1725,11 @@ def build_report(
start: int | None = None,
tokens: Iterable[str] = (),
forced: RepoView | None = None,
role: str = "",
) -> tuple[str | None, str, str]:
view, actual = (
(forced, path) if forced else resolve_path(path, start, tuple(tokens))
(forced, path) if forced
else resolve_path(path, start, tuple(tokens), role)
)
slug = view.repo.slug if view is not primary else None
return slug, upstream.raw_url(view.repo, view.head, actual), actual
@@ -1715,12 +1741,20 @@ def build_report(
both mamedev/mame and its libretro fork hold, with the same delimited
token on the cited line, so no single attribution can be right for all
of them. Taking the best outcome across repositories settles it.
A part that names its repository is judged there alone when that
repository carries the path: hypseus-singe cites lair.cpp under
`source` for one entry and `upstream` for the next.
"""
owner, _actual = resolve_path(part.path, part.start, tuple(tokens))
owner, _actual = resolve_path(
part.path, part.start, tuple(tokens), role_of(part)
)
carrying = [
view for view in views
if fetch_from(view.repo, view.pin, part.path) is not None
]
named = [view for view in carrying if _view_role(view) == part.role]
if part.role and named:
carrying = named
live = {
id(view) for view in carrying
if fetch_from(view.repo, view.head, part.path) is not None
@@ -1732,7 +1766,7 @@ def build_report(
lambda which, path, start=None, toks=(), _v=view: fetch(
which, path, start, toks, _v
),
rename_getter,
lambda path: rename_getter(path, role_of(part)),
lambda path, start=None, toks=(), _v=view: describe(
path, start, toks, _v
),
@@ -1753,7 +1787,15 @@ def build_report(
if best is None or rank < best[0] or (rank == best[0] and view is owner):
best = (rank, result)
return best[1] if best else anchor_part(
part, fetch, rename_getter, describe, tokens
part,
lambda which, path, start=None, toks=(): fetch(
which, path, start, toks, role=role_of(part)
),
lambda path: rename_getter(path, role_of(part)),
lambda path, start=None, toks=(): describe(
path, start, toks, role=role_of(part)
),
tokens,
)
# Comparing a revision with itself anchors every ref whatever it cites, so
@@ -1763,7 +1805,9 @@ def build_report(
def self_checked(part: RefPart, tokens: Sequence[str]) -> bool:
if report.pinned_tag:
return True
view, _ = resolve_path(part.path, part.start, tuple(tokens))
if not part.role and len(path_roles.get(part.path, ())) > 1:
return all(view.pin == view.head for view in views)
view, _ = resolve_path(part.path, part.start, tuple(tokens), role_of(part))
return view.pin == view.head
def judge(
@@ -1779,11 +1823,15 @@ def build_report(
)
if flag:
result = verify_at_pin(
part, fetch(PIN, part.path, part.start, tokens), tokens, hashes
part,
fetch(PIN, part.path, part.start, tokens, role=role_of(part)),
tokens, hashes,
)
# Checked at its own revision, the part still lies in one tree:
# the report names it, as a comparison does.
owner, _actual = resolve_path(part.path, part.start, tuple(tokens))
owner, _actual = resolve_path(
part.path, part.start, tuple(tokens), role_of(part)
)
if result.repo is None and owner is not primary:
result = replace(result, repo=owner.repo.slug)
else:
@@ -1791,7 +1839,9 @@ def build_report(
# A forge that refused the request said nothing about the file:
# git.eden-emu.dev answers 403 to scripts, and nine citations of
# eden-nightly read "absent at HEAD" for a file that exists.
owner, _actual = resolve_path(part.path, part.start, tuple(tokens))
owner, _actual = resolve_path(
part.path, part.start, tuple(tokens), role_of(part)
)
if (
result.status in ("GONE", "AMBIGUOUS")
and (owner.repo.host, owner.repo.slug) in mute
+4
View File
@@ -261,6 +261,10 @@ def link_prose_citations(profile: dict, text: str) -> str:
continue
if "/" not in path and path not in structured:
continue
role = parts[0].role
if role and (role_sources(profile).get(role) or (None,))[0] != repo:
# `upstream a.c:5` names a repository this link would not reach.
continue
if any(lo <= path_span[0] < hi for lo, hi in guarded):
continue
for index, ((lo, hi), part) in enumerate(zip(spans, parts)):
+20 -2
View File
@@ -39,6 +39,10 @@ class RefPart:
start: int | None
end: int | None
raw: str = ""
# The repository the part names (`upstream src/a.c:5`), empty when it
# names none. Carried per part: one profile cites the same path in both
# repositories, hypseus-singe's lair.cpp under `source` and `upstream`.
role: str = ""
@property
def last(self) -> int | None:
@@ -247,6 +251,19 @@ def _prose_external(
return not lead or lead[-1] in _PROSE_LIST_DELIMITERS
def _prose_role(line: str, path_start: int) -> str:
"""The role word written right before a prose path, empty when none.
Prose follows the source_ref convention: `(upstream retro_host.c:196)`
names the original emulator, `ref: source src/devices/floppy.cpp:1656`
the repository declared as source. Read as a linking word or a project
name, the word was dropped or made the citation external.
"""
before = line[:path_start].rstrip()
word = before[max(before.rfind(" "), before.rfind("\t"), before.rfind("(")) + 1:]
return word if word in REPO_ROLES else ""
def resolve_project_word(path: str, trees: Iterable[list[str] | None]) -> str | None:
"""Path of `project file` inside a declared tree, None when out of reach.
@@ -333,7 +350,8 @@ def _prose_runs(
):
path_start -= 1
path = "." + path
if _prose_external(line, path_start, known):
role = _prose_role(line, path_start)
if not role and _prose_external(line, path_start, known):
# Kept with its project word, the source_ref convention: the
# report looks for the project inside the declared trees
# (mupen64plus-ae vendors `rice Config.cpp`) and calls the
@@ -358,7 +376,7 @@ def _prose_runs(
for lo, hi in spans:
rng = line[lo:hi]
a, _, b = rng.partition("-")
parts.append(RefPart(path, int(a), int(b or a), rng))
parts.append(RefPart(path, int(a), int(b or a), rng, role))
yield (
number, path_span, tuple(spans), tuple(parts),
line[path_start:end], end > boundary,