feat: preflight - prerequisites checked and tool paths recorded before wikitool runs; launcher refuses without it (#151, POSIX half)
CI / verify (push) Successful in 2m18s
Release / release (push) Successful in 36s

Files changed:
- .claude/settings.json
- .gitea/workflows/ci.yml
- .gitea/workflows/nightly.yml
- .gitea/workflows/release.yml
- .gitea/workflows/tracker-live.yml
- .github/hooks/wiki-trace.json
- .gitignore
- .vibe/hooks.toml
- AGENTS.md
- CHANGES.md
- EVALS.md
- INSTALL.md
- README.md
- VERSION
- instructions/bootstrap.md
- instructions/preflight.md
- instructions/setup-instance.md
- instructions/upgrade-instance.md
- tools/CONTRACT.md
- tools/README.md
- tools/chemenu/cli.py
- tools/chemenu/commands/dist_cmd.py
- tools/chemenu/commands/docs_verify.py
- tools/chemenu/commands/doctor.py
- tools/chemenu/commands/git_publish.py
- tools/chemenu/commands/migrate_cmd.py
- tools/chemenu/config.py
- tools/chemenu/corpus_cache.py
- tools/chemenu/prerequisites.py
- tools/chemenu/search/ripgrep.py
- tools/chemenu/tests/conftest.py
- tools/chemenu/tests/test_dist_upgrade.py
- tools/chemenu/tests/test_doctor.py
- tools/chemenu/tests/test_preflight.py
- tools/chemenu/toolpaths.py
- tools/preflight.sh
- tools/prerequisites.txt
- tools/run_wikitool.py
- tools/trace-hook
- tools/wikitool
This commit is contained in:
torben committed 2026-10-01 05:52:55 +02:00
1 parent 5a731729f6
commit e4b2b6d9b1
40 files changed
+1849 -125

No files matched your search

+9 -1
View File
@@ -4,7 +4,7 @@ from pathlib import Path
import pytest
from chemenu import config, conventions
from chemenu import config, conventions, toolpaths
from chemenu.frontmatter_io import write_page
from chemenu.session import HARNESS_ENV_VARS
from chemenu.telemetry import policy as telemetry_policy
@@ -33,6 +33,8 @@ _WIKITOOL_ENV = (
"WIKITOOL_UPDATE_TOKEN",
"CHEMENU_ROOT",
"WIKITOOL_TASKS_CONFIG",
"CHEMENU_PREFLIGHT_PLATFORM",
"CHEMENU_PREFLIGHT_LONGPATHS",
) + tuple(var for var, _harness in HARNESS_ENV_VARS)
# Environment git reads for identity or for where its repo lives. A stray
@@ -156,6 +158,12 @@ def hermetic_environment(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
for name in (*_WIKITOOL_ENV, *_GIT_ENV):
monkeypatch.delenv(name, raising=False)
# `.wikitool-tools.json` beside this checkout records where the developer's
# git and rg live. A test must not depend on whether the preflight has run
# here, so every test sees no file - the bare-name fallback - unless it
# writes one of its own and points `toolpaths.tools_file` at it.
monkeypatch.setattr(toolpaths, "tools_file", lambda: home / toolpaths.FILE_NAME)
# The same hole as the environment above, one layer in: `config` resolves
# its paths on access, and `monkeypatch.setattr(config, "KB_DIR", ...)`
# undoes itself by writing the *resolved* old path back as a real
+3 -1
View File
@@ -345,7 +345,9 @@ def test_closing_report_points_at_the_upgrade_instruction(instance, tmp_path, ca
out = " ".join(capsys.readouterr().out.split()) # rich wraps; rejoin first
assert "instructions/upgrade-instance.md" in out
assert "instructions sync" in out
# The first thing the new launcher needs (Gitea #151): it refuses to start
# until the preflight has passed against the swapped-in tools/.
assert "tools/preflight.sh" in out
@pytest.mark.parametrize("preserved", [".wikitool-kb.json", "CHANGES.md", "kb/log.md", "raw/notes/.gitkeep"])
+83 -1
View File
@@ -4,12 +4,14 @@ is missing."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
from chemenu import config, conventions
from chemenu import config, conventions, prerequisites, toolpaths
from chemenu.commands import doctor, instructions_cmd
@@ -17,6 +19,18 @@ def _git(root: Path, *args: str) -> None:
subprocess.run(["git", *args], cwd=root, check=True, capture_output=True)
def _write_tools_file(path: Path, tools: dict, complete: bool = True) -> Path:
path.write_text(
json.dumps({"schema": 1, "complete": complete, "tools": tools}, indent=2) + "\n",
encoding="utf-8",
)
return path
def _real_tools() -> dict:
return {"python": sys.executable, "git": shutil.which("git"), "rg": shutil.which("rg")}
@pytest.fixture
def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""A minimal, fully-configured wiki instance: a git repo with identity,
@@ -72,6 +86,11 @@ def instance(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
_git(root, "config", "user.name", "Fixture Author")
_git(root, "config", "user.email", "fixture@example.com")
# What the preflight would have written: every tool this platform needs,
# at the path it really has on this machine.
tools_file = _write_tools_file(root / toolpaths.FILE_NAME, _real_tools())
monkeypatch.setattr(toolpaths, "tools_file", lambda: tools_file)
instructions_cmd.sync(force=False)
return root
@@ -558,3 +577,66 @@ def test_tasks_provider_names_an_active_override(instance, monkeypatch):
checks = doctor.run_doctor()
assert _status(checks, "tasks-provider") == "OK"
assert "WIKITOOL_TASKS_CONFIG" in _detail(checks, "tasks-provider")
# --- tool paths and the install folder (Gitea #151) --------------------------
def test_tool_paths_missing_file_is_a_fail_naming_the_preflight(instance):
(instance / toolpaths.FILE_NAME).unlink()
checks = doctor.run_doctor()
assert _status(checks, "tool-paths") == "FAIL"
fix = next(c.fix for c in checks if c.name == "tool-paths")
assert "tools/preflight.sh" in fix
def test_tool_paths_incomplete_run_is_a_fail(instance):
_write_tools_file(instance / toolpaths.FILE_NAME, _real_tools(), complete=False)
assert _status(doctor.run_doctor(), "tool-paths") == "FAIL"
def test_tool_paths_vanished_path_is_a_fail(instance):
tools = {**_real_tools(), "rg": str(instance / "gone" / "rg")}
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
checks = doctor.run_doctor()
assert _status(checks, "tool-paths") == "FAIL"
assert "no longer there: rg" in _detail(checks, "tool-paths")
# The same broken path makes the ripgrep check fail instead of finding rg on PATH.
assert _status(checks, "ripgrep") == "FAIL"
def test_tool_paths_unrecorded_tool_is_a_fail(instance):
tools = _real_tools()
del tools["git"]
_write_tools_file(instance / toolpaths.FILE_NAME, tools)
checks = doctor.run_doctor()
assert _status(checks, "tool-paths") == "FAIL"
assert "not recorded: git" in _detail(checks, "tool-paths")
def test_tool_paths_complete_file_is_ok(instance):
assert _status(doctor.run_doctor(), "tool-paths") == "OK"
def _folder_of(length: int) -> str:
return "C:\\" + "x" * (length - 3)
@pytest.mark.parametrize("length, longpaths, expected", [
(95, "0", "OK"),
(96, "0", "FAIL"),
(96, "1", "OK"),
])
def test_install_dir_limit_at_the_boundary(instance, monkeypatch, length, longpaths, expected):
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "windows")
monkeypatch.setenv(prerequisites.ENV_LONGPATHS, longpaths)
folder = _folder_of(length)
assert len(folder) == length
monkeypatch.setattr(prerequisites, "install_dir", lambda: folder)
assert _status(doctor.run_doctor(), "install-dir") == expected
def test_install_dir_is_not_limited_off_windows(instance, monkeypatch):
monkeypatch.setenv(prerequisites.ENV_PLATFORM, "linux")
monkeypatch.setattr(prerequisites, "install_dir", lambda: "/" + "x" * 300)
assert _status(doctor.run_doctor(), "install-dir") == "OK"
+529
View File
@@ -0,0 +1,529 @@
"""tools/preflight.sh, the launcher, `tools/trace-hook` and `toolpaths` (Gitea #151).
The preflight runs before Python is known to exist, so it is tested as what it
is: a POSIX shell script, run under every shell this machine has of dash and
bash, against a `PATH` built for the test. That `PATH` holds two directories -
the handful of utilities the script uses, symlinked one by one, and stubs for
the tools under test - so whether `rg` or `python3` is "installed" is decided
here and nowhere else. The stub Python fakes `-m venv` and `-m pip` as well, so
no test creates a real venv or touches the network; the CI workflows run the
preflight for real.
"""
from __future__ import annotations
import json
import os
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import pytest
from chemenu import config, prerequisites, titles, toolpaths
TOOLS = config._PACKAGE_ROOT / "tools"
# What preflight.sh, the launcher and trace-hook call besides shell built-ins
# and the tools under test. `iconv` is optional in the script itself.
UTILITIES = ("dirname", "uname", "sed", "tr", "wc", "iconv", "tail", "cat", "mv", "head",
"cut", "grep", "mkdir", "cp", "rm")
def _shells() -> list[str]:
found = {}
for name in ("dash", "bash", "sh"):
path = shutil.which(name)
if path:
found.setdefault(os.path.realpath(path), path)
return sorted(found.values())
SHELLS = _shells()
def _executable(path: Path, text: str) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(text, encoding="utf-8")
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return path
FAKE_PYTHON = """#!/bin/sh
# A Python that answers the preflight's probe as itself, and fakes venv and pip.
case "$1" in
-c)
case "$2" in
*hashlib*) exec "{real}" "$@" ;;
esac
printf '%s\\n%s\\n' "${{FAKE_PY_VERSION:-3.13}}" "$0"
exit 0 ;;
-m)
case "$2" in
venv)
shift 2
[ "$1" = --clear ] && shift
if [ -n "${{FAKE_VENV_FAIL:-}}" ]; then
echo "Error: ensurepip is not available" >&2
exit 1
fi
mkdir -p "$1/bin"
cp "$0" "$1/bin/python"
exit 0 ;;
pip)
shift 2
[ "$1" = --version ] && exit 0
echo "$*" >> "$PIP_LOG"
if [ -n "${{FAKE_PIP_FAIL:-}}" ]; then
echo "ERROR: Could not find a version that satisfies the requirement (network unreachable)" >&2
exit 1
fi
exit 0 ;;
esac ;;
esac
exit 1
"""
class Machine:
"""A stack tree plus a PATH of stubs, for one preflight run or several."""
def __init__(self, base: Path, root_name: str = "instance"):
self.base = base
self.root = base / root_name
self.tools = self.root / "tools"
self.tools.mkdir(parents=True)
for name in ("preflight.sh", "prerequisites.txt", "wikitool", "run_wikitool.py", "trace-hook"):
shutil.copy2(TOOLS / name, self.tools / name)
(self.tools / "requirements.txt").write_text("PyYAML\n", encoding="utf-8")
self.sysbin = base / "sysbin"
self.sysbin.mkdir(exist_ok=True)
for name in UTILITIES:
found = shutil.which(name)
if found and not (self.sysbin / name).exists():
(self.sysbin / name).symlink_to(found)
self.stubs = base / "stubs"
self.pip_log = base / "pip.log"
self.extra_env: dict[str, str] = {}
self.path_dirs: list[Path] = [self.stubs, self.sysbin]
def stub(self, name: str, text: str, where: Path | None = None) -> Path:
return _executable((where or self.stubs) / name, text)
def python(self, name: str = "python3", where: Path | None = None) -> Path:
return self.stub(name, FAKE_PYTHON.format(real=sys.executable), where)
def standard(self, rg: bool = True, pwsh: bool = False) -> "Machine":
self.python()
self.stub("git", "#!/bin/sh\necho 'git version 2.47.1'\n")
if rg:
self.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1 (rev 4649aa9700)'\n")
if pwsh:
self.stub("pwsh", "#!/bin/sh\necho 'PowerShell 7.6.6'\n")
return self
def run(self, *args: str, shell: str) -> subprocess.CompletedProcess:
env = {
"PATH": os.pathsep.join(str(d) for d in self.path_dirs),
"HOME": str(self.base),
"PIP_LOG": str(self.pip_log),
**self.extra_env,
}
return subprocess.run(
[shell, str(self.tools / "preflight.sh"), *args],
capture_output=True, text=True, env=env, timeout=60,
)
@property
def tools_file(self) -> Path:
return self.root / toolpaths.FILE_NAME
def recorded(self) -> dict:
return json.loads(self.tools_file.read_text(encoding="utf-8"))
def assert_guidance(output: str, *fragments: str) -> None:
"""Every exit-42 stop carries the block: what, why, the fixing command, what next."""
assert "STOP" in output
for marker in ("Why:", "Fix:", "Next:"):
assert marker in output, f"no {marker!r} in:\n{output}"
for fragment in fragments:
assert fragment in output, f"no {fragment!r} in:\n{output}"
@pytest.fixture
def machine(tmp_path: Path) -> Machine:
return Machine(tmp_path.resolve())
pytestmark = pytest.mark.skipif(not SHELLS, reason="no POSIX shell on this machine")
# --- the happy path ---------------------------------------------------------------
@pytest.mark.parametrize("shell", SHELLS)
def test_complete_path_records_absolute_paths_and_sets_up_the_venv(machine, shell):
machine.standard()
result = machine.run(shell=shell)
assert result.returncode == 0, result.stdout + result.stderr
data = machine.recorded()
assert data["schema"] == 1 and data["complete"] is True
assert set(data["tools"]) == {"python", "git", "rg"}
for path in data["tools"].values():
assert os.path.isabs(path) and os.path.isfile(path)
assert data["tools"]["rg"] == str(machine.stubs / "rg")
assert (machine.tools / ".venv" / "bin" / "python").is_file()
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
assert "Preflight passed" in result.stdout
@pytest.mark.parametrize("shell", SHELLS)
def test_second_run_changes_nothing(machine, shell):
machine.standard()
assert machine.run(shell=shell).returncode == 0
before = machine.tools_file.read_text(encoding="utf-8")
again = machine.run(shell=shell)
assert again.returncode == 0, again.stdout
assert machine.tools_file.read_text(encoding="utf-8") == before
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 1
# --- stop cases -------------------------------------------------------------------
@pytest.mark.parametrize("shell", SHELLS)
def test_missing_rg_stops_with_42_and_a_missing_line(machine, shell):
machine.standard(rg=False)
result = machine.run(shell=shell)
assert result.returncode == 42
assert any(line.split()[:2] == ["MISSING", "rg"] for line in result.stdout.splitlines())
assert_guidance(result.stdout, "ripgrep (rg) was not found", "--set rg=")
# What was found is kept for the next round of the loop, but not as ready.
data = machine.recorded()
assert data["complete"] is False and "rg" not in data["tools"]
assert not (machine.tools / ".venv").exists()
@pytest.mark.parametrize("shell", SHELLS)
def test_python_too_old_stops(machine, shell):
machine.standard()
machine.extra_env["FAKE_PY_VERSION"] = "3.9"
result = machine.run(shell=shell)
assert result.returncode == 42
assert "TOO_OLD" in result.stdout
assert_guidance(result.stdout, "Python 3.9 is too old")
@pytest.mark.parametrize("shell", SHELLS)
def test_invalid_set_path_writes_nothing(machine, shell):
machine.standard()
result = machine.run("--set", f"rg={machine.base / 'nowhere' / 'rg'}", shell=shell)
assert result.returncode == 42
assert_guidance(result.stdout, "The path given for ripgrep (rg) does not work")
assert not machine.tools_file.exists()
@pytest.mark.parametrize("shell", SHELLS)
def test_invalid_set_path_leaves_an_existing_file_alone(machine, shell):
machine.standard()
assert machine.run(shell=shell).returncode == 0
before = machine.tools_file.read_text(encoding="utf-8")
result = machine.run("--set", "git=/nowhere/git", shell=shell)
assert result.returncode == 42
assert machine.tools_file.read_text(encoding="utf-8") == before
@pytest.mark.parametrize("shell", SHELLS)
def test_valid_set_path_is_recorded_and_kept(machine, shell):
machine.standard(rg=False)
elsewhere = machine.stub("rg", "#!/bin/sh\necho 'ripgrep 14.1.1'\n", machine.base / "opt")
result = machine.run("--set", f"rg={elsewhere}", shell=shell)
assert result.returncode == 0, result.stdout
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
# The next run finds it in the file, although it is still not on PATH.
again = machine.run(shell=shell)
assert again.returncode == 0, again.stdout
assert machine.recorded()["tools"]["rg"] == str(elsewhere)
@pytest.mark.parametrize("shell", SHELLS)
def test_set_for_a_tool_this_platform_does_not_need_is_a_usage_error(machine, shell):
machine.standard()
result = machine.run("--set", "nonsense=/bin/true", shell=shell)
assert result.returncode == 1
assert not machine.tools_file.exists()
@pytest.mark.parametrize("shell", SHELLS)
def test_venv_that_cannot_be_created_stops(machine, shell):
machine.standard()
machine.extra_env["FAKE_VENV_FAIL"] = "1"
result = machine.run(shell=shell)
assert result.returncode == 42
assert_guidance(result.stdout, "tools/.venv) could not be created", "ensurepip is not available")
assert machine.recorded()["complete"] is False
@pytest.mark.parametrize("shell", SHELLS)
def test_pip_failure_stops_and_is_retried_next_time(machine, shell):
machine.standard()
machine.extra_env["FAKE_PIP_FAIL"] = "1"
result = machine.run(shell=shell)
assert result.returncode == 42
assert_guidance(result.stdout, "could not be installed into tools/.venv", "network unreachable")
assert machine.recorded()["complete"] is False
del machine.extra_env["FAKE_PIP_FAIL"]
assert machine.run(shell=shell).returncode == 0
assert machine.pip_log.read_text(encoding="utf-8").count("install") == 2
# --- the store alias ----------------------------------------------------------------
@pytest.mark.parametrize("shell", SHELLS)
@pytest.mark.parametrize("platform, alias", [("linux", "python3"), ("windows", "python")])
def test_store_alias_is_never_run_and_never_recorded(machine, shell, platform, alias):
marker = machine.base / "alias-was-run"
apps = machine.base / "Users" / "u" / "AppData" / "Local" / "Microsoft" / "WindowsApps"
machine.stub(alias, f"#!/bin/sh\necho ran > '{marker}'\nexit 9009\n", apps)
machine.path_dirs.insert(0, apps)
machine.standard(pwsh=True)
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": platform, "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
if platform == "windows":
machine.python("python") # the real one, behind the alias on PATH
result = machine.run(shell=shell)
assert result.returncode == 0, result.stdout
recorded = machine.recorded()["tools"]["python"]
assert "WindowsApps" not in recorded
assert recorded.startswith(str(machine.stubs))
assert not marker.exists()
@pytest.mark.parametrize("shell", SHELLS)
def test_windows_needs_pwsh_too(machine, shell):
machine.standard(pwsh=False)
machine.extra_env.update({"CHEMENU_PREFLIGHT_PLATFORM": "windows", "CHEMENU_PREFLIGHT_LONGPATHS": "1"})
result = machine.run(shell=shell)
assert result.returncode == 42
assert_guidance(result.stdout, "PowerShell 7 (pwsh) was not found")
# --- install folder length (D32) --------------------------------------------------
def _machine_with_root_of(tmp_path: Path, length: int) -> Machine:
base = tmp_path.resolve()
name_length = length - len(str(base)) - 1
assert name_length > 0, "tmp_path is too long for this test"
machine = Machine(base, "r" * name_length)
assert len(str(machine.root)) == length
machine.standard(pwsh=True)
machine.extra_env["CHEMENU_PREFLIGHT_PLATFORM"] = "windows"
return machine
@pytest.mark.parametrize("shell", SHELLS)
@pytest.mark.parametrize("length, longpaths, expected", [
(95, "0", 0),
(96, "0", 42),
(96, "1", 0),
])
def test_install_folder_limit_at_the_boundary(tmp_path, shell, length, longpaths, expected):
machine = _machine_with_root_of(tmp_path, length)
machine.extra_env["CHEMENU_PREFLIGHT_LONGPATHS"] = longpaths
result = machine.run(shell=shell)
assert result.returncode == expected, result.stdout
if expected == 42:
assert_guidance(result.stdout, f"too long ({length} characters, at most 95)", "C:\\Chemenu")
assert not (machine.tools / ".venv").exists()
def test_manifest_limit_and_path_budget_fit_max_path_together():
"""95 + separator + 160 must stay within Windows' 259 - the two numbers are
one sum, kept in two places (tools/prerequisites.txt, titles.PATH_BUDGET)."""
limit = prerequisites.load_manifest().limits["install_dir_max"]
assert limit + 1 + titles.PATH_BUDGET <= 259
def test_manifest_names_the_tools_the_stack_starts():
manifest = prerequisites.load_manifest()
assert [t.name for t in manifest.tools_for("linux")] == ["python", "git", "rg"]
assert [t.name for t in manifest.tools_for("windows")] == ["python", "git", "rg", "pwsh"]
# --- the launcher -----------------------------------------------------------------
def _launch(machine: Machine, *args: str) -> subprocess.CompletedProcess:
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
return subprocess.run([str(machine.tools / "wikitool"), *args],
capture_output=True, text=True, env=env, timeout=30)
ECHO_PYTHON = "#!/bin/sh\nprintf '%s\\n' \"$@\"\n"
def _complete_file(machine: Machine, complete: bool = True) -> None:
machine.tools_file.write_text(json.dumps(
{"schema": 1, "complete": complete, "tools": {"git": "/usr/bin/git"}}, indent=2),
encoding="utf-8")
@pytest.mark.parametrize("state", ["no file", "no venv", "incomplete"])
def test_launcher_stops_with_42_until_the_preflight_has_passed(machine, state):
if state != "no file":
_complete_file(machine, complete=(state != "incomplete"))
if state != "no venv":
machine.stub("python", ECHO_PYTHON, machine.tools / ".venv" / "bin")
result = _launch(machine, "doctor")
assert result.returncode == 42
assert "tools/preflight.sh" in result.stderr
assert "Traceback" not in result.stdout + result.stderr
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
def test_launcher_runs_the_entry_script_with_either_venv_layout(machine, layout):
_complete_file(machine)
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
result = _launch(machine, "doctor", "--json")
assert result.returncode == 0, result.stderr
assert result.stdout.splitlines() == [str(machine.tools / "run_wikitool.py"), "doctor", "--json"]
def test_there_is_a_powershell_launcher_slot_but_no_cmd():
assert (TOOLS / "wikitool").is_file()
assert not (TOOLS / "wikitool.cmd").exists()
# --- trace-hook -------------------------------------------------------------------
def _hook(machine: Machine, *args: str) -> subprocess.CompletedProcess:
env = {"PATH": os.pathsep.join(str(d) for d in machine.path_dirs), "HOME": str(machine.base)}
return subprocess.run([str(machine.tools / "trace-hook"), *args],
capture_output=True, text=True, env=env, timeout=30)
@pytest.mark.parametrize("layout", [("bin", "python"), ("Scripts", "python.exe")])
def test_trace_hook_runs_trace_ingest_with_the_venv_python(machine, layout):
machine.stub(layout[1], ECHO_PYTHON, machine.tools / ".venv" / layout[0])
result = _hook(machine, "--source", "claude-code", "--event", "prompt.submitted")
assert result.returncode == 0
assert result.stdout.splitlines() == [
str(machine.tools / "trace_ingest.py"), "--source", "claude-code", "--event", "prompt.submitted",
]
def test_trace_hook_is_silent_without_a_venv(machine):
result = _hook(machine, "--source", "claude-code")
assert result.returncode == 0
assert result.stdout == "" and result.stderr == ""
def _hook_commands() -> list[str]:
root = config._PACKAGE_ROOT
commands = []
claude = json.loads((root / ".claude" / "settings.json").read_text(encoding="utf-8"))
for entries in claude["hooks"].values():
for entry in entries:
commands += [hook["command"] for hook in entry["hooks"]]
copilot = json.loads((root / ".github" / "hooks" / "wiki-trace.json").read_text(encoding="utf-8"))
for entries in copilot["hooks"].values():
for hook in entries:
commands += [hook["bash"], hook["powershell"]]
import tomllib
vibe = tomllib.loads((root / ".vibe" / "hooks.toml").read_text(encoding="utf-8"))
commands += [hook["command"] for hook in vibe["hooks"]]
return commands
def test_no_hook_relies_on_a_shebang_or_a_bare_python():
commands = _hook_commands()
assert len(commands) > 10
for command in commands:
assert command.startswith(("./tools/trace-hook ", ".\\tools\\.venv\\Scripts\\python.exe ")), command
# --- toolpaths --------------------------------------------------------------------
def _point_at(monkeypatch, path: Path) -> Path:
monkeypatch.setattr(toolpaths, "tools_file", lambda: path)
return path
def test_no_file_means_the_bare_name(tmp_path, monkeypatch):
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME)
assert toolpaths.git() == "git" and toolpaths.rg() == "rg"
def test_recorded_path_is_used(tmp_path, monkeypatch):
rg = _executable(tmp_path / "bin" / "rg", "#!/bin/sh\n")
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
json.dumps({"schema": 1, "complete": True, "tools": {"rg": str(rg)}}), encoding="utf-8")
assert toolpaths.rg() == str(rg)
@pytest.mark.parametrize("content, message", [
('{"schema": 1, "complete": true, "tools": {}}', "not recorded"),
('{"schema": 1, "complete": true, "tools": {"git": "/nowhere/git"}}', "no longer exists"),
("{not json", "not valid JSON"),
('{"schema": 2, "tools": {}}', "shape this version reads"),
])
def test_a_present_but_unusable_file_never_falls_back_to_path(tmp_path, monkeypatch, content, message):
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(content, encoding="utf-8")
with pytest.raises(toolpaths.ToolPathError, match=message) as caught:
toolpaths.git()
assert "tools/preflight.sh" in str(caught.value)
assert not isinstance(caught.value, OSError)
def _logging_wrapper(directory: Path, name: str, log: Path) -> Path:
real = shutil.which(name)
assert real, f"{name} is needed for this test"
return _executable(directory / name, f"#!/bin/sh\necho \"$@\" >> '{log}'\nexec '{real}' \"$@\"\n")
def test_git_and_rg_start_from_the_recorded_paths_not_from_path(tmp_path, monkeypatch):
log = tmp_path / "calls.log"
hidden = tmp_path / "not-on-path"
git = _logging_wrapper(hidden, "git", log)
rg = _logging_wrapper(hidden, "rg", log)
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(json.dumps(
{"schema": 1, "complete": True, "tools": {"git": str(git), "rg": str(rg)}}), encoding="utf-8")
repo = tmp_path / "repo"
repo.mkdir()
subprocess.run(["git", "init", "-q", "-b", "main"], cwd=repo, check=True)
subprocess.run(["git", "config", "user.name", "Recorded Git"], cwd=repo, check=True)
(repo / "kb").mkdir()
(repo / "kb" / "Page.md").write_text("---\ntitle: Page\n---\nkingfisher\n", encoding="utf-8")
monkeypatch.setattr(config, "ROOT", repo)
monkeypatch.setenv("PATH", str(tmp_path / "empty"))
assert config.default_author() == "Recorded Git"
from chemenu.search.base import SearchQuery
from chemenu.search.ripgrep import RipgrepBackend
backend = RipgrepBackend(search_root=repo / "kb", repo_root=repo)
backend.search(SearchQuery(text="kingfisher"), {})
calls = log.read_text(encoding="utf-8")
assert "config user.name" in calls
assert "kingfisher" in calls
def test_cli_turns_a_tool_path_error_into_an_error_line(tmp_path, monkeypatch, capsys):
from chemenu import cli
_point_at(monkeypatch, tmp_path / toolpaths.FILE_NAME).write_text(
'{"schema": 1, "complete": true, "tools": {}}', encoding="utf-8")
monkeypatch.setattr(sys, "argv", ["wikitool", "search", "kingfisher"])
with pytest.raises(SystemExit) as exited:
cli._run_traced("search", ["kingfisher"])
assert exited.value.code == 1
out = capsys.readouterr().out
assert "ERROR" in out and "tools/preflight.sh" in out