The missing-dependency error gives the Void xbps instruction instead of piping upstream's installer, the IBus hint points at the fork's docs (niri names its own notes), and the IBus engine homepage is the fork. VocaGateway stays upstream, with the credit stated in code and docs.
457 lines
19 KiB
Python
457 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"
|
|
FORK_SURFACE_CREDITS: dict[str, frozenset[str]] = {
|
|
"README.md": frozenset({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", 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"
|