|
| 1 | +"""A library does not name its consumers' internals. |
| 2 | +
|
| 3 | +kirby-combat, kirby-cost, kirby-dice and kirby-terrain are standalone |
| 4 | +packages. A comment here that names a module in a consuming application -- |
| 5 | +or worse, cites line numbers inside it -- is a leaky abstraction and a |
| 6 | +guaranteed source of rot: those offsets were pinned against a file in |
| 7 | +another repository that no CI here can check, and they were wrong within |
| 8 | +weeks. Describe the ROLE ("the consumer's driver", "the chooser") and the |
| 9 | +comment stays true for as long as the role does. |
| 10 | +
|
| 11 | +The same rule covers vendor and product names. This package models |
| 12 | +CHOOSING -- a `chooser`, an `option`, a `situation`, a `decide()` -- and |
| 13 | +that vocabulary is deliberately neutral about what implements the choice. |
| 14 | +The neutrality is the better design as well as the tidier one: a scripted |
| 15 | +chooser and any other kind are then indistinguishable to the engine, which |
| 16 | +is exactly what lets a fight run with no dependencies at all. |
| 17 | +
|
| 18 | +A comment convention does not survive contact with a year of commits. This |
| 19 | +does, because it fails the build. It follows the same shape as |
| 20 | +kirby-terrain's leaf test: assert the property, then assert the assertion |
| 21 | +could actually fail. |
| 22 | +
|
| 23 | +To permit a genuine exception, add it to ALLOW with a reason. Deliberate |
| 24 | +and reviewable beats a silently widened pattern. |
| 25 | +""" |
| 26 | +import pathlib |
| 27 | +import re |
| 28 | +import subprocess |
| 29 | + |
| 30 | +#: Unambiguous vendor, product and technique names. Deliberately NOT "ai": |
| 31 | +#: `ai = AttackInput(...)` is an ordinary local in several tests, so banning |
| 32 | +#: it would train everyone to ignore this test -- the failure mode that ends |
| 33 | +#: with the guard deleted. |
| 34 | +TERMS = [ |
| 35 | + "llm", "ollama", "anthropic", "openai", "gpt", "chatgpt", "claude", |
| 36 | + "gemini", "mistral", "llama", "huggingface", "copilot", "bedrock", |
| 37 | + "medialib", "prompt", |
| 38 | +] |
| 39 | + |
| 40 | +#: Underscore is a word character, so `\bllm\b` does NOT match `llm_driver` -- |
| 41 | +#: the exact string this test exists to catch. These lookarounds treat any |
| 42 | +#: non-alphanumeric as a boundary, so `llm_driver`, `LLM-driver` and `(llm)` |
| 43 | +#: all match. |
| 44 | +PATTERN = re.compile( |
| 45 | + "|".join(rf"(?<![a-z0-9]){t}(?![a-z0-9])" for t in TERMS), re.IGNORECASE |
| 46 | +) |
| 47 | + |
| 48 | +ROOT = pathlib.Path(__file__).resolve().parent.parent |
| 49 | + |
| 50 | +#: (path, matched term) -> why it is allowed to stay. |
| 51 | +ALLOW: dict[tuple[str, str], str] = { |
| 52 | + (".gitignore", "claude"): "editor tooling directory, not a project reference", |
| 53 | +} |
| 54 | + |
| 55 | + |
| 56 | +def _tracked_files() -> list[str]: |
| 57 | + """Every file git tracks -- source, README, pyproject, CI workflows. |
| 58 | +
|
| 59 | + Uses git rather than rglob so the scan covers exactly what is published |
| 60 | + and nothing that is not: no .venv, no build artefacts, no local scratch. |
| 61 | + """ |
| 62 | + out = subprocess.run( |
| 63 | + ["git", "-C", str(ROOT), "ls-files"], |
| 64 | + capture_output=True, text=True, check=True, |
| 65 | + ) |
| 66 | + return out.stdout.split() |
| 67 | + |
| 68 | + |
| 69 | +def _offenders() -> list[str]: |
| 70 | + found: list[str] = [] |
| 71 | + here = pathlib.Path(__file__).name |
| 72 | + for rel in _tracked_files(): |
| 73 | + # This file necessarily contains every banned term. |
| 74 | + if pathlib.Path(rel).name == here: |
| 75 | + continue |
| 76 | + path = ROOT / rel |
| 77 | + try: |
| 78 | + text = path.read_text(encoding="utf-8") |
| 79 | + except (UnicodeDecodeError, OSError): |
| 80 | + continue # binary or unreadable; nothing to read anyway |
| 81 | + for lineno, line in enumerate(text.splitlines(), 1): |
| 82 | + for match in PATTERN.finditer(line): |
| 83 | + term = match.group(0).lower() |
| 84 | + if (rel, term) in ALLOW: |
| 85 | + continue |
| 86 | + found.append(f"{rel}:{lineno}: {term!r} in {line.strip()[:80]!r}") |
| 87 | + return found |
| 88 | + |
| 89 | + |
| 90 | +def test_the_published_source_names_no_inference_vocabulary() -> None: |
| 91 | + offenders = _offenders() |
| 92 | + assert offenders == [], ( |
| 93 | + "A library must not name its consumers' internals. These " |
| 94 | + "references do:\n " |
| 95 | + + "\n ".join(offenders) |
| 96 | + + "\n\nRephrase to describe the ROLE ('the consumer's driver', 'the " |
| 97 | + "chooser') rather than the implementation. If a reference is " |
| 98 | + "genuinely necessary, add it to ALLOW with a reason." |
| 99 | + ) |
| 100 | + |
| 101 | + |
| 102 | +def test_the_guard_would_notice_a_leak() -> None: |
| 103 | + """Guards the guard. |
| 104 | +
|
| 105 | + A word-boundary bug would make the test above pass on every input while |
| 106 | + appearing to work -- so assert against the specific string that motivated |
| 107 | + it, underscore and all. |
| 108 | + """ |
| 109 | + assert PATTERN.search("see llm_driver.py for details") |
| 110 | + assert PATTERN.search("calls the Anthropic API") |
| 111 | + assert PATTERN.search("OLLAMA_BASE_URL") |
| 112 | + # ...and does not fire on ordinary prose or an `ai` local. |
| 113 | + assert not PATTERN.search("ai = AttackInput(attacker=a, target=b)") |
| 114 | + assert not PATTERN.search("the domain contains available terrain") |
| 115 | + |
| 116 | + |
| 117 | +def test_the_scan_actually_reads_files() -> None: |
| 118 | + """A `git ls-files` that returned nothing would make the guard vacuous.""" |
| 119 | + tracked = _tracked_files() |
| 120 | + assert len(tracked) > 5, tracked |
| 121 | + assert any(f.endswith(".py") for f in tracked) |
0 commit comments