Files
computer-playbook/utils/roles/required_by_coverage.py
T
kevinveenbirkenbachandClaude Opus 5 c39f3ec9ee feat(web-app-docs): replay the published instructions in its CLI test
The replay of a role's "### Production" block moves out of the CI guide
scripts and into the documentation role itself. The block is read off the
rendered page over HTTP, so what runs is what a reader copies, and the role
that publishes the page is also the role that proves it.

scripts/github/guide/{one,compose_deploy,host_deploy}.sh and
cli/build/docs/readme/production.py are gone with the workflow step that
called them. The instructions marker now only adds web-app-docs to the row's
apps; test-e2e-cli discovers the role through its new templates/test.env.j2
and runs files/test/test.sh.

The block runs in a throwaway machine, never on the stack host. compose.yml
starts one service built from files/test/Dockerfile, whose base is
INFINITO_PARENT_IMAGE out of the mounted checkout's .env, so the machine is
the stack host's own distribution and the replay exercises the installation
instead of a prebuilt image. files/test/init.sh adds an init when the base
carries none and keeps the full -Syu that ef0f03ea98 established for arch.
scripts/tests/workspace/base/01_install.sh then prepares the machine before
the block runs.

Nothing is skipped any more. The previous host-install branch exited 0 and
passed silently over 65 of the 178 invokable roles; a block without a
"docker run" is now replayed against systemd in that same machine, with the
git clone dropped in favour of the mounted tree under test.

The target role comes from the new guide_role lookup, which reads
list_invokable_app_ids() rather than matching role-name prefixes, and falls
back to the asking role when the round deploys nothing else. deployment.py
grows running_apps() so both lookups share one definition of what the round
deploys. required_by_coverage.role_is_invokable drops its own walk over
categories.yml and delegates to the same SPOT; measured equal across all 280
roles.

Carried in the same index, because the refactor pass ran over this change
set: library.py splits into sites.py and build_queue.py mixins, builder.py
hands translated_languages to catalogs.py and folds three repeated log-tail
and state writes, and placeholders.py becomes a facade over spans.py,
repairs.py and damage.py. No test changed for those splits, and the two
ref-resolution dicts builder.py carried are replaced by _wanted_ref, which
also stops hashing the deployed tree on every tag build.

Verified: 7132 unit and lint tests pass over the whole tree, the guide_role
lookup renders through a real Templar, the base image resolves from .env,
and compose config validates the service. The replay itself has never been
executed; that needs a deploy of web-app-docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-26 04:39:48 +02:00

85 lines
2.9 KiB
Python

"""Static classifiers used by the `required_by` lint and verifier:
role invokability (per categories.yml), `required_by` presence in
`meta/services.yml`, and `# nocheck: <id>` opt-outs.
"""
from __future__ import annotations
import re
from pathlib import Path
from plugins.filter.invokable_paths import get_invokable_paths
from utils.cache.files import read_text
from utils.cache.yaml import load_yaml_any
from utils.roles.categories import categories_file
from utils.roles.mapping import ROLE_FILE_META_SERVICES
from utils.roles.validation.invokable import _is_role_invokable
from . import PROJECT_ROOT
def _default_roles_dir(roles_dir: str | Path | None) -> Path:
return Path(roles_dir) if roles_dir else (PROJECT_ROOT / "roles")
def role_is_invokable(role_id: str, roles_dir: str | Path | None = None) -> bool:
"""True when `role_id` sits at or below a category marked ``invokable: true``.
Args:
role_id: the role to classify.
roles_dir: the roles directory whose sibling ``categories.yml`` decides.
"""
if not role_id:
return False
spot = categories_file(_default_roles_dir(roles_dir).parent)
return _is_role_invokable(role_id, [str(p) for p in get_invokable_paths(str(spot))])
def role_has_required_by(role_id: str, roles_dir: str | Path | None = None) -> bool:
"""True if any entity in `roles/<role_id>/meta/services.yml` declares
non-empty `required_by` categories/roles, either flat or nested under a
`compose:`/`swarm:` mode block."""
if not role_id:
return False
base = _default_roles_dir(roles_dir)
services_yml = base / str(role_id) / ROLE_FILE_META_SERVICES
if not services_yml.is_file():
return False
data = load_yaml_any(str(services_yml), default_if_missing={})
if not isinstance(data, dict):
return False
for entry in data.values():
if not isinstance(entry, dict):
continue
rb = entry.get("required_by")
if not isinstance(rb, dict):
continue
blocks = (
[rb[m] for m in ("compose", "swarm") if isinstance(rb.get(m), dict)]
if ("compose" in rb or "swarm" in rb)
else [rb]
)
if any(b.get("categories") or b.get("roles") for b in blocks):
return True
return False
def role_has_nocheck(
role_id: str,
check_id: str,
roles_dir: str | Path | None = None,
) -> bool:
"""True if `roles/<role_id>/meta/services.yml` contains
`# nocheck: <check_id>` (whole-word match)."""
if not role_id or not check_id:
return False
base = _default_roles_dir(roles_dir)
services_yml = base / str(role_id) / ROLE_FILE_META_SERVICES
if not services_yml.is_file():
return False
pattern = re.compile(rf"#\s*nocheck:\s*{re.escape(check_id)}(?!\S)")
try:
return bool(pattern.search(read_text(str(services_yml))))
except OSError:
return False