diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 4f06bb10..c55248ee 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -23,6 +23,12 @@ on: - '.github/**' - 'data/**' - 'tests/**' + # Deployment surfaces read by tests/test_env_surfaces.py. + - '.env.example' + - 'README.md' + - 'docker-compose.yml' + - 'flake.nix' + - 'web/lib/potato_mesh/config.rb' # tests/test_dockerfile_sync.py compares these two files. - 'Dockerfile' - 'web/Dockerfile' diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a00355d..426eec37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ * Web: the federation table renders a Reticulum zero count as a dash instead of a tile (SPEC RD8) ### Fixes +* Data: `MESHCORE_TELEMETRY_POLL_24H_EXEMPT` reaches every packaged deployment - `docker-compose.yml`, both `data/Dockerfile` stages and the NixOS module (`meshcoreTelemetryPoll24hExempt`); setting it in `.env` had no effect under Compose or NixOS (SPEC PX5) +* Docs: the README documents `MESHTASTIC_PSK_B64` (SPEC PX7) +* CI: the env-surface check runs in pytest whenever a deployment surface changes, and also checks names with digits, names read through helpers, and retired names (SPEC PX6/PX8) * Docker: the root `Dockerfile` builds the same image as `web/Dockerfile`, including Chromium for Open Graph previews (skip it with `--build-arg WITH_OG_IMAGE=0`), the custom pages directory, and the `APP_VERSION` build arg (SPEC DF1) * Docs: `SECURITY.md` says how to report a vulnerability privately (SPEC SEC1) * Data/Web: Reticulum reports a frequency and LoRa preset, read from the RNS config (SPEC RL1-RL3) diff --git a/README.md b/README.md index 4f7bace0..fb049fc8 100644 --- a/README.md +++ b/README.md @@ -235,6 +235,7 @@ directly in the web process's environment. | `REMOTE_INSTANCE_READ_TIMEOUT` | `60` | Read timeout when fetching a peer. | | `REMOTE_INSTANCE_REQUEST_TIMEOUT` | `30` | Overall request timeout when fetching a peer. | | `REMOTE_INSTANCE_MAX_RESPONSE_BYTES` | `8388608` | Response ceiling (8 MiB) when fetching a peer. | +| `MESHTASTIC_PSK_B64` | `AQ==` | Base64 PSK used to decrypt Meshtastic messages that ingestors forward still encrypted (default = Meshtastic default key). | ### Monitoring diff --git a/data/Dockerfile b/data/Dockerfile index d99a082b..daf889d4 100644 --- a/data/Dockerfile +++ b/data/Dockerfile @@ -93,6 +93,7 @@ ENV CONNECTION=/dev/ttyACM0 \ FREQUENCY="" \ MESHCORE_SELF_TELEMETRY_SECONDS=3600 \ MESHCORE_TELEMETRY_POLL_SECONDS=300 \ + MESHCORE_TELEMETRY_POLL_24H_EXEMPT="" \ FEDERATION=1 \ PRIVATE=0 \ INSTANCE_DOMAIN="" \ @@ -141,6 +142,7 @@ ENV CONNECTION=/dev/ttyACM0 \ FREQUENCY="" \ MESHCORE_SELF_TELEMETRY_SECONDS=3600 \ MESHCORE_TELEMETRY_POLL_SECONDS=300 \ + MESHCORE_TELEMETRY_POLL_24H_EXEMPT="" \ FEDERATION=1 \ PRIVATE=0 \ INSTANCE_DOMAIN="" \ diff --git a/docker-compose.yml b/docker-compose.yml index 571fe7b3..07744ccd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -85,6 +85,7 @@ x-ingestor-base: &ingestor-base ENERGY_SAVING: ${ENERGY_SAVING:-0} MESHCORE_SELF_TELEMETRY_SECONDS: ${MESHCORE_SELF_TELEMETRY_SECONDS:-3600} MESHCORE_TELEMETRY_POLL_SECONDS: ${MESHCORE_TELEMETRY_POLL_SECONDS:-300} + MESHCORE_TELEMETRY_POLL_24H_EXEMPT: ${MESHCORE_TELEMETRY_POLL_24H_EXEMPT:-} # TRANSPORT=udp needs host networking; see "Passive UDP transport" in README. TRANSPORT: ${TRANSPORT:-api} PRIMARY_CHANNEL_ONLY: ${PRIMARY_CHANNEL_ONLY:-} diff --git a/flake.nix b/flake.nix index 898330fb..643b151a 100644 --- a/flake.nix +++ b/flake.nix @@ -441,6 +441,12 @@ description = "Seconds between Meshcore contact telemetry polls; requires txEnabled since polling transmits. 0 disables on-air polling"; }; + meshcoreTelemetryPoll24hExempt = lib.mkOption { + type = lib.types.str; + default = ""; + description = "Comma-separated node ids exempt from the once-per-24h Meshcore contact telemetry cooldown; listed nodes are polled every round-robin rotation. Requires txEnabled"; + }; + txEnabled = lib.mkOption { type = lib.types.bool; default = false; @@ -557,6 +563,7 @@ ENERGY_SAVING = if cfg.ingestor.energySaving then "1" else "0"; MESHCORE_SELF_TELEMETRY_SECONDS = toString cfg.ingestor.meshcoreSelfTelemetrySeconds; MESHCORE_TELEMETRY_POLL_SECONDS = toString cfg.ingestor.meshcoreTelemetryPollSeconds; + MESHCORE_TELEMETRY_POLL_24H_EXEMPT = cfg.ingestor.meshcoreTelemetryPoll24hExempt; PRIMARY_CHANNEL_ONLY = if cfg.ingestor.primaryChannelOnly then "1" else "0"; PRIMARY_CHANNEL_KEY = cfg.ingestor.primaryChannelKey; MESH_UDP_GROUP = cfg.ingestor.meshUdpGroup; diff --git a/tests/test_env_surfaces.py b/tests/test_env_surfaces.py new file mode 100644 index 00000000..c6fcbcc5 --- /dev/null +++ b/tests/test_env_surfaces.py @@ -0,0 +1,606 @@ +# Copyright © 2025-26 l5yth & contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Ensure every environment variable the config files read reaches its surfaces. + +Executable form of ACCEPTANCE ``DOC-A1`` (SPEC ``DOC2``). ``README.md`` +documents every variable that ``data/mesh_ingestor/config.py`` or +``web/lib/potato_mesh/config.rb`` reads; every operator-facing variable is also +declared in ``.env.example``, ``docker-compose.yml``, ``flake.nix`` and the +image of each service that reads it. Internal tuning knobs +(:data:`ADVANCED_NAMES`, :data:`ADVANCED_PREFIXES`) are README-only. Retired +names (:data:`RETIRED`) are still read but appear on none of those surfaces. + +Names are collected from every reading form, not from one regex. The shell +heredoc this replaces matched ``[A-Z_]+``, which skips names containing digits +(``MESHCORE_TELEMETRY_POLL_24H_EXEMPT``), and saw only ``os.environ.get``, so +names read through a helper such as ``_env_flag`` were never checked. A read +whose key the scanners cannot resolve fails the suite instead of being skipped. +""" + +from __future__ import annotations + +import ast +import bisect +import re +from collections.abc import Iterable, Mapping +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] + +INGESTOR_CONFIG = "data/mesh_ingestor/config.py" +"""Python module whose environment reads are checked.""" + +WEB_CONFIG = "web/lib/potato_mesh/config.rb" +"""Ruby module whose environment reads are checked.""" + +README = "README.md" +"""Documents every variable either config file reads.""" + +COMMON_SURFACES = (".env.example", "docker-compose.yml", "flake.nix") +"""Surfaces every operator-facing variable reaches, whichever service reads it.""" + +INGESTOR_IMAGE = "data/Dockerfile" +"""Image that declares every operator-facing variable the ingestor reads.""" + +WEB_IMAGE = "web/Dockerfile" +"""Image that declares every operator-facing variable the web app reads.""" + +SURFACES = (README, *COMMON_SURFACES, INGESTOR_IMAGE, WEB_IMAGE) +"""Every file the check reads, and so every place a retired name must not be.""" + +RUNTIME = frozenset( + { + "HOME", + "PATH", + "PORT", + "HOST", + "RACK_ENV", + "APP_ENV", + "APP_VERSION", + "XDG_DATA_HOME", + "XDG_CONFIG_HOME", + "PYTHONPATH", + "INSTANCES", + } +) +"""Set by the platform or the runtime, not by the operator; never checked.""" + +ADVANCED_NAMES = frozenset( + { + "MIN_THREADS", + "MAX_THREADS", + "PUMA_FORCE_SHUTDOWN", + "STATS_CACHE_TTL_SECONDS", + "OG_IMAGE_TTL_SECONDS", + "LIVE_SAFETY_POLL_SECONDS", + "SSE_HEARTBEAT_SECONDS", + "SSE_MAX_LIFETIME_SECONDS", + "SSE_PUBLISH_COOLDOWN", + "SSE_THREAD_RESERVE", + "INITIAL_FEDERATION_DELAY_SECONDS", + # Not a tuning knob, but README-only by decision (SPEC PX7). + "MESHTASTIC_PSK_B64", + } +) +"""README-only names: documented in the README, kept out of the templates.""" + +ADVANCED_PREFIXES = ("FEDERATION_", "REMOTE_INSTANCE_") +"""Prefixes of the federation internals, README-only like :data:`ADVANCED_NAMES`.""" + +RETIRED = frozenset({"RX_ONLY"}) +"""Still honoured for existing deployments, retired from every surface (SPEC MA7). + +The inverse of the operator tier: a retired name must not appear in the README +or any template, so the check also guards ACCEPTANCE ``TX-A6``. +""" + +_ENV_NAME = re.compile(r"[A-Z][A-Z0-9_]*\Z") +"""Shape of a variable name; digits included, unlike ``DOC-A1``'s ``[A-Z_]+``.""" + +_PY_READERS = frozenset({"os.environ.get", "os.getenv"}) +"""Python callables that read the variable named by their first argument.""" + +_RB_DEF = re.compile( + r"^[ \t]*def[ \t]+(?:self\.)?(?P\w+[?!]?)" + r"(?:[ \t]*\((?P[^)]*)\))?", + re.MULTILINE, +) +"""Header of a Ruby method definition, with or without a parameter list.""" + +_RB_PARAM = re.compile(r"(?:^|,)\s*[*&]*(\w+)") +"""One parameter name in a Ruby parameter list (defaults and splats allowed).""" + +_RB_COMMENT = re.compile(r"^[ \t]*#.*$", re.MULTILINE) +"""A whole-line Ruby comment: a mention of ``ENV`` there is prose, not a read.""" + +_RB_OTHER_ENV = re.compile(r"\bENV\b(?!\[|\.fetch\()") +"""Any use of ``ENV`` other than the two read forms the Ruby scanner resolves.""" + + +def _read(path: str) -> str: + """Return the text of a repository file. + + Parameters: + path: File path relative to the repository root. + + Returns: + The file's contents. + """ + + return (REPO_ROOT / path).read_text(encoding="utf-8") + + +def _dotted_name(node: ast.AST) -> str | None: + """Render a ``Name``/``Attribute`` chain such as ``os.environ.get``. + + Parameters: + node: Expression to render. + + Returns: + The dotted path, or ``None`` when ``node`` is not a plain name chain. + """ + + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + base = _dotted_name(node.value) + return None if base is None else f"{base}.{node.attr}" + return None + + +class _PythonEnvReads(ast.NodeVisitor): + """Collect the environment reads in one parsed Python module. + + A read is a call to :data:`_PY_READERS` or to a known helper, or an + ``os.environ[...]`` subscript. Its key resolves one of three ways: a + string literal names a variable; a parameter of the enclosing function + makes that function a helper, whose own call sites then name the + variables; any other key, and any other use of ``os.environ``, is + unresolved. + + Attributes: + helpers: Functions already known to forward a parameter to a read. + names: Variable names read with a literal key. + forwarders: Functions seen forwarding a parameter to a read. + unresolved: Line and source of every read that could not be resolved. + """ + + def __init__(self, helpers: frozenset[str]) -> None: + """Start a scan that also treats calls to ``helpers`` as reads. + + Parameters: + helpers: Bare names of the known helper functions. + """ + + self.helpers = helpers + self.names: set[str] = set() + self.forwarders: set[str] = set() + self.unresolved: list[str] = [] + # Innermost enclosing function last: (name, parameter names). + self._scopes: list[tuple[str, frozenset[str]]] = [] + # ``os.environ`` nodes already accounted for by a recognised read. + self._consumed: set[int] = set() + + def visit_FunctionDef(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: + """Visit a function body with the function's parameters in scope. + + Parameters: + node: Function definition being entered. + """ + + args = node.args + params = {arg.arg for arg in (*args.posonlyargs, *args.args, *args.kwonlyargs)} + self._scopes.append((node.name, frozenset(params))) + self.generic_visit(node) + self._scopes.pop() + + visit_AsyncFunctionDef = visit_FunctionDef + + def visit_Call(self, node: ast.Call) -> None: + """Record the key of a call to a reader or to a known helper. + + Parameters: + node: Call expression being visited. + """ + + dotted = _dotted_name(node.func) or "" + # Helpers match on the final component, so ``config._env_flag(...)`` + # cannot hide a read from the scan. + if dotted in _PY_READERS or dotted.rpartition(".")[2] in self.helpers: + if dotted == "os.environ.get": + self._consumed.add(id(node.func.value)) + self._record(node.args[0] if node.args else None, node) + self.generic_visit(node) + + def visit_Subscript(self, node: ast.Subscript) -> None: + """Record the key of an ``os.environ[...]`` lookup. + + Parameters: + node: Subscript expression being visited. + """ + + if _dotted_name(node.value) == "os.environ": + self._consumed.add(id(node.value)) + self._record(node.slice, node) + self.generic_visit(node) + + def visit_Attribute(self, node: ast.Attribute) -> None: + """Flag a use of ``os.environ`` that is not a recognised read. + + Parameters: + node: Attribute expression being visited. + """ + + if _dotted_name(node) == "os.environ" and id(node) not in self._consumed: + self.unresolved.append(f"line {node.lineno}: {ast.unparse(node)}") + self.generic_visit(node) + + def _record(self, key: ast.expr | None, read: ast.expr) -> None: + """Resolve one read's key to a name, a forwarded parameter, or neither. + + Parameters: + key: First argument or subscript of the read; ``None`` when absent. + read: The whole read expression, quoted when unresolved. + """ + + if ( + isinstance(key, ast.Constant) + and isinstance(key.value, str) + and _ENV_NAME.match(key.value) + ): + self.names.add(key.value) + elif ( + isinstance(key, ast.Name) and self._scopes and key.id in self._scopes[-1][1] + ): + self.forwarders.add(self._scopes[-1][0]) + else: + self.unresolved.append(f"line {read.lineno}: {ast.unparse(read)}") + + +def _python_env_names(source: str) -> set[str]: + """Return every environment variable name Python ``source`` reads. + + Rescans until no new helper appears, so a helper that forwards its + parameter to another helper is followed too. + + Parameters: + source: Python module source. + + Returns: + The names read with a literal key, directly or through a helper. + + Raises: + AssertionError: When a read cannot be resolved to a name. + """ + + tree = ast.parse(source) + helpers: frozenset[str] = frozenset() + while True: + scan = _PythonEnvReads(helpers) + scan.visit(tree) + if scan.forwarders <= helpers: + break + helpers |= scan.forwarders + assert not scan.unresolved, f"unresolved environment reads: {scan.unresolved}" + return scan.names + + +def _ruby_read_pattern(helpers: Iterable[str]) -> re.Pattern[str]: + """Compile the pattern matching every Ruby ``ENV`` read and helper call. + + Parameters: + helpers: Names of the methods known to forward a parameter to a read. + + Returns: + A pattern whose ``key`` group is the read's first argument: a quoted + string, a lower-case identifier, or the single character found instead. + """ + + # Helper calls may omit the parentheses (``resolve "X"``); \s* after the + # opener spans newlines, so multi-line argument lists are followed. + calls = "".join(rf"|\b{re.escape(name)}(?:\(|[ \t]+)" for name in sorted(helpers)) + return re.compile( + rf"(?:\bENV(?:\[|\.fetch\(){calls})\s*" + r"""(?P"[^"]*"|'[^']*'|[a-z_]\w*|\S)""" + ) + + +def _ruby_env_names(source: str) -> set[str]: + """Return every environment variable name Ruby ``source`` reads. + + ``ENV[...]`` and ``ENV.fetch(...)`` are reads. A read whose key is a + parameter of the enclosing method makes that method a helper (as + ``fetch_string`` and ``resolve_xdg_home`` are), and calls to a helper are + reads in turn, rescanned until no new helper appears. Whole-line comments + are ignored; any other use of ``ENV`` or an unresolvable key fails. + + Parameters: + source: Ruby source text. + + Returns: + The names read with a literal key, directly or through a helper. + + Raises: + AssertionError: When a read cannot be resolved to a name. + """ + + code = _RB_COMMENT.sub("", source) + defs = list(_RB_DEF.finditer(code)) + starts = [header.start() for header in defs] + helpers: frozenset[str] = frozenset() + while True: + names: set[str] = set() + forwarders: set[str] = set() + unresolved = [use.group(0) for use in _RB_OTHER_ENV.finditer(code)] + for read in _ruby_read_pattern(helpers).finditer(code): + # The enclosing method is the last ``def`` header before the read. + index = bisect.bisect_right(starts, read.start()) - 1 + method = defs[index] if index >= 0 else None + if method is not None and read.start() < method.end(): + continue # the helper's own ``def`` line, not a call to it + params = set(_RB_PARAM.findall(method["params"] or "")) if method else set() + key = read["key"] + if key[0] in "\"'" and _ENV_NAME.match(key[1:-1]): + names.add(key[1:-1]) + elif key in params: + forwarders.add(method["name"]) + else: + unresolved.append(read.group(0)) + if forwarders <= helpers: + break + helpers |= forwarders + assert not unresolved, f"unresolved environment reads: {unresolved}" + return names + + +def _has(text: str, name: str) -> bool: + """Report whether ``name`` appears in ``text`` as a whole word. + + Parameters: + text: Contents of one surface. + name: Environment variable name. + + Returns: + ``True`` when the surface mentions the variable. + """ + + return re.search(rf"\b{name}\b", text) is not None + + +def _is_advanced(name: str) -> bool: + """Report whether ``name`` is README-only, kept out of the templates. + + Parameters: + name: Environment variable name. + + Returns: + ``True`` for :data:`ADVANCED_NAMES` and :data:`ADVANCED_PREFIXES` names. + """ + + return name in ADVANCED_NAMES or name.startswith(ADVANCED_PREFIXES) + + +def _surface_findings( + ingestor: set[str], web: set[str], texts: Mapping[str, str] +) -> list[str]: + """List every surface a variable fails to reach, in ``DOC-A1``'s wording. + + Parameters: + ingestor: Names the ingestor config reads. + web: Names the web config reads. + texts: Contents of every :data:`SURFACES` file, keyed by repository path. + + Returns: + One line per gap, ordered by variable; empty when nothing is missing. + """ + + findings = [] + for name in sorted((ingestor | web) - RUNTIME): + if name in RETIRED: + present = [path for path in SURFACES if _has(texts[path], name)] + if present: + findings.append(f"{name}: retired but present in {', '.join(present)}") + continue + if not _has(texts[README], name): + findings.append(f"{name}: absent from {README}") + if _is_advanced(name): + continue + need = list(COMMON_SURFACES) + # A variable both services read is declared in both images. + if name in ingestor: + need.append(INGESTOR_IMAGE) + if name in web: + need.append(WEB_IMAGE) + missing = [path for path in need if not _has(texts[path], name)] + if missing: + findings.append(f"{name}: missing from {', '.join(missing)}") + return findings + + +def test_every_env_var_reaches_its_surfaces() -> None: + """Each name is in the README, operator names in each template, retired nowhere.""" + + ingestor = _python_env_names(_read(INGESTOR_CONFIG)) + web = _ruby_env_names(_read(WEB_CONFIG)) + findings = _surface_findings( + ingestor, web, {path: _read(path) for path in SURFACES} + ) + assert not findings, "\n".join(findings) + + +@pytest.mark.parametrize( + "name", + # os.environ.get, the _env_flag helper, and a name containing digits. + ["CONNECTION", "TX_ENABLED", "MESHCORE_TELEMETRY_POLL_24H_EXEMPT"], +) +def test_ingestor_scan_sees_every_reading_form(name: str) -> None: + """Each reading form ``config.py`` uses is seen, so no pass is vacuous.""" + + assert name in _python_env_names(_read(INGESTOR_CONFIG)) + + +@pytest.mark.parametrize( + "name", + # ENV[...], ENV.fetch, fetch_string, a multi-line fetch_positive_integer + # call, a helper call nested in another, and resolve_xdg_home, a helper + # that forwards to a helper. + [ + "DEBUG", + "PRIVATE", + "MESHTASTIC_PSK_B64", + "REMOTE_INSTANCE_CONNECT_TIMEOUT", + "CHANNEL", + "XDG_DATA_HOME", + ], +) +def test_web_scan_sees_every_reading_form(name: str) -> None: + """Each reading form ``config.rb`` uses is seen, so no pass is vacuous.""" + + assert name in _ruby_env_names(_read(WEB_CONFIG)) + + +def test_python_scanner_follows_every_form() -> None: + """Literal reads, helpers, helpers of helpers and dotted calls all count.""" + + source = """ +import os + +def _flag(name, *, default): + return os.environ.get(name) or default + +def _wrap(key): + return _flag(key, default=1) + +async def _later(key): + return os.getenv(key) + +A_1 = os.environ.get("A_1") +B = os.getenv("B") +C = os.environ["C"] +D = _wrap("D") +E = config._flag("E", default=0) +G = _later("G") +F = make().get("NOT_A_READ") +""" + assert _python_env_names(source) == {"A_1", "B", "C", "D", "E", "G"} + + +@pytest.mark.parametrize( + "source", + [ + 'import os\nX = os.environ.get(PREFIX + "X")', + 'import os\nX = os.environ.get("lower_case")', + "import os\nX = os.environ.get()", + "import os\nX = dict(os.environ)", + "import os\ndef f(a):\n return os.environ.get(b)", + "import os\nX = os.environ[key]", + ], + ids=["expression", "not-a-name", "no-key", "bare", "not-a-param", "module-key"], +) +def test_python_scanner_rejects_unresolvable_reads(source: str) -> None: + """A read whose variable cannot be named fails instead of being skipped.""" + + with pytest.raises(AssertionError, match="unresolved environment reads"): + _python_env_names(source) + + +def test_ruby_scanner_follows_every_form() -> None: + """Literal reads, helpers, nesting, multi-line and paren-less calls count.""" + + source = """ +# ENV in a comment is prose, not a read. +TOP = ENV["TOP"] +module M + def flag? + ENV.fetch("FLAG_2", "0") == "1" + end + + def fetch_string(key, default = nil) + value = ENV[key] + value.nil? ? default : value + end + + def resolve(env_key, *rest) + fetch_string(env_key, nil) + end + + def site + fetch_string("SITE", fetch_string('CHANNEL', nil)) + end + + def timeout + fetch_string( + "TIMEOUT", + nil, + ) + end + + def home + resolve "HOME_DIR" + end +end +""" + assert _ruby_env_names(source) == { + "TOP", + "FLAG_2", + "SITE", + "CHANNEL", + "TIMEOUT", + "HOME_DIR", + } + + +@pytest.mark.parametrize( + "source", + [ + 'X = ENV.key?("X")', + "X = ENV[KEY]", + 'X = ENV["lower_case"]', + "def f(a)\n ENV[b]\nend", + "X = ENV[key]", + ], + ids=["other-use", "constant-key", "not-a-name", "not-a-param", "outside-def"], +) +def test_ruby_scanner_rejects_unresolvable_reads(source: str) -> None: + """A read whose variable cannot be named fails instead of being skipped.""" + + with pytest.raises(AssertionError, match="unresolved environment reads"): + _ruby_env_names(source) + + +def test_surface_findings_name_every_gap() -> None: + """Each tier, image and whole-word rule of ``DOC-A1`` yields its own line.""" + + texts = { + README: "ING SHARED RX_ONLY", + ".env.example": "ING SHARED", + "docker-compose.yml": "ING_EXTRA SHARED RX_ONLY", + "flake.nix": "ING SHARED", + INGESTOR_IMAGE: "ING SHARED", + WEB_IMAGE: "", + } + ingestor = {"ING", "SHARED", "MIN_THREADS", "HOME", "RX_ONLY"} + web = {"SHARED", "WEB", "FEDERATION_X"} + assert _surface_findings(ingestor, web, texts) == [ + "FEDERATION_X: absent from README.md", + "ING: missing from docker-compose.yml", + "MIN_THREADS: absent from README.md", + "RX_ONLY: retired but present in README.md, docker-compose.yml", + "SHARED: missing from web/Dockerfile", + "WEB: absent from README.md", + "WEB: missing from .env.example, docker-compose.yml, flake.nix, web/Dockerfile", + ]