From c0628dbd4cab07e1ca5b7320e6958d6bc544dabe Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 17 Aug 2026 20:34:48 -0400 Subject: [PATCH] feat(docs): add component registry generator --- docs/generate_component_registry.py | 101 ++++++++++++++++++++++++++++ docs/verify-manual.py | 69 +++++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 docs/generate_component_registry.py diff --git a/docs/generate_component_registry.py b/docs/generate_component_registry.py new file mode 100644 index 0000000..712290b --- /dev/null +++ b/docs/generate_component_registry.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later +"""Generate the committed opinionated-component registry. + +Walks every `# COMPONENT` header in functions/*.fish, conf.d/*.fish, and +config.fish and writes conf.d/__fish_config_op_registry.fish, the fish +data file __fish_config_op_registry_lookup reads at shell startup. + +Run manually (via __fish_config_op_registry_rebuild) after editing a +# COMPONENT header, and automatically as a pre-step in build-manual.py +before the manual is built. +""" + +import sys +from pathlib import Path + +import manualtools as mt + +DOCS = Path(__file__).parent +REPO = DOCS.parent +OUTPUT = REPO / "conf.d" / "__fish_config_op_registry.fish" + + +def collect_components() -> dict[str, list[str]]: + """Gather every `# COMPONENT` header across the whole repo.""" + out = mt.parse_components(REPO / "functions") + out.update(mt.parse_components(REPO / "conf.d")) + out.update(mt.parse_component_file(REPO / "config.fish")) + return out + + +def build_registry(components: dict[str, list[str]]) -> tuple[dict[str, list[str]], list[str]]: + """Turn {identity: [raw COMPONENT lines]} into ({"identity:site": [tags]}, warnings). + + A site with both always/on and always/off tagged is a contradiction: + both are stripped and a warning is emitted, but generation continues + -- any other real tag on that same site survives. A site whose + effective tag set is empty after stripping produces no registry entry + at all, which __fish_config_op_enabled already treats as always/on + (fail-open) at guard time -- see spec ยง4.5. + """ + registry: dict[str, list[str]] = {} + warnings: list[str] = [] + for identity, raw_lines in components.items(): + by_site: dict[str, list[str]] = {} + for site, tag in mt.parse_component_lines(raw_lines): + by_site.setdefault(site, []).append(tag) + for site, tags in by_site.items(): + if "always/on" in tags and "always/off" in tags: + label = identity if not site else f"{identity}:{site}" + warnings.append( + f"{label}: both always/on and always/off tagged; ignoring both" + ) + tags = [t for t in tags if t not in ("always/on", "always/off")] + if tags: + registry[f"{identity}:{site}"] = tags + return registry, warnings + + +def render(registry: dict[str, list[str]]) -> str: + keys = sorted(registry) + lines = [ + "# Copyright (C) 2026 Rootiest", + "# SPDX-License-Identifier: AGPL-3.0-or-later", + "#", + "# GENERATED FILE --- do not edit by hand.", + "# Regenerate with __fish_config_op_registry_rebuild after editing a", + "# # COMPONENT header, or automatically via docs/build-manual.py.", + "# Source: docs/generate_component_registry.py", + "", + ] + if not keys: + lines.append("set -g __fish_config_op_registry_keys") + lines.append("set -g __fish_config_op_registry_values") + return "\n".join(lines) + "\n" + + lines.append("set -g __fish_config_op_registry_keys \\") + lines += [f" {k} \\" for k in keys[:-1]] + [f" {keys[-1]}"] + lines.append("") + + values = ['"' + " ".join(registry[k]) + '"' for k in keys] + lines.append("set -g __fish_config_op_registry_values \\") + lines += [f" {v} \\" for v in values[:-1]] + [f" {values[-1]}"] + lines.append("") + return "\n".join(lines) + "\n" + + +def main() -> int: + components = collect_components() + registry, warnings = build_registry(components) + for w in warnings: + print(f" WARN {w}", file=sys.stderr) + OUTPUT.write_text(render(registry)) + print(f"wrote {OUTPUT} ({len(registry)} entries)") + return 0 + + +if __name__ == "__main__": + sys.path.insert(0, str(Path(__file__).parent)) + raise SystemExit(main()) diff --git a/docs/verify-manual.py b/docs/verify-manual.py index ae5810b..c4067f3 100644 --- a/docs/verify-manual.py +++ b/docs/verify-manual.py @@ -900,6 +900,75 @@ def test_parse_component_file_single_file(): assert got == {"config": ["site greeting-block: greeting/greeting-message"]} +def test_build_registry_strips_on_off_contradiction_with_warning(): + import generate_component_registry as gcr + + components = {"contradictory_fn": ["always/on", "always/off", "aliases/filesystem"]} + registry, warnings = gcr.build_registry(components) + assert registry["contradictory_fn:"] == ["aliases/filesystem"], ( + f"the non-contradictory tag should survive: {registry}" + ) + assert len(warnings) == 1 and "contradictory_fn" in warnings[0] + + +def test_build_registry_drops_empty_effective_tag_sets(): + import generate_component_registry as gcr + + components = {"only_contradictory": ["always/on", "always/off"]} + registry, warnings = gcr.build_registry(components) + assert "only_contradictory:" not in registry, ( + "a site stripped down to nothing must produce no registry entry " + "(fail-open: absence of an entry already means always/on at guard time)" + ) + assert len(warnings) == 1 + + +def test_build_registry_keeps_sites_independent(): + import generate_component_registry as gcr + + components = { + "smart_exit": [ + "site exit-plain: overrides/key-bindings", + "site logging-guard: logging/terminal-capture", + ] + } + registry, warnings = gcr.build_registry(components) + assert registry["smart_exit:exit-plain"] == ["overrides/key-bindings"] + assert registry["smart_exit:logging-guard"] == ["logging/terminal-capture"] + assert not warnings + + +def test_render_registry_is_valid_fish_and_round_trips(): + """Sourcing render()'s output must leave the two arrays in the exact + shape __fish_config_op_registry_lookup expects -- checked via the real + lookup helper (functions/__fish_config_op_registry_lookup.fish, Task + 2) rather than re-parsing the generated text by hand.""" + import subprocess + + import generate_component_registry as gcr + + registry = { + "rm:": ["aliases/filesystem"], + "smart_exit:exit-plain": ["overrides/key-bindings"], + } + text = gcr.render(registry) + + repo = Path(__file__).parent.parent + proc = subprocess.run( + [ + "fish", "-c", + f"source {repo}/functions/__fish_config_op_registry_lookup.fish; " + "source /dev/stdin; " + "__fish_config_op_registry_lookup rm ''; echo status=$status", + ], + input=text, + capture_output=True, + text=True, + ) + assert "aliases/filesystem" in proc.stdout, f"unexpected output: {proc.stdout!r} {proc.stderr!r}" + assert "status=0" in proc.stdout, f"lookup did not report found: {proc.stdout!r}" + + TESTS = [v for k, v in sorted(globals().items()) if k.startswith("test_")]