mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
Of the 76 open code scanning alerts, 39 point at code that can change. svc-ai-mcp-adapter: - A path argument that is empty or made only of dots is refused. Percent-encoding keeps a separator inside its segment, but "." and ".." need none, so an upstream that resolves dot segments could answer from a path the contract does not name. - parse_duration splits on the units. The pattern it used before rescanned a long run of digits from every start position. Tests and the README generators: - Every Jinja environment built with autoescape=False and a "noqa: S701" uses select_autoescape now, as six test modules and utils/templating/jinja.py already did. It escapes templates named *.html, *.htm and *.xml, which none of these call sites loads. - Dead code goes: a doubled "rel" block in the email lint, an unread _SKIP_DIRS, and a module that _resolve returned to no caller. - Two "except ...: pass" blocks become contextlib.suppress and assertRaises. - Nine test modules import unittest.mock one way instead of two, and one lookup test imports its module once. Closes #807 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
205 lines
7.5 KiB
Python
205 lines
7.5 KiB
Python
"""Generate / complete a role README from the template.
|
||
|
||
The generator owns only the sections it can derive from metadata:
|
||
|
||
* **Cosmos** – the mermaid diagram, from ``meta/services.yml``.
|
||
* **Quick Setup** – the deploy commands, from the role id (invokable roles
|
||
only; removed from non-invokable roles).
|
||
* **Credits** – the fixed block, from ``meta/main.yml`` author.
|
||
|
||
Prose sections (Description, Overview, Features, Use Cases) cannot be
|
||
synthesised, so an existing README's prose is never rewritten. On a brand
|
||
new README the prose is emitted as fill-in placeholders.
|
||
|
||
Modes:
|
||
* default – add a managed section only when it is missing.
|
||
* override – regenerate every managed section, overwriting the current
|
||
content in place (position is left untouched).
|
||
* only – restrict the run to a subset of the managed sections
|
||
(e.g. ``("Cosmos",)`` for ``--update-cosmos``).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import jinja2
|
||
|
||
from cli.build.docs.readme import schema
|
||
from cli.build.docs.readme.cosmos import derive_cosmos_mermaid
|
||
from cli.build.docs.readme.sections import Readme, parse_readme
|
||
from utils.cache.files import PROJECT_ROOT, read_text
|
||
from utils.cache.yaml import load_yaml
|
||
from utils.roles.credits import author_urls
|
||
from utils.roles.deploy import role_has_stack
|
||
from utils.roles.entity.name import get_entity_name
|
||
from utils.roles.mapping import ROLE_FILE_META_MAIN, ROLE_FILE_README
|
||
from utils.roles.validation.invokable import _get_invokable_paths, _is_role_invokable
|
||
from utils.symbol_glossary import to_emoji
|
||
|
||
MANAGED_SECTIONS = ("Cosmos", "Quick Setup", "Credits")
|
||
_DEFAULT_AUTHOR = "Kevin Veen-Birkenbach"
|
||
|
||
|
||
def _is_invokable(role_name: str) -> bool:
|
||
return _is_role_invokable(role_name, _get_invokable_paths())
|
||
|
||
|
||
def _render_template(ctx: dict) -> str:
|
||
env = jinja2.Environment(
|
||
loader=jinja2.FileSystemLoader(str(schema.TEMPLATE_DIR)),
|
||
keep_trailing_newline=True,
|
||
autoescape=jinja2.select_autoescape(),
|
||
)
|
||
return env.get_template(schema.TEMPLATE_NAME).render(**ctx)
|
||
|
||
|
||
def _role_author(role_dir) -> str:
|
||
meta = load_yaml(role_dir / ROLE_FILE_META_MAIN, default_if_missing={})
|
||
galaxy = meta.get("galaxy_info") if isinstance(meta, dict) else None
|
||
author = galaxy.get("author") if isinstance(galaxy, dict) else None
|
||
return (
|
||
author.strip()
|
||
if isinstance(author, str) and author.strip()
|
||
else _DEFAULT_AUTHOR
|
||
)
|
||
|
||
|
||
def _app_name(preamble: str, role_name: str) -> str:
|
||
for line in preamble.splitlines():
|
||
if line.startswith("# "):
|
||
return line[2:].strip()
|
||
entity = get_entity_name(role_name) or role_name
|
||
return entity.replace("-", " ").title()
|
||
|
||
|
||
def _needs_clearnet_resolver(role_dir) -> bool:
|
||
"""Whether the role refuses to deploy without ``networks.internet.dns``.
|
||
|
||
Only the inventories under ``inventories/development/`` set that value, so a
|
||
role asserting it has to name it in the block an operator copies, or the
|
||
documented deploy aborts on the assert.
|
||
"""
|
||
return any(
|
||
"networks.internet.dns" in read_text(str(path))
|
||
for path in sorted((role_dir / "tasks").rglob("*.yml"))
|
||
)
|
||
|
||
|
||
def _base_context(role_dir, role_name: str, app_name: str, *, invokable: bool) -> dict:
|
||
return {
|
||
"application_id": role_name,
|
||
"application_name": app_name,
|
||
"application_invokable": invokable,
|
||
"application_url": "https://example.com/",
|
||
"application_description": "is an application.",
|
||
"application_overview": f"This role deploys {app_name}.",
|
||
"application_features": [
|
||
{"name": "Feature", "description": "Describe a capability."}
|
||
],
|
||
"application_author": _role_author(role_dir),
|
||
"application_author_url": author_urls().get(_role_author(role_dir)),
|
||
"application_is_host": not role_has_stack(role_dir),
|
||
"application_needs_clearnet_resolver": _needs_clearnet_resolver(role_dir),
|
||
"cosmos_mermaid": derive_cosmos_mermaid(role_dir, role_name),
|
||
"cosmos_legend": _cosmos_legend(),
|
||
}
|
||
|
||
|
||
def _cosmos_legend() -> str:
|
||
"""Reading key rendered below the diagram; emojis come from the shared
|
||
symbol glossary so a glyph means the same thing everywhere."""
|
||
return (
|
||
f"Solid `1:1` edges are fixed relationships; dashed `0..1` edges are "
|
||
f"conditional (enabled only in matching deployments); red `0..0` edges "
|
||
f"are turned off in this role. Node markers "
|
||
f"show the role's deploy modes ({to_emoji('host')} host, "
|
||
f"{to_emoji('compose')} compose, {to_emoji('swarm')} swarm); "
|
||
f"{to_emoji('disabled')} marks a service that is explicitly turned "
|
||
f"off, and {to_emoji('role_dependency')} an Ansible role dependency "
|
||
f"declared in `meta/main.yml`."
|
||
)
|
||
|
||
|
||
def _managed_blocks(
|
||
role_dir, role_name: str, app_name: str, *, invokable: bool
|
||
) -> dict[str, str]:
|
||
rendered = _render_template(
|
||
_base_context(role_dir, role_name, app_name, invokable=invokable)
|
||
)
|
||
parsed = parse_readme(rendered)
|
||
return {
|
||
title: block for title, block in parsed.sections if title in MANAGED_SECTIONS
|
||
}
|
||
|
||
|
||
def _insert_managed(
|
||
sections: list[tuple[str, str]], title: str, block: str, canonical: tuple[str, ...]
|
||
) -> None:
|
||
"""Insert ``title`` right after the last present section that canonically
|
||
precedes it, so the required order holds without touching extras."""
|
||
idx = canonical.index(title)
|
||
preceding = set(canonical[:idx])
|
||
anchor = -1
|
||
for i, (existing_title, _) in enumerate(sections):
|
||
if existing_title in preceding:
|
||
anchor = i
|
||
sections.insert(anchor + 1, (title, block))
|
||
|
||
|
||
def generate_readme(
|
||
role_dir,
|
||
role_name: str,
|
||
*,
|
||
override: bool = False,
|
||
only: tuple[str, ...] = MANAGED_SECTIONS,
|
||
) -> tuple[str | None, list[str]]:
|
||
"""Return ``(new_text_or_None, actions)``.
|
||
|
||
``new_text_or_None`` is ``None`` when nothing changed.
|
||
"""
|
||
readme_path = role_dir / ROLE_FILE_README
|
||
canonical = schema.canonical_order()
|
||
invokable = _is_invokable(role_name)
|
||
|
||
if not readme_path.is_file():
|
||
app_name = _app_name("", role_name)
|
||
text = _render_template(
|
||
_base_context(role_dir, role_name, app_name, invokable=invokable)
|
||
)
|
||
return text, ["create README.md from template"]
|
||
|
||
original = read_text(str(readme_path))
|
||
parsed = parse_readme(original)
|
||
app_name = _app_name(parsed.preamble, role_name)
|
||
managed = _managed_blocks(role_dir, role_name, app_name, invokable=invokable)
|
||
|
||
sections = list(parsed.sections)
|
||
present = {title for title, _ in sections}
|
||
actions: list[str] = []
|
||
|
||
for title in MANAGED_SECTIONS:
|
||
if title not in only:
|
||
continue
|
||
if title not in managed:
|
||
if title in present:
|
||
sections = [(t, b) for t, b in sections if t != title]
|
||
actions.append(f"remove {title}")
|
||
continue
|
||
block = managed[title]
|
||
if title in present:
|
||
if override:
|
||
sections = [(t, block if t == title else b) for t, b in sections]
|
||
actions.append(f"override {title}")
|
||
else:
|
||
_insert_managed(sections, title, block, canonical)
|
||
actions.append(f"add {title}")
|
||
|
||
new_text = Readme(parsed.preamble, sections).render()
|
||
if new_text == original:
|
||
return None, []
|
||
return new_text, actions
|
||
|
||
|
||
def role_dirs(roles_root=None) -> list:
|
||
root = roles_root or (PROJECT_ROOT / "roles")
|
||
return sorted(p for p in root.iterdir() if p.is_dir())
|