chore: guard the site against personal pronouns

This commit is contained in:
Abdessamad Derraz committed 2026-11-09 08:09:47 +01:00
1 parent a70f8bcb34
commit 78e22851dd
2 files changed
+208 -4

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
"""The site speaks without personal pronouns.
Every page is written as an instruction or a plain statement: "Open the
Start menu.", "The installer finds the BIOS folder." No I, we, you or their
forms in the wiki sources, the pages the generators write, the README and
CONTRIBUTING, llms.txt or the agent skill. Code, URLs, markup and anchor ids
are not prose.
"""
from __future__ import annotations
import tests # noqa: F401 offline, scratch under tmp/, the suite's own cache
import html
import re
import subprocess
import sys
import unittest
from functools import cache
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
DOCS = ROOT / "website" / "src" / "content" / "docs"
PRONOUN = re.compile(
r"(?<![\w-])((?-i:I)|me|my|mine|myself|we|us|our|ours|ourselves"
r"|you|your|yours|yourself|yourselves)(?![\w-])",
re.I,
)
FENCED = re.compile(r"^[ \t]*(```|~~~).*?^[ \t]*\1[^\n]*$", re.S | re.M)
INLINE_CODE = re.compile(r"(`+)(?:(?!\n\n)[^`])*?\1")
HTML_COMMENT = re.compile(r"<!--.*?-->", re.S)
SCRIPT_STYLE = re.compile(r"<(script|style|pre|code)\b.*?</\1>", re.S | re.I)
TAG = re.compile(r"</?[A-Za-z][^<>]*>")
ENTITY = re.compile(r"&#(?:\d+|x[0-9a-fA-F]+);")
URL = re.compile(r"(?:https?|ftp|smb)://\S+|\bwww\.\S+")
LINK_TARGET = re.compile(r"\]\([^)\s]*(?:\s+\"[^\"]*\")?\)|^\[[^\]]+\]:\s*\S+.*$", re.M)
ANCHOR = re.compile(r"\{[^{}\n]*#[^{}\n]*\}")
PATHLIKE = re.compile(r"\S*[/\\]\S*")
# File names, domains and dotted initials: baserom.us.z64, delete.me, A.I.R.
DOTTED = re.compile(r"[\w-]+(?:\.[\w-]+)+\.?")
# A list of single letters or digits (D, F, I, P), or of lowercase option
# values in parentheses (euro, us, asia), names keys, not people.
LETTERS = re.compile(r"(?<!\w)[A-Z0-9](?:,\s?[A-Z0-9#@^](?!\w))+")
VALUES = re.compile(r"\((?:[a-z][\w-]*, )+[a-z][\w-]*\)")
LETTER_HEADING = re.compile(r"^#+ [A-Z]\s*$", re.M)
# Search keywords, written the way readers type a query. Pagefind matches
# them, the page never shows them.
TERMS = re.compile(r"^terms: .*$", re.M)
# A capitalised "US" names the country and its region code.
COUNTRY = re.compile(r"(?<![\w-])US(?![\w-])")
# Proper names that hold one of the words.
NAMES: dict[str, str] = {
r"I(?:'|''|&#x27;|&#39;)Max": "I'Max MACS, an arcade board maker",
r"My (?:Old)?Boy!": "My Boy! and My OldBoy!, Android emulators",
r"My Vision": "Nichibutsu My Vision, a console",
r"On My iPhone": "the Files app location on iOS",
r"Us vs Them": "a game title",
r"Aliens Ate My Railway|Fell Apart On Me": "OpenTTD music track titles",
r"\b(?:Model|Level|Series|Mk|MK|Ultima|Interface|Type) I\b(?!')":
"a roman numeral in a machine or game name (Model I, Ultima I)",
r"\bI(?= \([A-Z])": "a model letter, I (Palladium)",
r"Java ME": "Java ME, the mobile Java platform",
r"laseractive us(?=:)": "the LaserActive region key of geargrafx's BIOS list",
}
# Program output quoted word for word. INSTALLER holds install.py's own
# messages, which the pass over the installer's wording rewrites, and empties
# this list with it. SOURCE holds what an emulator prints, quoted in its
# profile from the emulator's code, which this project does not write.
INSTALLER: dict[str, str] = {
"Where do you play?": "title of the installer's first screen",
"Copy the BIOS files from your old card or a folder": "installer menu row",
"copy the BIOS files from your other card": "installer menu row on a two-card handheld",
"if your files go there.": "installer hint for a card with no handheld system folder",
"See [Files you already had]": (
"link label in the generated options table of wiki/installer.md, "
"kept by generate_installer_doc"),
}
SOURCE: dict[str, str] = {
"You do not have a ROM image installed for Model": "sdltrs message, emulators/sdl2trs.yml",
"You might not get sound because": "lindbergh-loader message, emulators/lindbergh-loader.yml",
'"Your Card"': "KEGS disk name, emulators/kegs.yml",
"How do I use SC3000": "MG.html question title, emulators/mastergear.yml",
"How do I use SF7000 disk images?": "MG.html question title, emulators/mastergear.yml",
"There is no version of MG for my Unix!": "MG.html question title, emulators/mastergear.yml",
"rename your own SFX dat file to this name": "Zelda Classic README, emulators/zc210.yml",
"Could not load ROM images! You must supply these.":
"hypseus message, emulators/hypseus-singe.yml",
"Please copy pak0.pk3 from the 'etmain' path of your":
"ET: Legacy message, emulators/etlegacy.yml",
'"keyconf.dat(us)"': "file name px68k ships, emulators/px68k-sdl.yml",
}
_SPACED = [
re.compile(r"\s+".join(map(re.escape, phrase.split())))
for phrase in (*INSTALLER, *SOURCE)
]
_NAMES = [re.compile(pattern) for pattern in NAMES]
def _blank(match: re.Match[str]) -> str:
return re.sub(r"[^\n]", " ", match[0])
def prose(text: str) -> str:
"""The text of a page with everything that is not prose blanked."""
for pattern in (HTML_COMMENT, SCRIPT_STYLE, TAG):
text = pattern.sub(_blank, text)
# &#95; and &#x27; spell a file name or a name: read them as characters.
text = ENTITY.sub(lambda m: html.unescape(m[0]), text)
for pattern in (
*_SPACED, FENCED, INLINE_CODE, LINK_TARGET, URL, ANCHOR, PATHLIKE,
TERMS, *_NAMES, DOTTED, LETTERS, VALUES, LETTER_HEADING, COUNTRY,
):
text = pattern.sub(_blank, text)
return text
def pronouns(text: str) -> list[tuple[int, str]]:
"""(line number, line) for each prose line holding a personal pronoun."""
lines = text.splitlines()
return [
(number, lines[number - 1].strip())
for number, line in enumerate(prose(text).splitlines(), 1)
if PRONOUN.search(line)
]
def _hits(paths: list[Path]) -> list[str]:
return [
f"{path.relative_to(ROOT)}:{number}: {line}"
for path in paths
for number, line in pronouns(path.read_text(encoding="utf-8"))
]
@cache
def _generated() -> bool:
result = subprocess.run(
[sys.executable, str(ROOT / "scripts" / "generate_site.py")],
cwd=ROOT, capture_output=True, text=True, check=False,
)
return result.returncode == 0
class Scanner(unittest.TestCase):
def test_code_urls_and_anchors_are_not_prose(self):
text = (
"Run `install.py --help` first.\n"
"```\nyou are here\n```\n"
"See https://example.org/your/page and [a link](our-page.md).\n"
"## Get the files { #get-your-files }\n"
'<a title="your title">Open the menu</a>\n'
"A US BIOS, Quake II I mean.\n"
)
self.assertEqual(pronouns(text), [(8, "A US BIOS, Quake II I mean.")])
def test_each_form_is_found_in_any_case(self):
for word in ("I", "me", "My", "mine", "we", "Us", "OUR", "you", "Yours",
"yourselves", "you're", "I'm"):
with self.subTest(word=word):
self.assertTrue(pronouns(f"Then {word} copy it."))
def test_a_word_holding_the_letters_passes(self):
self.assertEqual(pronouns("Usage of the menu, mine-craft, youth."), [])
class SitePronouns(unittest.TestCase):
def test_wiki_sources(self):
hits = _hits(sorted((ROOT / "wiki").glob("*.md")))
self.assertEqual(hits, [], "\n".join(hits))
def test_readme_and_contributing(self):
hits = _hits([ROOT / "README.md", ROOT / "CONTRIBUTING.md"])
self.assertEqual(hits, [], "\n".join(hits))
def test_generated_pages(self):
if not _generated():
self.fail("scripts/generate_site.py failed")
pages = sorted(p for p in DOCS.rglob("*") if p.suffix in (".md", ".mdx"))
self.assertTrue(pages)
hits = _hits(pages)
self.assertEqual(hits, [], "\n".join(hits[:200]))
def test_agent_files(self):
if not _generated():
self.fail("scripts/generate_site.py failed")
public = ROOT / "website" / "public"
files = [public / "llms.txt", *sorted((public / "skills").rglob("*.md"))]
self.assertTrue(all(path.is_file() for path in files))
hits = _hits(files)
self.assertEqual(hits, [], "\n".join(hits))
def test_every_allowed_quote_says_why(self):
for quoted, reason in {**NAMES, **INSTALLER, **SOURCE}.items():
with self.subTest(quoted=quoted):
self.assertTrue(reason.strip())
if __name__ == "__main__":
unittest.main()
+4 -4
View File
@@ -69,7 +69,7 @@ Every module in `tests/`, by the area it guards.
| Pack probe | the cores a platform runs for a system, staging grown to a fixed point, runs mapped to pack and manifest entries, the line and its schema, the probe lock per run, that no page shows its lines. One class builds a real pack narrowed to PlayStation and lays it out both ways, from the archive and with `install.py --dest`, and skips without the collection | `test_pack_probe` |
| Automation runner, disk cleanup, asset sync and bit-flip repair | fake jobs, git repositories with a bare origin and session worktrees, a committed file with one bit flipped, canned CI runs in place of `gh` | `test_autorun` |
| Truth and native export | synthetic + cached originals | `test_export_counts`, `test_export_native_layer`, `test_export_report`, `test_exporters`, `test_truth_data_dir_readers`, `test_truth_diff`, `test_truth_layout` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_steps`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Scrapers, upstream and provenance | inline sources, synthetic | `test_batocera_md5_list`, `test_check_freshness`, `test_cited_lines`, `test_contributing_text`, `test_coverage_cache`, `test_data_dir_refs`, `test_dataclass_defaults`, `test_dedup`, `test_doc_pages`, `test_doc_surfaces`, `test_elec_scrapers`, `test_fbneo_parser`, `test_find_pinned_revision`, `test_hash_merge`, `test_launchers`, `test_launchers_emudeck`, `test_launchers_recalbox`, `test_launchers_retrobat`, `test_launchers_retrodeck`, `test_launchers_retropie`, `test_launchers_rocknix`, `test_mame_devices`, `test_mame_parser`, `test_mame_ref_audit`, `test_optional_dependencies`, `test_pin_build`, `test_profile_sync`, `test_prose_style`, `test_provenance`, `test_refresh_data_dirs`, `test_refresh_failures`, `test_refresh_stale`, `test_retrodeck_scraper_errors`, `test_retired_files`, `test_romset_recipes`, `test_schema_semantic_gate`, `test_scraper_contract`, `test_site_agents`, `test_site_arcade_boards`, `test_site_availability`, `test_site_bootstrap`, `test_site_built_gate`, `test_site_components`, `test_site_console_folders`, `test_site_content_data`, `test_site_copy`, `test_site_cores`, `test_site_css`, `test_site_demo`, `test_site_density_cards`, `test_site_emulator_page`, `test_site_emulators`, `test_site_entry`, `test_site_entry_tables`, `test_site_exports`, `test_site_funnel`, `test_site_images`, `test_site_inputs`, `test_site_insights`, `test_site_integration`, `test_site_nav`, `test_site_picker`, `test_site_platform_density`, `test_site_platform_hashes`, `test_site_privacy`, `test_site_pronouns`, `test_site_reference`, `test_site_render`, `test_site_search`, `test_site_steps`, `test_site_system_links`, `test_site_system_names`, `test_site_system_sections`, `test_site_systems`, `test_site_validation`, `test_site_weight`, `test_site_wiki`, `test_site_write`, `test_source_ref_targets`, `test_upstream`, `test_upstream_index`, `test_validate_pr`, `test_validate_pr_flow`, `test_validate_pr_hashes`, `test_version_basis`, `test_workflow_paths` |
| Games on the card | small DATs, listxml and setup sources, a fake fetcher, fixture install lists, and the committed data against its schemas | `test_arcade_parents`, `test_game_catalogs`, `test_game_headers`, `test_game_regions`, `test_games_index`, `test_romfolders_card`, `test_romfolders_es` |
## Test architecture
@@ -219,7 +219,7 @@ disambiguated by a hash suffix on purpose.
## How to add a test
1. **Pick the right category.** Find the number range that matches the
subsystem you are testing. If none fits, start a new range after the last
subsystem under test. If none fits, start a new range after the last
existing one.
2. **Create synthetic fixtures.** Write the minimum YAML configs and fake
@@ -235,7 +235,7 @@ disambiguated by a hash suffix on purpose.
method, file counts, or pack contents. Avoid brittle assertions on log
output or formatting.
5. **Run the full suite.** After adding your test, run `python -m unittest
5. **Run the full suite.** After adding the test, run `python -m unittest
discover -s tests -t . -v` to verify nothing else broke.
A new module starts with `import tests` right after
@@ -497,7 +497,7 @@ chmod +x .git/hooks/commit-msg
```
Files are staged by name, never with `git add -A` or `git add .`, which sweep in
deletions and work that is not yours. Generated artifacts (`database.json`,
deletions and other people's work. Generated artifacts (`database.json`,
`retired.json`, `derivable.json`, `install/*.json`, `README.md`, `CONTRIBUTING.md`, `exports/`,
the generated tables and run samples of `wiki/installer.md`) are committed
with the data they derive from, never alone: a manifest committed before the