mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-31 03:51:58 +01:00
607 lines
20 KiB
Python
607 lines
20 KiB
Python
# 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<name>\w+[?!]?)"
|
|
r"(?:[ \t]*\((?P<params>[^)]*)\))?",
|
|
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<key>"[^"]*"|'[^']*'|[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",
|
|
]
|