"""Repo layout constants for Chemenu, mirroring AGENTS.md. The repo is a pipeline: `raw/` (untrusted input) -> `types/` + `tools/` (schema and compiler) -> `kb/` (compiled knowledge) -> `reports/` (derived output). Only `kb/` is divided into collections; the other three stages are single-purpose directories. Repo root is resolved in three steps - an explicit argument to `resolve_root()`, then `$CHEMENU_ROOT`, then a walk up from this file's location (tools/chemenu/config.py -> tools/ -> repo root). The walk-up stays the default, so `tools/wikitool` behaves exactly as it always has; the two steps in front of it are what lets an in-process caller point this package at a corpus it does not itself live inside. **Nothing below is bound at import time.** `ROOT` and every path derived from it are resolved on each attribute access, through the module `__getattr__` at the bottom. They used to be module constants, which had a failure mode worse than the limitation itself: `monkeypatch.setattr(config, "ROOT", other)` repointed `ROOT` and left `KB_DIR` and `RAW_DIR` aimed at wherever this file happens to sit, so a caller that believed it was working on a target tree was in fact answering out of the developer's checkout. Resolving on access makes the derived paths follow whatever `ROOT` currently is - including a monkeypatched one - so the half-repointed state cannot be constructed. """ import os import subprocess from contextlib import contextmanager from pathlib import Path # The last resort, and the default every existing caller gets: the checkout this # file is part of. _PACKAGE_ROOT = Path(__file__).resolve().parents[2] # Points this package at a corpus other than its own checkout. Registered in # `_WIKITOOL_ENV` (tools/chemenu/tests/conftest.py), so the suite runs with it # cleared and a test that wants it sets it itself. ENV_ROOT = "CHEMENU_ROOT" def resolve_root(explicit: "Path | str | None" = None) -> Path: """The repo root, by the documented precedence: argument, then `$CHEMENU_ROOT`, then the checkout this package lives in. An explicit argument wins because a caller serving two corpora cannot use a process-wide variable to tell them apart; the variable exists for the case where the caller is a whole process (a server, a CI job) and there is nothing to pass it through. """ if explicit is not None: return Path(explicit).expanduser().resolve() from_env = os.environ.get(ENV_ROOT, "").strip() if from_env: return Path(from_env).expanduser().resolve() return _PACKAGE_ROOT def _root() -> Path: """`ROOT` as it stands right now, honouring an assignment onto this module. Reads the module dict directly rather than `resolve_root()` so that a test (or any caller) setting `config.ROOT` is what the derived paths follow. That assignment is why the derived paths are computed here at all. """ assigned = globals().get("ROOT") return Path(assigned) if assigned is not None else resolve_root() # Everything under the root, as a name -> relative-path table rather than as # assignments. One place to read, and the only place that has to know a derived # path exists at all. _DERIVED = { "RAW_DIR": ("raw",), "KB_DIR": ("kb",), "TYPES_DIR": ("types",), "REPORTS_DIR": ("reports",), "WORK_DIR": ("work",), "INSTRUCTIONS_DIR": ("instructions",), # The MCP upload quarantine (Gitea #32) - never `raw/` and never `incoming/`, # see raw/CONTRACT.md "Getting a file in". Gitignored; the server process is # the only writer. "UPLOAD_DIR": ("mcp-upload",), # Generated copies of the skill directories under `instructions/`. Both are # gitignored: they are build output, and a fresh clone publishes them with # `wikitool instructions sync` (see instructions/bootstrap.md). "AGENTS_SKILLS_DIR": (".agents", "skills"), "CLAUDE_SKILLS_DIR": (".claude", "skills"), } # The generated files, derived from `KB_DIR` rather than from the root: a # caller that repoints only the corpus directory must not be left with a log # and a catalog belonging to a different tree. _KB_DERIVED = { "INDEX_FILE": "index.md", "LOG_FILE": "log.md", "PROVENANCE_FILE": "provenance.md", } # Every name this module resolves rather than stores. Assigning one is # supported - that is what makes the paths repointable at all - but the # assignment has to be taken back afterwards, or it outlives the caller that # made it. See `reset()`. MANAGED_PATHS = ("ROOT", *_DERIVED, *_KB_DERIVED) @contextmanager def rooted(root: "Path | str"): """Resolve every managed path under `root` for the duration of the block. Some things below the read core reach for `config` directly rather than taking a root - the module-level `TypeResolver` singleton, which has to find `types/`, is the one that matters - so pointing this package at another corpus means pointing `config` at it, not only the functions that accept an argument. **Process-wide while it is open, and therefore not thread-safe.** A caller serving several corpora at once holds a lock around it, the same discipline `CorpusCache` documents. That is a real constraint and not a hidden one: `$CHEMENU_ROOT` is process-wide for the same reason, and the server this exists for (Gitea #19) serves one checkout that a `git reset --hard` keeps clean. Restores exactly what was there, including "nothing was assigned" - it must not leave `ROOT` bound behind it, or it recreates the stale-binding bug in the shape `reset()` describes. """ previous = {name: globals()[name] for name in MANAGED_PATHS if name in globals()} reset() globals()["ROOT"] = Path(root) try: yield Path(root) finally: reset() globals().update(previous) def reset() -> None: """Drop every assignment onto a managed path name, back to resolution. The test suite calls this between tests, and it is not optional there. `monkeypatch.setattr(config, "KB_DIR", tmp)` records the old value by *reading* it - which resolves it - and its undo then writes that resolved path back as a real attribute. The name is bound from then on, so the next caller to repoint only `ROOT` gets a `KB_DIR` still aimed at the previous tree: exactly the half-repointed state this module was rewritten to make unconstructible, rebuilt by the cleanup rather than by the test. """ for name in MANAGED_PATHS: globals().pop(name, None) def __getattr__(name: str): """Resolve `ROOT` and the paths under it on access (PEP 562). Only reached for names *not* in the module dict, so an explicit assignment - `monkeypatch.setattr(config, "ROOT", tmp)` - keeps working and now also carries the derived paths with it, which is the bug this replaces. """ if name == "ROOT": return resolve_root() if name in _DERIVED: return _root().joinpath(*_DERIVED[name]) if name in _KB_DERIVED: kb_dir = globals().get("KB_DIR") base = Path(kb_dir) if kb_dir is not None else _root() / "kb" return base / _KB_DERIVED[name] raise AttributeError(f"module {__name__!r} has no attribute {name!r}") def __dir__() -> list[str]: return sorted([*globals(), "ROOT", *_DERIVED, *_KB_DERIVED]) # Files/patterns to ignore when scanning raw/ for ingest coverage. # CONTRACT.md is the layer's source contract, not source material. RAW_IGNORE_NAMES = {".gitkeep", ".DS_Store", "CONTRACT.md"} # Per-instance personalization: who operates this wiki (`USER.md`) and how this # instance sounds while doing it (`SOUL.md`). Both are read every session and # are therefore an operating requirement - but their content belongs to one # instance and one person, so `dist export` ships only the `.template` files # and the Personalization step of instructions/setup-instance.md fills them in. # `wikitool doctor` FAILs on a missing file, and on one that still carries the # sentinel - a renamed template is not a filled one. PERSONALIZATION_FILES = ("USER.md", "SOUL.md") PERSONALIZATION_TEMPLATES = tuple(f"{name}.template" for name in PERSONALIZATION_FILES) TEMPLATE_SENTINEL = "wikitool:template-unfilled" # Per-checkout environment notes: which harness, skills, MCP servers, # connectors and remotes this working copy actually works through. Constant # for long stretches, but re-asked every session as long as nothing records # them - which is the whole reason the file exists. # # Unlike the personalization pair it is **optional**: a checkout without it # works, it just answers those questions the slow way, so `doctor` reports it # and never FAILs on it. It is gitignored rather than committed, because two # clones of the same repo are two different environments; the template ships # with `dist export` the same way the personalization templates do. ENVIRONMENT_FILE = "ENVIRONMENT.md" ENVIRONMENT_TEMPLATE = f"{ENVIRONMENT_FILE}.template" # The repository is dual-licensed, and both halves travel with every export: # `LICENSE` (AGPL-3.0) covers the stack, `LICENSE-CONTENT` (CC-BY-4.0) covers # the content, `NOTICE` names the boundary and the third-party attribution the # CC-BY terms require. `LICENSE` carries the copyleft half because that is what # a forge reports for the repository, and a reader who under-notices a copyleft # obligation is harmed in a way one who over-notices it is not. # # Which half a given file belongs to is not restated anywhere: it is the plan # `dist export` already computes (AGENTS.md invariant 8). See NOTICE. LICENSE_FILES = ("LICENSE", "LICENSE-CONTENT", "NOTICE") # Written by `dist export` into every distribution; presence means "this tree # is an exported instance", absence means "this is the dev checkout the stack # ships from". Defined here rather than only in `version.py`, because # `chemenu.telemetry` (`telemetry/policy.py`) needs the same marker to answer # opt-in vs. opt-out and must stay importable without the venv - a hook # handler imports it on every tool call. `version.py` re-exports this name # rather than defining its own, per AGENTS.md invariant 8. RELEASE_STAMP_FILENAME = ".wikitool-release.json" # Per-checkout telemetry opt-in/opt-out plus its two quantity caps (see # `telemetry/policy.py`). Same shape as `PUBLISH_REMOTES_FILENAME` below: it # answers a question about *this* checkout, so it is per-checkout and # gitignored, ships no `.template`, and its absence is a legitimate state - # the installation-form default (keyed off `RELEASE_STAMP_FILENAME`) applies. # `instructions/setup-instance.md`'s Telemetry decision point writes it from # the operator's answer. TELEMETRY_FILENAME = ".wikitool-telemetry.json" # Which push targets `publish` may write to, for a checkout that says so. The # danger this addresses is one checkout's content reaching another checkout's # remote - a private instance pushing its own `kb/` to a public repository, # where it cannot be taken back. # # It pins **URLs, not remote names**: a name-based list would pass a `publish` # whose `origin` had been repointed, which is the failure it exists to catch. # # Per-checkout and gitignored, like `ENVIRONMENT.md` and for the same reason: # two clones of this repo push to two different places, so a committed copy # would hand the second one an answer that is wrong rather than missing. Absent # means unrestricted - `doctor` reports it, and the Publish-Remote Gate simply # does not apply. A checkout that holds private content should have one; see # instructions/gates.md. PUBLISH_REMOTES_FILENAME = ".wikitool-remotes.json" # Opt-in for the MCP server's `submit` tool (Gitea #32): identity header name, # size deckel, extension allowlist, per-submitter quota. Same shape as the two # above - per-checkout, gitignored, no `.template` - but its absence means # something stronger than "unrestricted": **the write path does not exist at # all**, the tool is not registered. The safe direction, and a structural # opt-in rather than a flag - see `chemenu.upload.read_config`. UPLOAD_CONFIG_FILENAME = ".wikitool-upload.json" # The task-tracker provider opt-in (Gitea #124, D25/D30): which provider this # instance's GTD review reads/writes through, its connection details, and the # review's three staleness thresholds. Same shape as the three files above - # per-checkout, gitignored once a credential lands in it, no `.template` - and # its absence is a legitimate state, the same posture `UPLOAD_CONFIG_FILENAME` # takes: an instance with no tracker configured runs `doctor` and everything # else just fine, it only can't run the weekly review (#125, not yet built). TASKS_CONFIG_FILENAME = ".wikitool-tasks.json" # Reads a tracker configuration from another file than `/.wikitool-tasks.json` # (Gitea #156), so one checkout can be run against several trackers one after the # other without swapping a file. Registered in `_WIKITOOL_ENV` # (tools/chemenu/tests/conftest.py). A relative value is taken from the working # directory. Set but pointing at nothing is an error, never "no tracker". ENV_TASKS_CONFIG = "WIKITOOL_TASKS_CONFIG" def tasks_config_override() -> "Path | None": """The file `$WIKITOOL_TASKS_CONFIG` names, or `None` when it is unset or blank.""" value = os.environ.get(ENV_TASKS_CONFIG, "").strip() return Path(value).expanduser().resolve() if value else None def tasks_config_path(root: "Path | str") -> Path: """The tracker configuration file this process reads: the override when set, else `/.wikitool-tasks.json`.""" override = tasks_config_override() return override if override is not None else Path(root) / TASKS_CONFIG_FILENAME def tasks_config_label(root: "Path | str") -> str: """How a message names the file `tasks_config_path` reads - the bare file name by default, the actual path and the variable when the override is on.""" override = tasks_config_override() if override is None: return TASKS_CONFIG_FILENAME return f"{override} (from {ENV_TASKS_CONFIG})" def default_author() -> str | None: """The author to stamp a new source page with, per instance. `$WIKI_AUTHOR` overrides; otherwise this instance's own `git config user.name` (there is no separate author config - identity lives in git, the way `wikitool doctor` and `instructions/setup-instance.md` set it up). Returns None if neither resolves, so the caller can fail loudly instead of silently stamping a placeholder. """ override = os.environ.get("WIKI_AUTHOR", "").strip() if override: return override from chemenu import toolpaths # local: toolpaths imports this module try: result = subprocess.run( [toolpaths.git(), "config", "user.name"], cwd=_root(), capture_output=True, text=True, encoding="utf-8", timeout=5, check=False, ) except (OSError, subprocess.SubprocessError): return None name = result.stdout.strip() return name or None def iter_raw_files(raw_dir: Path): """Yield every real file under raw_dir (recursively), skipping dotfiles and the ignore list. Directories are never yielded - only concrete files.""" for path in sorted(raw_dir.rglob("*")): if not path.is_file(): continue if path.name in RAW_IGNORE_NAMES or path.name.startswith("."): continue yield path