576df2cddd
Files changed: - .gitea/workflows/ci.yml - CHANGES.md - README.md - VERSION - instructions/mcp-read-server.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/api.py - tools/chemenu/commands/doctor.py - tools/chemenu/commands/lint.py - tools/chemenu/commands/search.py - tools/chemenu/commands/types_cmd.py - tools/chemenu/config.py - tools/chemenu/corpus_cache.py - tools/chemenu/errors.py - tools/chemenu/frontmatter_io.py - tools/chemenu/lint_core.py - tools/chemenu/mcp/__init__.py - tools/chemenu/mcp/__main__.py - tools/chemenu/mcp/server.py - tools/chemenu/page.py - tools/chemenu/search/filters.py - tools/chemenu/search/registry.py - tools/chemenu/search/ripgrep.py - tools/chemenu/search/service.py - tools/chemenu/tests/conftest.py - tools/chemenu/tests/test_api.py - tools/chemenu/tests/test_corpus_cache.py - tools/chemenu/tests/test_doctor.py - tools/chemenu/tests/test_frontmatter_io.py - tools/chemenu/tests/test_instructions_cmd.py - tools/chemenu/tests/test_mcp_server.py - tools/chemenu/tests/test_new_page.py - tools/chemenu/tests/test_search.py - tools/chemenu/type_resolver.py - tools/chemenu/types_core.py - tools/requirements-mcp.txt
259 lines
11 KiB
Python
259 lines
11 KiB
Python
"""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",),
|
|
# 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")
|
|
|
|
# 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 upstream, 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"
|
|
|
|
|
|
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
|
|
try:
|
|
result = subprocess.run(
|
|
["git", "config", "user.name"],
|
|
cwd=_root(),
|
|
capture_output=True,
|
|
text=True,
|
|
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
|
|
|