#!/usr/bin/env python3 """ generator_sources — single source of truth for systems.db generator source sets. Each generator that writes a stamp row to systems.db's ``meta`` table records the SHA-1 of the concatenated bytes of its source files (sorted by path). This module is the ONE place that defines which files belong to each generator's stamped set (T-1067). It replaces the formerly triplicated lists in: - ``IMPORT_ECONOMICS_SOURCES`` in tooling/economy-db/import_economics.py - ``GENERATOR_SOURCES`` in tooling/check-systems-db-stamp - the hardcoded watch list in .claude/skills/pr-process/SKILL.md (now derived via ``python3 tooling/generator_sources.py --list``) Consumers: - tooling/economy-db (the importer) — imports ``IMPORT_ECONOMICS_SOURCES`` and ``file_sha1`` to write the stamp after a successful run. - tooling/check-systems-db-stamp — imports ``GENERATOR_SOURCES`` and ``file_sha1`` to verify the stamp before a push. - /pr-process (skill) — shells out to the ``--list`` CLI for its watch list. This file is itself a member of the stamped source set: a registry change (adding or removing a source) must flip the stamp and force ``make regen-db``, otherwise edits to the list would be invisible to staleness detection. The economy_import package modules are collected by glob rather than listed by hand, so a module added during a future split is stamped automatically — a hand-maintained list that misses a module silently weakens staleness detection. Usage: python3 tooling/generator_sources.py --list [generator_name] """ import argparse import hashlib import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent # --------------------------------------------------------------------------- # import_economics source set # --------------------------------------------------------------------------- # Entrypoint + split module package (T-1067). IMPORT_ECONOMICS_ENTRYPOINT: Path = ( REPO_ROOT / "tooling" / "economy-db" / "import_economics.py" ) ECONOMY_IMPORT_PACKAGE_DIR: Path = ( REPO_ROOT / "tooling" / "economy-db" / "economy_import" ) # Rust sources for the generate_brands subroutine. import_economics shells out # to tooling/generate-brands as part of its normal flow (see economy_import/ # brands.py), so all of these contribute to its effective source SHA: any change # to them must invalidate the meta stamp even though Python hasn't changed. GENERATE_BRANDS_RS: Path = ( REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "main.rs" ) GENERATE_BRANDS_NAMES_RS: Path = ( REPO_ROOT / "server" / "src" / "bin" / "generate_brands" / "names.rs" ) # Shared surname corpus extracted from the two names.rs copies (T-1064). GENERATE_BRANDS_SURNAMES_RS: Path = ( REPO_ROOT / "server" / "src" / "bin" / "shared" / "surname_corpus.rs" ) GENERATE_BRANDS_WRAPPER: Path = REPO_ROOT / "tooling" / "generate-brands" # D-237 authored specialization layer: these data TOMLs feed the DB, so a change # to either must flip the stamp and force a regen (#1013). SPECIALIZATION_VOCAB_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "specialization_vocabulary.toml" ) SYSTEM_SPECIALIZATION_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "system_specialization.toml" ) # D-232 architecture-flavor trait-template catalog + sparse hero bias (#993). ARCHITECTURE_TRAIT_CATALOG_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "architecture_trait_catalog.toml" ) ARCHITECTURE_TRAIT_BIAS_TOML: Path = ( REPO_ROOT / "wiki" / "economics" / "architecture_trait_bias.toml" ) def _economy_import_modules() -> tuple[Path, ...]: """All Python modules of the economy_import package, collected by glob. Globbing (rather than hand-listing) guarantees a future module is stamped the moment it exists. Fail closed if the package is missing — an empty set here would silently weaken staleness detection. """ modules = tuple(sorted(ECONOMY_IMPORT_PACKAGE_DIR.glob("*.py"))) if not modules: raise RuntimeError( f"economy_import package not found at {ECONOMY_IMPORT_PACKAGE_DIR} — " "the import_economics stamp source set would be incomplete" ) return modules # Canonical source set for import_economics' meta stamp. Covers the Python # entrypoint and its module package, the Rust binary it invokes (generate_brands # main.rs + names.rs + surname corpus + wrapper script), the shared schema # version constant, the authored data TOMLs, and this registry itself. IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = ( Path(__file__).resolve(), # registry changes must stale the stamp IMPORT_ECONOMICS_ENTRYPOINT, *_economy_import_modules(), GENERATE_BRANDS_RS, GENERATE_BRANDS_NAMES_RS, GENERATE_BRANDS_SURNAMES_RS, GENERATE_BRANDS_WRAPPER, REPO_ROOT / "tooling" / "schema_version.py", SPECIALIZATION_VOCAB_TOML, SYSTEM_SPECIALIZATION_TOML, ARCHITECTURE_TRAIT_CATALOG_TOML, ARCHITECTURE_TRAIT_BIAS_TOML, ) # --------------------------------------------------------------------------- # Registry # --------------------------------------------------------------------------- # Maps generator_name (as stored in meta.generator_name) to the source file(s) # whose SHA is stamped. The SHA is computed as SHA-1 of the concatenated bytes # of all files in sorted order. # # The atlas geometry generator (generate_atlas) was retired in #951 (D-223). # import_economics is now the sole regen-db generator that writes systems.db; it # owns the atlas index (names-only pool + empty geometry tables). The surviving # planet-gen importers (import_heightmaps, import_province_boundaries) are # one-time build imports baked into the committed DB, not part of regen-db, so # they are intentionally not stamped here. GENERATOR_SOURCES: dict[str, tuple[Path, ...]] = { "import_economics": IMPORT_ECONOMICS_SOURCES, } def file_sha1(*paths: Path) -> str: """SHA-1 of concatenated file contents (sorted paths). Missing files raise FileNotFoundError rather than silently contributing an empty-string hash (H2): a ghost SHA could mask real breakage when stored and current SHAs converge on the empty-bytes digest. """ h = hashlib.sha1() for p in sorted(paths): if not p.exists(): raise FileNotFoundError(f"generator source not found: {p}") h.update(p.read_bytes()) return h.hexdigest() def main() -> None: parser = argparse.ArgumentParser( description="Single source of truth for systems.db generator source sets" ) parser.add_argument( "--list", action="store_true", help="Print the stamped source files, one repo-relative path per line", ) parser.add_argument( "generator", nargs="?", default=None, help="Restrict --list to one generator (default: all)", ) args = parser.parse_args() if not args.list: parser.print_help() sys.exit(2) if args.generator is not None: if args.generator not in GENERATOR_SOURCES: print( f"error: unknown generator '{args.generator}' — " f"known: {sorted(GENERATOR_SOURCES)}", file=sys.stderr, ) sys.exit(1) names = [args.generator] else: names = sorted(GENERATOR_SOURCES) seen: set[Path] = set() for name in names: seen.update(GENERATOR_SOURCES[name]) for path in sorted(seen): print(path.relative_to(REPO_ROOT).as_posix()) if __name__ == "__main__": main()