"""The PKGBUILD audit screen: what changed, why it matters, and the diff itself.
The semantic summary comes first because a line diff answers the wrong question.
"pkgver 1.19 -> 1.19, source +1 -1, sums +1 -1" tells you what kind of change it
was; the rendered diff underneath tells you whether you agree with it.
"""
from __future__ import annotations
import difflib
from rich.text import Text
from ..audit import Report, Severity
from ..icons import Glyphs
from ..theme import Theme
from .base import Columns, Screen, truncate
SEVERITY_ROLE = {Severity.INFO: "muted", Severity.LOOK: "warn", Severity.BLOCK: "alert"}
def summary_parts(report: Report) -> list[tuple[str, str]]:
"""(text, role) pairs describing the change in PKGBUILD terms."""
d = report.diff
parts: list[tuple[str, str]] = []
def field(label: str, value: str, role: str = "text") -> None:
parts.append((f"{label} {value}", role))
if d.pkgver:
field("pkgver", f"{d.pkgver[0]} → {d.pkgver[1]}", "new")
if d.pkgrel:
field("pkgrel", f"{d.pkgrel[0]} → {d.pkgrel[1]}", "new")
if d.epoch:
field("epoch", f"{d.epoch[0]} → {d.epoch[1]}", "warn")
if d.source:
field("source", d.source.summary(), "warn")
if d.sums:
field("sums", d.sums.summary(), "warn")
if d.depends:
field("depends", d.depends.summary(), "warn")
if d.makedepends:
field("makedepends", d.makedepends.summary(), "warn")
if d.provides:
field("provides", d.provides.summary(), "warn")
if d.validpgpkeys:
field("pgpkeys", d.validpgpkeys.summary(), "alert")
if d.funcs_added:
field("functions added", ", ".join(d.funcs_added), "warn")
if d.funcs_changed:
field("functions changed", ", ".join(d.funcs_changed), "text")
if d.lines_added or d.lines_removed:
field("PKGBUILD", f"+{d.lines_added} -{d.lines_removed}", "text")
if not parts:
parts.append(("no metadata changed", "muted"))
return parts
def diff_lines(old: str, new: str, *, context: int = 3) -> list[tuple[str, str, str]]:
"""(marker, line_number, text) for a unified diff of two PKGBUILD texts."""
out: list[tuple[str, str, str]] = []
old_lines = old.splitlines()
new_lines = new.splitlines()
matcher = difflib.SequenceMatcher(None, old_lines, new_lines)
for group in matcher.get_grouped_opcodes(context):
if out:
out.append((" ", "", ""))
for tag, i1, i2, j1, j2 in group:
if tag == "equal":
for offset, line in enumerate(old_lines[i1:i2]):
out.append((" ", str(i1 + offset + 1), line))
else:
for offset, line in enumerate(old_lines[i1:i2]):
out.append(("-", str(i1 + offset + 1), line))
for offset, line in enumerate(new_lines[j1:j2]):
out.append(("+", str(j1 + offset + 1), line))
return out
def build(
report: Report,
*,
theme: Theme,
glyphs: Glyphs,
width: int = 100,
title: str = "",
old_text: str = "",
new_text: str = "",
limit: int = 40,
baseline: bool = True,
into: Screen | None = None,
) -> list[Text]:
screen = into or Screen(theme=theme, glyphs=glyphs, columns=Columns.for_width(width))
cols = screen.columns
screen.header(title or f"{report.package} · AUR")
role_of = SEVERITY_ROLE
loud = len(report.loud)
screen.section(
"Findings",
f"{len(report.findings)} item(s)"
+ (f" · {loud} need a look" if loud else " · nothing unusual"),
)
for finding in report.findings:
role = role_of[finding.severity]
row = screen.row()
row.span(f"{finding.severity.glyph:<2}", getattr(theme, role))
row.span(" ")
row.span(
truncate(finding.title, cols.ver - row.at - 2),
getattr(theme, role) if finding.severity is not Severity.INFO else theme.muted,
)
if finding.where:
row.right(truncate(finding.where, 20), theme.muted)
screen.finish(row)
if finding.evidence:
line = screen.row(indent=cols.indent + 4)
line.span(truncate(finding.evidence, cols.right - line.at), theme.ver)
screen.finish(line)
screen.section("Change", "parsed statically · PKGBUILD never sourced")
if not baseline:
# an all-additions diff would be technically true and completely useless:
# it would look like the whole PKGBUILD was rewritten when in fact we have
# simply never recorded a version to compare against
screen.add(
screen.row().span("no baseline recorded for this package yet", theme.muted).done()
)
screen.add(
screen.row()
.span("it will be recorded after the next successful build", theme.muted)
.done()
)
parts = summary_parts(report) if baseline else []
row = screen.row()
for text, role in parts:
chunk = text + " "
if row.at + len(chunk) > cols.right:
screen.finish(row)
row = screen.row()
row.span(chunk, getattr(theme, role) if hasattr(theme, role) else theme.text)
screen.finish(row)
if baseline and (old_text or new_text):
lines = diff_lines(old_text, new_text)
shown = lines[:limit]
screen.section(
"Diff", f"PKGBUILD · +{report.diff.lines_added} -{report.diff.lines_removed}"
)
for marker, number, body in shown:
role = {"+": "ok", "-": "err"}.get(marker, "muted")
line = screen.row()
line.span(marker, getattr(theme, role))
line.span(" ")
line.span(f"{number:>4}", theme.muted)
line.span(" ")
line.span(truncate(body, cols.right - line.at), getattr(theme, role))
screen.finish(line)
if len(lines) > len(shown):
screen.add(
screen.row()
.span(f"+ {len(lines) - len(shown)} more diff lines", theme.muted)
.done()
)
elif new_text:
# show it as a reference listing, with no +/- implying a comparison
body_lines = new_text.splitlines()
shown = body_lines[:limit]
screen.section("PKGBUILD", f"{len(body_lines)} lines · shown as reference")
for offset, body in enumerate(shown, start=1):
line = screen.row()
line.span(" ", theme.rule)
line.span(f"{offset:>4}", theme.muted)
line.span(" ")
line.span(truncate(body, cols.right - line.at), theme.ver)
screen.finish(line)
if len(body_lines) > len(shown):
screen.add(
screen.row()
.span(f"+ {len(body_lines) - len(shown)} more lines", theme.muted)
.done()
)
screen.hint("", "[o] open in delta [a] accept [s] skip [q] abort")
return screen.render()