Files
settled-reach/tooling/generator_sources.py
T
jpmschweitzerandClaude Opus 5 338644b409 refactor(tooling): T-1286 — generate, pr and dev become reach domains
Twelve scripts retired, three domains registered. `reach` now covers nine.

generate: `generate-brands` and `generate-corporations` were the second and
third copies of the same 24-line build-if-missing-then-exec bash `tooling/atlas`
carried, so they collapsed into `core.process.cargo_binary` rather than being
ported. `import_economics` shelled out to the first of those, so it now calls
that helper — `generated_brands.toml` comes back byte-identical, and the stamp
registry swaps the retired wrapper for `core/process.py`.

pr: `watchlist-diff` derives its watched set from `generator_sources.py` instead
of restating it, so it cannot drift from the stamp check.

dev: the environment scripts split decision from performing, per D-263's
guarded-exec rule. `godot_plan()` and `worktree_plan()` decide what would
happen; `install_godot()`, `install_rust()` and `setup_worktree()` do it.
`tooling/test_environment.py` pins the version pin, both override precedences,
the already-current skip, the platform refusal and both worktree refusals —
none of them performed. `make setup` now installs reach first, since the
targets that install rust and godot are reach verbs.

Two live bugs found while porting:

- The clerk read its decision index from `decisions/README.md`, a path that
  stopped existing when the DQR tree moved to `governance/`. Every clerk agent
  has been grepping blind; its prompt pointed at the same dead directory.
- The conformance exec-check matched any `x.system()` regardless of receiver,
  so `platform.system()` read as `os.system()`. Narrowed and re-proved against
  a real mutant.

`process.run` gains `input=`, `timeout=` and a `ProcessTimeout` subclass so a
killed run stays distinguishable from a verdict. The pre-push hook no longer
merges the clerk's stderr into its stdout — under streaming the last merged
line is a JSONL event, which would read as an unrecognised verdict and block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:00:46 +02:00

266 lines
11 KiB
Python

#!/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 runs the
# generate_brands binary 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"
)
# The `tooling/generate-brands` bash wrapper used to be stamped here. It was one
# of three identical copies of build-if-missing-then-exec and was retired into
# core.process.cargo_binary (T-1286); the seed argument it carried now lives in
# economy_import/brands.py, which is already stamped via the package directory
# below. Nothing about the brand output stopped being covered.
# 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-242 corp-HQ settlement model (T-1074): specialization -> {CityTenant,
# Standalone} placement map, keyed on the reused D-237 vocabulary above.
CORP_HQ_PLACEMENT_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "corp_hq_placement.toml"
)
# The corp wiki pages themselves (PR #177 review T1): their frontmatter
# (corp_specialization, headquarters, optional headquarters_body) now shapes
# the DB — specialization drives hq_placement, headquarters_body, and the
# Standalone-HQ settlement rows in atlas_city_names — so a page edit without a
# regen must trip the stamp check exactly like a TOML edit does. Defined here
# (not in economy_import/paths.py) per the stamped-paths-live-here convention;
# paths.py re-exports it.
CORPORATIONS_DIR: Path = REPO_ROOT / "wiki" / "corporations"
# D-242 population/settlement_class bake (T-1075): the small authored
# NameLocked hero-city override list.
SETTLEMENT_NAME_LOCKED_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "settlement_name_locked.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"
)
# ObjectTag vocabulary registry (T-995, Q-049): read by traits.py alongside the
# catalog above to validate allow_tags/block_tags/visual_bundle references and
# the fallback-graph (V-TT-03/V-TT-04), so a registry edit must flip the stamp
# exactly like a catalog edit does.
OBJECT_TAG_VOCABULARY_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "object_tag_vocabulary.toml"
)
# D-235 exterior-grammar content (T-988): per-(template, zone_type) axis-token
# bias overrides, and per-color_register integer HSV sampling bands. Both are
# baked alongside trait_templates (traits.py: populate_architecture_zone_bias,
# populate_color_register_bands), so an edit to either must flip the stamp
# exactly like the catalog/registry above.
ARCHITECTURE_ZONE_BIAS_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "architecture_zone_bias.toml"
)
COLOR_REGISTER_BANDS_TOML: Path = (
REPO_ROOT / "wiki" / "economics" / "color_register_bands.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
def _corporation_pages() -> tuple[Path, ...]:
"""All corp wiki pages the importer reads, collected by glob (T1, PR #177).
`load_wiki_corps` (economy_import/corporations.py) reads every
wiki/corporations/*.md EXCEPT index.md — the frontmatter feeds
corp_specialization/hq_placement/headquarters_body and the Standalone-HQ
settlement rows, so the read-set is stamped with the same glob-not-list +
fail-closed discipline as `_economy_import_modules`. index.md is excluded
because the importer skips it by name: stamping it would flag a false
stale on edits that cannot change DB output.
"""
pages = tuple(
sorted(p for p in CORPORATIONS_DIR.glob("*.md") if p.name != "index.md")
)
if not pages:
raise RuntimeError(
f"corporation pages not found at {CORPORATIONS_DIR} — "
"the import_economics stamp source set would be incomplete"
)
return pages
# 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, the corp wiki pages whose
# frontmatter the importer bakes (T1, PR #177), and this registry itself.
IMPORT_ECONOMICS_SOURCES: tuple[Path, ...] = (
Path(__file__).resolve(), # registry changes must stale the stamp
IMPORT_ECONOMICS_ENTRYPOINT,
*_economy_import_modules(),
*_corporation_pages(),
GENERATE_BRANDS_RS,
GENERATE_BRANDS_NAMES_RS,
GENERATE_BRANDS_SURNAMES_RS,
REPO_ROOT / "tooling" / "core" / "process.py", # runs the brand binary (T-1286)
REPO_ROOT / "tooling" / "schema_version.py",
SPECIALIZATION_VOCAB_TOML,
SYSTEM_SPECIALIZATION_TOML,
CORP_HQ_PLACEMENT_TOML,
SETTLEMENT_NAME_LOCKED_TOML,
ARCHITECTURE_TRAIT_CATALOG_TOML,
ARCHITECTURE_TRAIT_BIAS_TOML,
OBJECT_TAG_VOCABULARY_TOML,
ARCHITECTURE_ZONE_BIAS_TOML,
COLOR_REGISTER_BANDS_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()