460 lines
19 KiB
Python
460 lines
19 KiB
Python
"""Ticket #18 + #24: offline naming-sweep: no old-name strings on surfaces.
|
|
|
|
The fork takes its own name everywhere a user can see it. Anything still
|
|
spelling the old name must match a retained surface below. Each rule is
|
|
scoped to what the surface is, not to a word that happens to co-occur:
|
|
|
|
- upstream provenance: ``VocaHQ/vocalinux`` and ``jatinkrmalik/vocalinux``
|
|
slugs, and prose that labels the old name as upstream or as the fork's
|
|
origin ("Vocalinux (upstream)", "a hard fork of Vocalinux");
|
|
- the retained internal Python package (ticket #17): ``src/vocalinux`` paths,
|
|
dotted module references to modules that exist under ``src/vocalinux``,
|
|
import and ``-m`` forms, ``vocalinux/<file>.py`` module paths, and the
|
|
retained CamelCase class names (``VocalinuxEngine``, ...);
|
|
- the package name as a quoted tooling or test identifier (package lists,
|
|
mock module names, fixture trees), only in tooling and test paths;
|
|
- the one-time legacy import sources (ticket #18), only in the files that
|
|
implement or test the import, and only on lines that say so;
|
|
- pre-rename guards that name the old files they reject (ticket #28);
|
|
- generated or historical files (``uv.lock``, ``requirements/*.txt``,
|
|
``CHANGELOG.md``) and the git worktree pointer file ``.git``;
|
|
- web upstream surfaces (ticket #24): the nix flake snippets on the nixos page.
|
|
|
|
The Void release cleanup adds the fork surfaces (``FORK_SURFACES``): README,
|
|
the support and security docs, the install docs, the About page and the Void
|
|
install message. On those, upstream references are not allowlisted by pattern
|
|
at all. An upstream URL, handle or host is a regression unless the line is
|
|
exactly the one credit line recorded for that file.
|
|
|
|
Under ``src/`` the slug and label rules are not allowlisted either. Every
|
|
upstream reference in source is a recorded line (``SRC_UPSTREAM_CREDITS``),
|
|
matched exactly and once, so a new one fails the source scan.
|
|
|
|
``tests/test_rename_sweep.py`` also checks the allowlist itself, so a loosened
|
|
rule fails here before it lets an old name onto a user-visible surface.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
from collections import Counter
|
|
|
|
REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
SRC_PACKAGE = os.path.join(REPO_ROOT, "src", "vocalinux")
|
|
|
|
OLD_NAME = re.compile(r"vocalinux", re.IGNORECASE)
|
|
|
|
SKIP_DIRS = {
|
|
".git",
|
|
"__pycache__",
|
|
".venv",
|
|
"venv",
|
|
"node_modules",
|
|
".next",
|
|
"build",
|
|
"dist",
|
|
"out",
|
|
}
|
|
SKIP_SUFFIXES = (".egg-info",)
|
|
#: ``.git`` is a file in linked worktrees (``gitdir: .../worktrees/...``), so it
|
|
#: is skipped by name in addition to being a skipped directory.
|
|
SKIP_FILES = {"uv.lock", "CHANGELOG.md", ".git"}
|
|
SKIP_PREFIXES = ("requirements/",)
|
|
BINARY_EXTS = (
|
|
".wav",
|
|
".mp3",
|
|
".ogg",
|
|
".png",
|
|
".svg",
|
|
".ico",
|
|
".icns",
|
|
".bin",
|
|
".onnx",
|
|
".pt",
|
|
".zip",
|
|
".tar",
|
|
".gz",
|
|
".whl",
|
|
".AppImage",
|
|
".zsync",
|
|
".pyc",
|
|
)
|
|
|
|
#: web/ was fully rebranded in ticket #24; only upstream-provenance lines
|
|
#: (matched in _allowed) may still spell the old name there.
|
|
WEB_OWNED = {"web/src/app/page.tsx", "web/package.json"}
|
|
|
|
#: Top-level modules and packages of the retained package, read from the tree
|
|
#: so a dotted reference to a module that does not exist is not allowlisted.
|
|
RETAINED_MODULE_HEADS = frozenset(
|
|
[name[:-3] for name in os.listdir(SRC_PACKAGE) if name.endswith(".py")]
|
|
+ [
|
|
name
|
|
for name in os.listdir(SRC_PACKAGE)
|
|
if os.path.isdir(os.path.join(SRC_PACKAGE, name)) and not name.startswith("__")
|
|
]
|
|
) - {"__init__"}
|
|
|
|
#: The one upstream credit per fork surface, matched as whole stripped lines.
|
|
CREDIT_LINE = "Voculinux is a hard fork of Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
|
|
README_CREDIT_LINE = (
|
|
"Voculinux is inspired by Vocalinux by VocaHQ: https://github.com/VocaHQ/vocalinux"
|
|
)
|
|
FORK_SURFACE_CREDITS: dict[str, frozenset[str]] = {
|
|
"README.md": frozenset({README_CREDIT_LINE}),
|
|
"CONTRIBUTING.md": frozenset({CREDIT_LINE}),
|
|
"src/vocalinux/ui/settings_about.py": frozenset(
|
|
{
|
|
'UPSTREAM_REPO_URL = "https://github.com/VocaHQ/vocalinux"',
|
|
'UPSTREAM_CREDIT_LINE = f"Voculinux is a hard fork of Vocalinux by VocaHQ: {UPSTREAM_REPO_URL}"',
|
|
}
|
|
),
|
|
}
|
|
FORK_SURFACES = frozenset(
|
|
{
|
|
"README.md",
|
|
"SECURITY.md",
|
|
"SUPPORT.md",
|
|
"CONTRIBUTING.md",
|
|
"docs/UPDATE.md",
|
|
"docs/INSTALL.md",
|
|
"docs/TROUBLESHOOTING.md",
|
|
"docs/DISTRO_COMPATIBILITY.md",
|
|
"src/vocalinux/ui/settings_about.py",
|
|
"src/vocalinux/ui/settings_dialog.py",
|
|
"packaging/void/srcpkgs/voculinux/INSTALL.msg",
|
|
}
|
|
)
|
|
#: Upstream references under ``src/``, by the release checklist's pattern. Each
|
|
#: hit must be a recorded line in SRC_UPSTREAM_CREDITS, not a pattern match.
|
|
SRC_UPSTREAM_REFERENCE = re.compile(
|
|
r"github\.com/(?:VocaHQ|jatinkrmalik)|vocahq|raw\.githubusercontent", re.IGNORECASE
|
|
)
|
|
#: Every upstream reference under src/, as stripped lines per file. The scan
|
|
#: counts occurrences, so a duplicated or stale entry fails too.
|
|
SRC_UPSTREAM_CREDITS: dict[str, frozenset[str]] = {
|
|
# About-page credit (also the fork-surface credit above).
|
|
"src/vocalinux/ui/settings_about.py": FORK_SURFACE_CREDITS[
|
|
"src/vocalinux/ui/settings_about.py"
|
|
],
|
|
# Provenance record in RETAINED_SURFACES.
|
|
"src/vocalinux/app_identity.py": frozenset(
|
|
{'"https://github.com/VocaHQ/vocalinux (upstream URLs and provenance)",'}
|
|
),
|
|
# Docstring: the upstream repository is never queried.
|
|
"src/vocalinux/utils/update_checker.py": frozenset(
|
|
{"upstream VocaHQ repository is never queried, and packaged installs never query"}
|
|
),
|
|
# VocaGateway: a separate upstream project, cloned at its pinned tag.
|
|
"src/vocalinux/gateway_embed/paths_embed.py": frozenset(
|
|
{'GATEWAY_GIT_URL = "https://github.com/VocaHQ/vocagateway.git"'}
|
|
),
|
|
"src/vocalinux/gateway_embed/__init__.py": frozenset(
|
|
{"https://github.com/VocaHQ/vocagateway on the same Linux box via podman"}
|
|
),
|
|
}
|
|
#: Upstream hosts, handles and URL forms. Plain words such as "Flathub" are not
|
|
#: listed, so a sentence that says a channel is not published stays legal.
|
|
UPSTREAM_REFERENCE = re.compile(
|
|
r"VocaHQ|vocahq|jatinkrmalik|raw\.githubusercontent|discord\.gg|(?<![\w.])x\.com/"
|
|
r"|mailto:|codeberg|vocamac\.com|vocawin\.com|vocaphone\.|vocagateway\.|flathub\.org"
|
|
r"|dl\.flathub"
|
|
r"|snapcraft\.io|aur\.archlinux|pypi\.org/project/voculinux|github:|star-history"
|
|
r"|contrib\.rocks"
|
|
r"|shields\.io/(?:aur|pypi|discord|github)",
|
|
re.IGNORECASE,
|
|
)
|
|
_UPSTREAM_SLUG = re.compile(r"(?:VocaHQ|jatinkrmalik)/vocalinux", re.IGNORECASE)
|
|
_UPSTREAM_LABEL = re.compile(
|
|
r"upstream[^\n]{0,40}vocalinux|vocalinux[^\n]{0,40}upstream|fork of vocalinux",
|
|
re.IGNORECASE,
|
|
)
|
|
_DOTTED_MODULE = re.compile(
|
|
r"\bvocalinux\.(?:__\w+__|"
|
|
+ "|".join(sorted(RETAINED_MODULE_HEADS, key=len, reverse=True))
|
|
+ r")\b"
|
|
)
|
|
_IMPORT_FORM = re.compile(r"(?:^|[\s\"'])(?:from|import)\s+vocalinux\b|-m\s+vocalinux\b")
|
|
#: Prose that names the retained package as such (ticket #17's inventory).
|
|
_RETAINED_PROSE = re.compile(
|
|
r"retained[^\n]{0,60}vocalinux|vocalinux[^\n]{0,60}(?:retained|import package|module name)"
|
|
r"|(?:import package|module name)[^\n]{0,60}vocalinux",
|
|
re.IGNORECASE,
|
|
)
|
|
#: Prose that names the old install as the source of the one-time import.
|
|
_LEGACY_PROSE = re.compile(r"\b(?:legacy|previous|old) Vocalinux\b", re.IGNORECASE)
|
|
_MODULE_PATH = re.compile(
|
|
r"src/vocalinux\b"
|
|
r"|site-packages/vocalinux\b"
|
|
r"|\"src\"\s*/\s*\"vocalinux\""
|
|
r"|\bvocalinux/[\w./-]*\.py\b"
|
|
r"|\"vocalinux/"
|
|
r"|\bsite\s*/\s*\"vocalinux\""
|
|
r"|\"platform/vocalinux\""
|
|
)
|
|
_CAMEL_CLASS = re.compile(r"Vocalinux[A-Z]\w*")
|
|
#: Quoted package names in tooling and tests (package lists, mock targets,
|
|
#: ``"vocalinux."`` prefixes), and scanner regex literals in web tests.
|
|
_PACKAGE_SEGMENT = re.compile(r"[\"']vocalinux(?:[\"']|\.)|/vocalinux/[a-z]*")
|
|
_TOOLING_PREFIXES = (
|
|
"tests/",
|
|
"web/src/lib/__tests__/",
|
|
"scripts/",
|
|
"snap/local/",
|
|
".flake8",
|
|
"MANIFEST.in",
|
|
"install.sh",
|
|
"install.d/",
|
|
"uninstall.sh",
|
|
"pyproject.toml",
|
|
"justfile",
|
|
)
|
|
#: Files that implement or test the one-time legacy import (ticket #18), and the
|
|
#: pre-XDG compatibility path the custom dictionary keeps for stored settings.
|
|
_LEGACY_SOURCE_FILES = frozenset(
|
|
{
|
|
"src/vocalinux/legacy_import.py",
|
|
"src/vocalinux/app_identity.py",
|
|
"src/vocalinux/custom_dictionary.py",
|
|
"tests/test_legacy_import.py",
|
|
"tests/test_app_identity.py",
|
|
"tests/test_custom_dictionary.py",
|
|
"tests/test_rename_sweep.py",
|
|
}
|
|
)
|
|
_LEGACY_LINE = re.compile(
|
|
r"legacy|previous|pre-XDG|\.config/vocalinux|\.config\"?\s*/\s*\"vocalinux\""
|
|
r"|\.local/share/vocalinux",
|
|
re.IGNORECASE,
|
|
)
|
|
#: Pre-rename file names a packaging guard rejects (ticket #28).
|
|
_PRE_RENAME_GUARD = re.compile(r"usr/share/(?:applications|icons/[^\s\"]*)/vocalinux")
|
|
|
|
|
|
WEB_REBRAND_TEST = "web/src/lib/__tests__/voculinux-rebrand.test.ts"
|
|
WEB_REBRAND_SCANNER_LINES = frozenset(
|
|
{
|
|
"const OLD_NAME = /Vocalinux/;",
|
|
'expect(home).not.toContain("Then launch <code>vocalinux</code>");',
|
|
}
|
|
)
|
|
|
|
|
|
def _fork_surface_allowed(path: str, line: str) -> bool:
|
|
"""Fork surfaces allow the exact credit, retained module references and legacy prose."""
|
|
if line.strip() in FORK_SURFACE_CREDITS.get(path, frozenset()):
|
|
return True
|
|
if UPSTREAM_REFERENCE.search(line):
|
|
return False
|
|
return bool(
|
|
_MODULE_PATH.search(line)
|
|
or _DOTTED_MODULE.search(line)
|
|
or _IMPORT_FORM.search(line)
|
|
or _CAMEL_CLASS.search(line)
|
|
or _RETAINED_PROSE.search(line)
|
|
or _LEGACY_PROSE.search(line)
|
|
)
|
|
|
|
|
|
def _allowed(path: str, line: str) -> bool:
|
|
"""Return True when an old-name match is an allowlisted retained surface."""
|
|
if path == "tests/test_rename_sweep.py":
|
|
# This file defines the allowlist and quotes the old name to do so.
|
|
return True
|
|
if path in FORK_SURFACES:
|
|
return _fork_surface_allowed(path, line)
|
|
if path == WEB_REBRAND_TEST and line.strip() in WEB_REBRAND_SCANNER_LINES:
|
|
# The web rebrand test names the old word only to scan for it.
|
|
return True
|
|
if line.strip() in SRC_UPSTREAM_CREDITS.get(path, frozenset()):
|
|
return True
|
|
if not path.startswith("src/") and (
|
|
_UPSTREAM_SLUG.search(line) or _UPSTREAM_LABEL.search(line)
|
|
):
|
|
# Upstream provenance on tests, docs, web and packaging. Source is pinned line by line.
|
|
return True
|
|
if _MODULE_PATH.search(line) or _DOTTED_MODULE.search(line):
|
|
return True
|
|
if _IMPORT_FORM.search(line) or _CAMEL_CLASS.search(line) or _RETAINED_PROSE.search(line):
|
|
return True
|
|
if _LEGACY_PROSE.search(line):
|
|
return True
|
|
if path in _LEGACY_SOURCE_FILES and _LEGACY_LINE.search(line):
|
|
return True
|
|
if path.startswith(_TOOLING_PREFIXES) and _PACKAGE_SEGMENT.search(line):
|
|
return True
|
|
if path == "packaging/void/container-release.sh" and _PRE_RENAME_GUARD.search(line):
|
|
return True
|
|
if path == "src/vocalinux/utils/update_checker.py" and (
|
|
'casefold() == "vocalinux"' in line or '"/{name}/vocalinux"' in line
|
|
):
|
|
# Upstream repository identity the update checker recognises.
|
|
return True
|
|
return False
|
|
|
|
|
|
def _iter_files():
|
|
for root, dirs, files in os.walk(REPO_ROOT):
|
|
dirs[:] = [d for d in sorted(dirs) if d not in SKIP_DIRS]
|
|
for name in sorted(files):
|
|
if name in SKIP_FILES or name.endswith(BINARY_EXTS):
|
|
continue
|
|
full = os.path.join(root, name)
|
|
rel = os.path.relpath(full, REPO_ROOT)
|
|
if rel.startswith(SKIP_PREFIXES):
|
|
continue
|
|
yield rel, full
|
|
|
|
|
|
def test_naming_sweep_no_old_name_outside_allowlist() -> None:
|
|
"""Every old-name hit must be an allowlisted retained surface."""
|
|
offenders: list[str] = []
|
|
for rel, full in _iter_files():
|
|
if any(part.endswith(SKIP_SUFFIXES) for part in rel.split("/")):
|
|
continue
|
|
try:
|
|
with open(full, encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
except (UnicodeDecodeError, ValueError):
|
|
continue
|
|
for number, line in enumerate(text.splitlines(), 1):
|
|
if OLD_NAME.search(line) and not _allowed(rel, line):
|
|
offenders.append(f"{rel}:{number}:{line.strip()[:160]}")
|
|
assert (
|
|
offenders == []
|
|
), f"{len(offenders)} old-name string(s) outside the allowlist:\n" + "\n".join(offenders[:60])
|
|
|
|
|
|
def test_fork_surfaces_name_no_upstream_but_the_one_credit() -> None:
|
|
"""A fork surface may name upstream only on its recorded credit line."""
|
|
offenders: list[str] = []
|
|
for rel in sorted(FORK_SURFACES):
|
|
with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
credits = FORK_SURFACE_CREDITS.get(rel, frozenset())
|
|
for number, line in enumerate(text.splitlines(), 1):
|
|
if UPSTREAM_REFERENCE.search(line) and line.strip() not in credits:
|
|
offenders.append(f"{rel}:{number}:{line.strip()[:160]}")
|
|
assert offenders == [], "upstream reference outside the credit:\n" + "\n".join(offenders)
|
|
|
|
|
|
def test_fork_surfaces_carry_their_credit_exactly_once() -> None:
|
|
"""The credit is present, once, on every file that records one."""
|
|
for rel, credits in sorted(FORK_SURFACE_CREDITS.items()):
|
|
with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as handle:
|
|
lines = [line.strip() for line in handle.read().splitlines()]
|
|
for credit in credits:
|
|
assert lines.count(credit) == 1, f"{rel}: credit line must appear exactly once"
|
|
|
|
|
|
def test_src_upstream_references_are_exactly_the_recorded_credits() -> None:
|
|
"""Under src/, an upstream reference is allowed only on its recorded line, once each."""
|
|
found: Counter[tuple[str, str]] = Counter()
|
|
for rel, full in _iter_files():
|
|
if not rel.startswith("src/") or any(
|
|
part.endswith(SKIP_SUFFIXES) for part in rel.split("/")
|
|
):
|
|
continue
|
|
try:
|
|
with open(full, encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
except (UnicodeDecodeError, ValueError):
|
|
continue
|
|
for line in text.splitlines():
|
|
if SRC_UPSTREAM_REFERENCE.search(line):
|
|
found[(rel, line.strip())] += 1
|
|
expected: Counter[tuple[str, str]] = Counter(
|
|
(rel, line) for rel, lines in SRC_UPSTREAM_CREDITS.items() for line in lines
|
|
)
|
|
unrecorded = sorted(f"{rel}: {line}" for rel, line in (found - expected).elements())
|
|
missing = sorted(f"{rel}: {line}" for rel, line in (expected - found).elements())
|
|
assert not unrecorded and not missing, (
|
|
"src/ upstream references must match SRC_UPSTREAM_CREDITS exactly, once each.\n"
|
|
f"unrecorded: {unrecorded}\nmissing: {missing}"
|
|
)
|
|
|
|
|
|
def test_gateway_docs_state_vocagateway_is_upstream() -> None:
|
|
"""The gateway guide says VocaGateway is upstream's project, not Voculinux's."""
|
|
path = os.path.join(REPO_ROOT, "docs", "GATEWAY_EMBED.md")
|
|
with open(path, encoding="utf-8") as handle:
|
|
text = " ".join(handle.read().split())
|
|
sentence = (
|
|
"VocaGateway is a separate upstream project by VocaHQ, not part of Voculinux. "
|
|
"Voculinux does not publish it; the Settings action clones the pinned upstream tag."
|
|
)
|
|
assert sentence in text
|
|
|
|
|
|
def test_owned_web_files_carry_new_name() -> None:
|
|
"""The two web rename surfaces name the fork (URLs aside)."""
|
|
for rel in sorted(WEB_OWNED):
|
|
with open(os.path.join(REPO_ROOT, rel), encoding="utf-8") as handle:
|
|
text = handle.read()
|
|
assert "Voculinux" in text or "voculinux" in text, rel
|
|
|
|
|
|
def test_allowlist_denies_user_facing_old_names() -> None:
|
|
"""A user-facing old name is never allowlisted, wherever it sits."""
|
|
denied = [
|
|
("README.md", "Upstream: https://github.com/VocaHQ/vocalinux/issues"),
|
|
("README.md", "Join us on [Discord](https://discord.gg/t6muquAJbm)."),
|
|
("docs/INSTALL.md", "flatpak install vocahq com.voculinux.Voculinux"),
|
|
("SUPPORT.md", "| [Discussions](https://github.com/VocaHQ/vocalinux/discussions) |"),
|
|
("SECURITY.md", "Email hello@vocahq.com for security reports."),
|
|
("src/vocalinux/ui/settings_about.py", 'VOCAHQ_X_URL = "https://x.com/vocahq"'),
|
|
(
|
|
"src/vocalinux/ui/settings_dialog.py",
|
|
'MODEL_SELECTION_DOC_URL = "https://github.com/VocaHQ/vocalinux/x"',
|
|
),
|
|
(
|
|
"packaging/void/srcpkgs/voculinux/INSTALL.msg",
|
|
"Report bugs at github.com/VocaHQ/vocalinux",
|
|
),
|
|
("web/src/app/page.tsx", "<h1>Install vocalinux today</h1>"),
|
|
("README.md", "Launch vocalinux from your app menu."),
|
|
("docs/UPDATE.md", "Your settings live in ~/.local/share/vocalinux."),
|
|
("src/vocalinux/ui/tray_indicator.py", 'TRAY_TITLE = "vocalinux"'),
|
|
("src/vocalinux/ui/tray_indicator.py", 'ICON = "vocalinux-microphone"'),
|
|
("packaging/void/srcpkgs/voculinux/template", "pkgname=vocalinux"),
|
|
("docs/INSTALL.md", "Open vocalinux.desktop from the menu."),
|
|
(
|
|
"src/vocalinux/main.py",
|
|
'" curl -fsSL https://raw.githubusercontent.com/VocaHQ/vocalinux/main/install.sh | bash"',
|
|
),
|
|
(
|
|
"src/vocalinux/text_injection/text_injector.py",
|
|
'"For IBus setup, see: https://github.com/VocaHQ/vocalinux/wiki/IBus-Setup"',
|
|
),
|
|
(
|
|
"src/vocalinux/text_injection/ibus_engine.py",
|
|
'"homepage": "https://github.com/VocaHQ/vocalinux",',
|
|
),
|
|
]
|
|
for path, line in denied:
|
|
assert not _allowed(path, line), f"{path}: {line!r} must not be allowlisted"
|
|
|
|
|
|
def test_allowlist_keeps_the_retained_surfaces() -> None:
|
|
"""Upstream provenance, the retained package and the legacy import stay allowed."""
|
|
kept = [
|
|
("README.md", README_CREDIT_LINE),
|
|
("CONTRIBUTING.md", CREDIT_LINE),
|
|
(
|
|
"src/vocalinux/ui/settings_about.py",
|
|
'UPSTREAM_REPO_URL = "https://github.com/VocaHQ/vocalinux"',
|
|
),
|
|
("web/src/app/page.tsx", "Voculinux is a hard fork of Vocalinux for Void Linux."),
|
|
("web/src/app/install/[distro]/page.tsx", "installs Vocalinux (upstream), not Voculinux."),
|
|
("src/vocalinux/ui/tray_indicator.py", "from vocalinux.utils import paths"),
|
|
("src/vocalinux/app_identity.py", 'LEGACY_BUS_NAME: str = "com.vocalinux.Vocalinux"'),
|
|
("tests/test_app_identity.py", "from vocalinux import app_identity"),
|
|
("packaging/void/container-release.sh", 'grep -E "usr/share/applications/vocalinux"'),
|
|
("install.sh", 'local PKG_NAME="vocalinux"'),
|
|
(
|
|
"src/vocalinux/app_identity.py",
|
|
'"https://github.com/VocaHQ/vocalinux (upstream URLs and provenance)",',
|
|
),
|
|
]
|
|
for path, line in kept:
|
|
assert _allowed(path, line), f"{path}: {line!r} is a retained surface"
|