User-facing docs, About page and website now point at the fork and the Void xbps repository, with one credit line to upstream Vocalinux. Channels this fork does not publish are removed from navigation.
364 lines
15 KiB
Python
364 lines
15 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.
|
|
|
|
``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
|
|
|
|
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 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 _UPSTREAM_SLUG.search(line) or _UPSTREAM_LABEL.search(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_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."),
|
|
]
|
|
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"'),
|
|
]
|
|
for path, line in kept:
|
|
assert _allowed(path, line), f"{path}: {line!r} is a retained surface"
|