From b9fade7996371a2a1294cef17ec5dc738f4ae43a Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sun, 18 Oct 2026 18:46:45 +0200 Subject: [PATCH 01/31] release: 0.6.5 --- README.md | 4 ++-- packages/npm/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index fefae44..c885c6a 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Reporails CLI (v0.6.4) +# Reporails CLI (v0.6.5) > **AI Instruction Diagnostics for coding agents. Validates the entire agentic instruction system against 120+ rules across six rule packs (core + per-agent). Supports Antigravity, Claude, Codex, Copilot, and Cursor.** > @@ -99,7 +99,7 @@ Full breakdown: [Tiers and Limits](https://github.com/reporails/cli/blob/main/do Run on every PR so instruction-quality regressions (vague or buried instructions, oversized files, weak reinforcement, instructions repeated across files) get caught the same way test or lint regressions do — before merge, not after a teammate's agent has been silently misbehaving for a week. ```yaml -- uses: reporails/cli/action@0.6.4 +- uses: reporails/cli/action@0.6.5 with: api-key: ${{ secrets.REPORAILS_API_KEY }} # optional - a Pro key unlocks the full diagnostic detail strict: "true" # exit 1 if any rule fires diff --git a/packages/npm/package.json b/packages/npm/package.json index 6005cbe..73b9d8e 100644 --- a/packages/npm/package.json +++ b/packages/npm/package.json @@ -1,6 +1,6 @@ { "name": "@reporails/cli", - "version": "0.6.4", + "version": "0.6.5", "description": "AI instruction diagnostics for coding agents", "type": "module", "bin": { diff --git a/pyproject.toml b/pyproject.toml index 284bac7..ef9ecfc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "reporails-cli" -version = "0.6.4" +version = "0.6.5" description = "AI instruction diagnostics for coding agents" readme = "README.md" license = "BUSL-1.1 AND LicenseRef-Reporails-Model" diff --git a/uv.lock b/uv.lock index 18a8f30..e0eaf47 100644 --- a/uv.lock +++ b/uv.lock @@ -1422,7 +1422,7 @@ wheels = [ [[package]] name = "reporails-cli" -version = "0.6.4" +version = "0.6.5" source = { editable = "." } dependencies = [ { name = "filelock" }, From f68910e6dabf82fcc821da620b47b11b1d6b1433 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sat, 17 Oct 2026 11:03:42 +0200 Subject: [PATCH 02/31] expected-loads: the plugin's context x-ray can ask which instruction files Claude Code loads in a folder and why an AGENTS.md is skipped; a user-level rule reads as loaded at session start --- UNRELEASED.md | 4 + .../core/discovery/agent_discovery.py | 13 ++ .../core/discovery/read_gates.py | 71 +++++++-- src/reporails_cli/core/mapper/inspect.py | 27 ++++ .../core/pipeline/expected_loads.py | 64 ++++++++ .../core/platform/config/bootstrap.py | 5 + .../interfaces/cli/expected_loads_command.py | 29 ++++ src/reporails_cli/interfaces/cli/main.py | 20 ++- tests/unit/test_expected_loads.py | 145 ++++++++++++++++++ 9 files changed, 365 insertions(+), 13 deletions(-) create mode 100644 src/reporails_cli/core/pipeline/expected_loads.py create mode 100644 src/reporails_cli/interfaces/cli/expected_loads_command.py create mode 100644 tests/unit/test_expected_loads.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 3052d77..4ba72f7 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,10 +2,14 @@ ### Added +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped. + ### Changed ### Fixed +- `ails check` reads a rule in your user rules folder (Claude Code's `~/.claude/rules/`, Antigravity's `~/.gemini/config/rules/`) as loaded at session start, as the agent loads it. + ### Removed ### Internal diff --git a/src/reporails_cli/core/discovery/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py index a4700a6..e6ceb8c 100644 --- a/src/reporails_cli/core/discovery/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -434,6 +434,19 @@ def _discover_surface(scope: _AgentScope, ft_name: str, spec: JsonObject) -> tup return bucket, drop_shadowed_fallbacks(found, scope.agent_id, ft_name, scope.project_config) +def discover_unread_candidates( + target: Path, agent_id: str, file_type_names: tuple[str, ...], excluded: frozenset[str] +) -> list[Path]: + """The files the named file types of `agent_id` match under `target`, before any read gate narrows them.""" + file_types = load_config_file_types(agent_id) or {} + found: list[Path] = [] + for name in file_type_names: + spec = file_types.get(name) + if isinstance(spec, dict): + found.extend(glob_file_type_patterns(target, extract_patterns(spec), extract_properties(spec), excluded)) + return _dedupe_by_canonical(found) + + def discover_from_config( target: Path, agent_id: str, diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index e285ab5..228e93b 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -17,12 +17,15 @@ from __future__ import annotations from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path +from typing import Literal from reporails_cli.core.discovery.project_root import resolve_project_root from reporails_cli.core.discovery.walk import list_dir, safe_resolve from reporails_cli.core.platform.config.claude_settings import ( MODE_CLAUDE_MD_AND_AGENTS_MD, + MODE_CLAUDE_MD_OR_AGENTS_MD, MODES_WITHOUT_AGENTS_MD, claude_instruction_files_mode, ) @@ -80,21 +83,62 @@ def _loads_at_session_start(path: Path, root: Path) -> bool: return path.parent in (root, root / ".claude") -def claude_agents_md_files(found: list[Path], target: Path) -> list[Path]: - """The `AGENTS.md` files among `found` that Claude Code loads for a session in `target`.""" - if not found: - return [] +SkipReason = Literal["claude_md_present", "subdir_has_claude_md", "setting_off", "never_read"] + + +@dataclass(frozen=True) +class SkippedFile: + """An `AGENTS.md` Claude Code does not read, and why.""" + + path: Path + reason: SkipReason + + +@dataclass(frozen=True) +class AgentsMdReads: + """The `AGENTS.md` files Claude Code loads, the ones it skips, and the setting that decided.""" + + kept: list[Path] + skipped: list[SkippedFile] + mode: str + + +def claude_agents_md_reads(found: list[Path], target: Path) -> AgentsMdReads: + """Split the `AGENTS.md` files among `found` into those Claude Code loads for a session in + `target` and those it skips, each skipped file with its reason.""" root = resolve_project_root(target) mode = claude_instruction_files_mode(root) + kept: list[Path] = [] + skipped: list[SkippedFile] = [] + claude_above = mode == MODE_CLAUDE_MD_OR_AGENTS_MD and claude_file_at_or_above(root) + user_file = _user_claude_md() + for path in found: + reason = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) + if reason is None: + kept.append(path) + else: + skipped.append(SkippedFile(path, reason)) + return AgentsMdReads(kept, skipped, mode) + + +def _skip_reason(path: Path, root: Path, mode: str, *, claude_above: bool, user_file: Path) -> SkipReason | None: + """Why Claude Code skips `path`, None when it loads it.""" + if _never_read(path, root): + return "never_read" if mode in MODES_WITHOUT_AGENTS_MD: - return [] - readable = [f for f in found if not _never_read(f, root)] + return "setting_off" if mode == MODE_CLAUDE_MD_AND_AGENTS_MD: - return readable - if claude_file_at_or_above(root): - return [] - user_file = _user_claude_md() - return [f for f in readable if _loads_at_session_start(f, root) or not _has_claude_file(f.parent, user_file)] + return None + if claude_above: + return "claude_md_present" + if _loads_at_session_start(path, root) or not _has_claude_file(path.parent, user_file): + return None + return "subdir_has_claude_md" + + +def claude_agents_md_files(found: list[Path], target: Path) -> list[Path]: + """The `AGENTS.md` files among `found` that Claude Code loads for a session in `target`.""" + return claude_agents_md_reads(found, target).kept # (agent, file type) -> the files among a discovered list that the agent really reads. @@ -107,6 +151,11 @@ def claude_agents_md_files(found: list[Path], target: Path) -> list[Path]: _FALLBACK_READS: dict[str, frozenset[str]] = {"claude": frozenset({_AGENTS_MD})} +def gated_file_types(agent_id: str) -> tuple[str, ...]: + """The file types of `agent_id` whose files a read gate narrows.""" + return tuple(file_type for agent, file_type in _GATES if agent == agent_id) + + def apply_read_gate(agent_id: str, file_type: str, found: list[Path], target: Path) -> list[Path]: """`found`, narrowed to the files `agent_id` reads for `file_type` in a session at `target`.""" gate = _GATES.get((agent_id, file_type)) diff --git a/src/reporails_cli/core/mapper/inspect.py b/src/reporails_cli/core/mapper/inspect.py index 9330214..a5600bf 100644 --- a/src/reporails_cli/core/mapper/inspect.py +++ b/src/reporails_cli/core/mapper/inspect.py @@ -103,6 +103,8 @@ def _load_registry() -> dict[str, JsonObject]: _ACTIVATION_KEYS = ( + "loading", + "precedence", "activation", "path_key", "always_key", @@ -379,3 +381,28 @@ def file_type_of(path: Path, root: Path, registry: dict[str, JsonObject]) -> str rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else path.as_posix() match = _find_best_registry_match(rel.lower(), registry, path, root) return match[1] if match is not None else "generic" + + +class FileRead(NamedTuple): + """How an agent reads a file: its file type, when it loads and from which precedence scope.""" + + file_type: str + loading: str + precedence: str + + +def file_read_by(agent_id: str, path: Path, root: Path, registry: dict[str, JsonObject]) -> FileRead | None: + """The file type, loading and precedence `agent_id`'s own file types give `path`, None when none matches. + + Loading is the mapper's own (`_detect_file_activation`); precedence is the matched location scope's. + """ + config = registry.get(agent_id) + if config is None: + return None + own = {agent_id: config} + rel = path.relative_to(root).as_posix() if path.is_relative_to(root) else path.as_posix() + match = _find_best_registry_match(rel.lower(), own, path, root) + if match is None: + return None + loading, _, _, _, file_type, _ = _detect_file_activation(path, root, own) + return FileRead(file_type, loading, str(match[2].get("precedence", ""))) diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py new file mode 100644 index 0000000..15e843e --- /dev/null +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -0,0 +1,64 @@ +"""The instruction files Claude Code loads for a session in a folder, and the AGENTS.md files it skips.""" + +from __future__ import annotations + +from pathlib import Path + +from reporails_cli.core.discovery.agent_discovery import discover_unread_candidates +from reporails_cli.core.discovery.project_root import resolve_project_root +from reporails_cli.core.discovery.read_gates import claude_agents_md_reads, gated_file_types +from reporails_cli.core.discovery.walk import safe_resolve +from reporails_cli.core.mapper.inspect import _load_registry, file_read_by +from reporails_cli.core.pipeline.mapping import discover_scope +from reporails_cli.core.platform.config.bootstrap import get_xray_log_dir +from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue + +AGENT = "claude" + + +def expected_loads(target: Path) -> JsonObject: + """The files Claude Code loads for a session in `target` (instruction and rule files, no config + surfaces) with their file type, loading mode and precedence, and the AGENTS.md files it skips.""" + root = resolve_project_root(target) + scope = discover_scope(root, AGENT, None, scan_files=False) + registry = _load_registry() + paths = sorted({p for agent in scope.filtered for p in (*agent.instruction_files, *agent.rule_files)}) + files: list[JsonValue] = [] + for path in paths: + read = file_read_by(AGENT, path, root, registry) + if read is not None: + files.append( + { + "path": str(path), + "file_type": read.file_type, + "loading": read.loading, + "precedence": read.precedence, + } + ) + exclusions = scope.exclusions + candidates = discover_unread_candidates( + root, AGENT, gated_file_types(AGENT), exclusions.walk_dirs if exclusions else frozenset() + ) + reads = claude_agents_md_reads([p for p in candidates if exclusions is None or not exclusions.leaves_out(p)], root) + skipped: list[JsonValue] = [ + { + "path": str(item.path), + "file_type": _file_type_of_skipped(item.path, root, registry), + "reason": item.reason, + "setting": reads.mode, + } + for item in sorted(reads.skipped, key=lambda s: s.path) + ] + return { + "version": 1, + "agent": AGENT, + "root": str(safe_resolve(root)), + "event_log_dir": str(get_xray_log_dir()), + "files": files, + "skipped": skipped, + } + + +def _file_type_of_skipped(path: Path, root: Path, registry: dict[str, JsonObject]) -> str: + read = file_read_by(AGENT, path, root, registry) + return read.file_type if read is not None else "agents_md" diff --git a/src/reporails_cli/core/platform/config/bootstrap.py b/src/reporails_cli/core/platform/config/bootstrap.py index a6df81c..a281165 100644 --- a/src/reporails_cli/core/platform/config/bootstrap.py +++ b/src/reporails_cli/core/platform/config/bootstrap.py @@ -40,6 +40,11 @@ def get_daemon_dir() -> Path: return REPORAILS_HOME / "daemon" +def get_xray_log_dir() -> Path: + """Get ~/.reporails/xray/ — per-session event logs of the context x-ray.""" + return REPORAILS_HOME / "xray" + + def get_global_cache_dir() -> Path: """Get ~/.reporails/cache/ — shared map-cache location.""" return REPORAILS_HOME / "cache" diff --git a/src/reporails_cli/interfaces/cli/expected_loads_command.py b/src/reporails_cli/interfaces/cli/expected_loads_command.py new file mode 100644 index 0000000..1e1323c --- /dev/null +++ b/src/reporails_cli/interfaces/cli/expected_loads_command.py @@ -0,0 +1,29 @@ +"""CLI command: ails expected-loads — the instruction files an agent loads for a session in a folder.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from reporails_cli.interfaces.cli.typer_app import app + + +@app.command("expected-loads", hidden=True) +def expected_loads_command( + path: str = typer.Argument(".", help="Project folder"), # typer declares the default + agent: str = typer.Option("claude", "--agent", "-a", help="Agent (claude)"), +) -> None: + """Print, as one JSON object, the files the agent loads for a session in PATH and the AGENTS.md files it skips.""" + from reporails_cli.core.pipeline.expected_loads import ( # pylint: disable=import-outside-toplevel # startup: loads when the command runs + AGENT, + expected_loads, + ) + from reporails_cli.core.platform.utils.utils import ( # pylint: disable=import-outside-toplevel # startup: loads when the command runs + compact_json, + ) + + if agent != AGENT: + typer.echo(f"Error: only --agent {AGENT} is supported", err=True) + raise typer.Exit(2) + typer.echo(compact_json(expected_loads(Path(path).resolve()))) diff --git a/src/reporails_cli/interfaces/cli/main.py b/src/reporails_cli/interfaces/cli/main.py index 1239250..8ee9235 100644 --- a/src/reporails_cli/interfaces/cli/main.py +++ b/src/reporails_cli/interfaces/cli/main.py @@ -9,7 +9,15 @@ import logging import sys -from reporails_cli.interfaces.cli import check_command, commands, install, login_command, rules_command, test_command +from reporails_cli.interfaces.cli import ( + check_command, + commands, + expected_loads_command, + install, + login_command, + rules_command, + test_command, +) from reporails_cli.interfaces.cli.config_command import config_app from reporails_cli.interfaces.cli.daemon_cmd import daemon_app from reporails_cli.interfaces.cli.stopwords_command import stopwords_app @@ -18,7 +26,15 @@ logger = logging.getLogger(__name__) # The modules whose commands register on `app` when they are imported. -COMMAND_MODULES = (check_command, commands, install, login_command, rules_command, test_command) +COMMAND_MODULES = ( + check_command, + commands, + expected_loads_command, + install, + login_command, + rules_command, + test_command, +) # Force UTF-8 on stdout/stderr so the box-drawing and arrow glyphs in the rich # scorecard and the `-f md` output do not crash on consoles whose default diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py new file mode 100644 index 0000000..5d37102 --- /dev/null +++ b/tests/unit/test_expected_loads.py @@ -0,0 +1,145 @@ +"""`ails expected-loads`: the files Claude Code loads for a session in a folder, and the AGENTS.md it skips.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from reporails_cli.core.pipeline.expected_loads import expected_loads +from reporails_cli.core.platform.config import claude_settings +from reporails_cli.interfaces.cli.main import app + +runner = CliRunner() + + +def _write(path: Path, text: str = "# Notes\n\nRun the tests.\n") -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _mode(mode: str) -> None: + settings = Path.home() / ".claude" / "settings.json" + data = {"pluginConfigs": {"agents-md@builtin": {"options": {"instructionFiles": mode}}}} + _write(settings, json.dumps(data)) + + +@pytest.fixture(autouse=True) +def _no_managed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + real = claude_settings._settings_patterns + + def patterns() -> dict[str, list[str]]: + scopes = real() + scopes["managed"] = [str(tmp_path / "managed" / "managed-settings.json")] + scopes["managed_dropin"] = [str(tmp_path / "managed" / "d" / "*.json")] + return scopes + + monkeypatch.setattr(claude_settings, "_settings_patterns", patterns) + + +def _project(tmp_path: Path) -> Path: + return tmp_path / "proj" + + +def _by_name(items: list[dict], root: Path) -> dict[str, dict]: + return {Path(i["path"]).relative_to(root).as_posix(): i for i in items} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_beside_claude_md_is_skipped(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / "AGENTS.md") + out = expected_loads(root) + assert _by_name(out["files"], root)["CLAUDE.md"]["file_type"] == "main" + assert "AGENTS.md" not in _by_name(out["files"], root) + skipped = _by_name(out["skipped"], root)["AGENTS.md"] + assert skipped["reason"] == "claude_md_present" + assert skipped["setting"] == "claude-md-or-agents-md" + assert skipped["file_type"] == "agents_md" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_lone_agents_md_loads_at_session_start(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "AGENTS.md") + out = expected_loads(root) + entry = _by_name(out["files"], root)["AGENTS.md"] + assert (entry["file_type"], entry["loading"], entry["precedence"]) == ("agents_md", "session_start", "project") + assert out["skipped"] == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_and_mode_loads_both(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / "AGENTS.md") + _mode("claude-md-and-agents-md") + out = expected_loads(root) + assert {"CLAUDE.md", "AGENTS.md"} <= set(_by_name(out["files"], root)) + assert out["skipped"] == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_claude_md_mode_skips_agents_md_as_setting_off(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "AGENTS.md") + _mode("claude-md") + skipped = _by_name(expected_loads(root)["skipped"], root)["AGENTS.md"] + assert (skipped["reason"], skipped["setting"]) == ("setting_off", "claude-md") + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_dir_is_never_read(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / ".agents" / "AGENTS.md") + skipped = _by_name(expected_loads(root)["skipped"], root) + assert skipped[".agents/AGENTS.md"]["reason"] == "never_read" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_nested_agents_md_beside_its_own_claude_md_is_skipped(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "sub" / "AGENTS.md") + _write(root / "sub" / "CLAUDE.md") + _write(root / "other" / "AGENTS.md") + out = expected_loads(root) + assert _by_name(out["skipped"], root)["sub/AGENTS.md"]["reason"] == "subdir_has_claude_md" + loaded = _by_name(out["files"], root)["other/AGENTS.md"] + assert (loaded["file_type"], loaded["loading"]) == ("nested_context", "on_demand") + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_config_surfaces_are_not_listed(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / ".claude" / "settings.json", "{}") + _write(root / ".claude" / "rules" / "style.md") + names = set(_by_name(expected_loads(root)["files"], root)) + assert names == {"CLAUDE.md", ".claude/rules/style.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_command_prints_one_json_object(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + result = runner.invoke(app, ["expected-loads", str(root), "--agent", "claude"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert list(data) == ["version", "agent", "root", "event_log_dir", "files", "skipped"] + assert data["version"] == 1 + assert data["agent"] == "claude" + assert data["event_log_dir"] == str(Path.home() / ".reporails" / "xray") + assert set(data["files"][0]) == {"path", "file_type", "loading", "precedence"} From 89656b6d66201a6c3a55470836f586c63afc5183 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sat, 17 Oct 2026 14:28:47 +0200 Subject: [PATCH 03/31] expected-loads: an AGENTS.md that links to a CLAUDE.md Claude Code reads is reported as that file, not as skipped --- UNRELEASED.md | 2 +- .../core/discovery/read_gates.py | 36 +++++++++++++++++-- .../core/pipeline/expected_loads.py | 4 +++ tests/unit/test_expected_loads.py | 28 ++++++++++++++- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 4ba72f7..42c0f27 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md is reported as that file rather than as skipped. ### Changed diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index 228e93b..b6eb507 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -94,13 +94,23 @@ class SkippedFile: reason: SkipReason +@dataclass(frozen=True) +class AliasFile: + """An `AGENTS.md` that links to a `CLAUDE.md` Claude Code reads: the same file under another name.""" + + path: Path + same_as: Path + + @dataclass(frozen=True) class AgentsMdReads: - """The `AGENTS.md` files Claude Code loads, the ones it skips, and the setting that decided.""" + """The `AGENTS.md` files Claude Code loads, the ones it skips, the ones that are a `CLAUDE.md` + it reads under another name, and the setting that decided.""" kept: list[Path] skipped: list[SkippedFile] mode: str + aliases: list[AliasFile] def claude_agents_md_reads(found: list[Path], target: Path) -> AgentsMdReads: @@ -110,15 +120,35 @@ def claude_agents_md_reads(found: list[Path], target: Path) -> AgentsMdReads: mode = claude_instruction_files_mode(root) kept: list[Path] = [] skipped: list[SkippedFile] = [] + aliases: list[AliasFile] = [] claude_above = mode == MODE_CLAUDE_MD_OR_AGENTS_MD and claude_file_at_or_above(root) user_file = _user_claude_md() for path in found: reason = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) if reason is None: kept.append(path) - else: + continue + same_as = _linked_claude_md(path, root) + if same_as is None: skipped.append(SkippedFile(path, reason)) - return AgentsMdReads(kept, skipped, mode) + else: + aliases.append(AliasFile(path, same_as)) + return AgentsMdReads(kept, skipped, mode, aliases) + + +def _linked_claude_md(path: Path, root: Path) -> Path | None: + """The `CLAUDE.md` Claude Code reads that `path` links to, None when it is not such a link.""" + target = safe_resolve(path) + if target.name.lower() != "claude.md" or not target.is_file(): + return None + resolved_root = safe_resolve(root) + read_dirs = {resolved_root, *resolved_root.parents, safe_resolve(path.parent)} + if resolved_root in (target.parent, *target.parent.parents): + read_dirs.add(target.parent) + folder = target.parent + if folder.name == ".claude": + folder = folder.parent + return target if folder in read_dirs else None def _skip_reason(path: Path, root: Path, mode: str, *, claude_above: bool, user_file: Path) -> SkipReason | None: diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 15e843e..895b867 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -49,6 +49,9 @@ def expected_loads(target: Path) -> JsonObject: } for item in sorted(reads.skipped, key=lambda s: s.path) ] + aliases: list[JsonValue] = [ + {"path": str(item.path), "same_as": str(item.same_as)} for item in sorted(reads.aliases, key=lambda a: a.path) + ] return { "version": 1, "agent": AGENT, @@ -56,6 +59,7 @@ def expected_loads(target: Path) -> JsonObject: "event_log_dir": str(get_xray_log_dir()), "files": files, "skipped": skipped, + "aliases": aliases, } diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 5d37102..a0e3cb6 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -138,8 +138,34 @@ def test_command_prints_one_json_object(tmp_path: Path) -> None: result = runner.invoke(app, ["expected-loads", str(root), "--agent", "claude"]) assert result.exit_code == 0 data = json.loads(result.stdout) - assert list(data) == ["version", "agent", "root", "event_log_dir", "files", "skipped"] + assert list(data) == ["version", "agent", "root", "event_log_dir", "files", "skipped", "aliases"] assert data["version"] == 1 assert data["agent"] == "claude" assert data["event_log_dir"] == str(Path.home() / ".reporails" / "xray") assert set(data["files"][0]) == {"path", "file_type", "loading", "precedence"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_linked_to_claude_md_is_reported_as_that_file(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / "sub" / "CLAUDE.md") + _write(root / "sub2" / "CLAUDE.md") + _write(root / "sub2" / "AGENTS.md") + _write(root / "sub3" / "CLAUDE.md") + _write(root / "sub3" / "notes.md") + try: + (root / "AGENTS.md").symlink_to("CLAUDE.md") + (root / "sub" / "AGENTS.md").symlink_to("CLAUDE.md") + (root / "sub3" / "AGENTS.md").symlink_to("notes.md") + except OSError: + pytest.skip("symlinks cannot be created here") + out = expected_loads(root) + resolved = root.resolve() + assert out["aliases"] == [ + {"path": str(resolved / "AGENTS.md"), "same_as": str(resolved / "CLAUDE.md")}, + {"path": str(resolved / "sub" / "AGENTS.md"), "same_as": str(resolved / "sub" / "CLAUDE.md")}, + ] + skipped = {Path(i["path"]).relative_to(resolved).as_posix(): i["reason"] for i in out["skipped"]} + assert skipped == {"sub2/AGENTS.md": "claude_md_present", "sub3/AGENTS.md": "claude_md_present"} From 2ed2d61e7c0e28878a3804f27e0d8806bc1a8ead Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sat, 17 Oct 2026 15:44:54 +0200 Subject: [PATCH 04/31] expected-loads: a user rule keeps its paths, links are found by path identity and each listed by name, config excludes and managed-only are honoured, a bad path is refused --- UNRELEASED.md | 2 - .../core/discovery/agent_discovery.py | 82 ++++++++---- .../core/discovery/read_gates.py | 42 +++--- src/reporails_cli/core/mapper/inspect.py | 1 - .../core/pipeline/expected_loads.py | 77 +++++++---- src/reporails_cli/core/pipeline/mapping.py | 4 +- .../core/platform/config/claude_settings.py | 3 + .../interfaces/cli/expected_loads_command.py | 6 +- tests/unit/test_expected_loads.py | 124 ++++++++++++++++++ tests/unit/test_inspect_activation_modes.py | 19 +++ 10 files changed, 287 insertions(+), 73 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 42c0f27..6aaa029 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -8,8 +8,6 @@ ### Fixed -- `ails check` reads a rule in your user rules folder (Claude Code's `~/.claude/rules/`, Antigravity's `~/.gemini/config/rules/`) as loaded at session start, as the agent loads it. - ### Removed ### Internal diff --git a/src/reporails_cli/core/discovery/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py index e6ceb8c..7ba3d78 100644 --- a/src/reporails_cli/core/discovery/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -408,8 +408,11 @@ class _AgentScope: plugin_roots: dict[str, list[Path]] = field(default_factory=dict) -def _discover_surface(scope: _AgentScope, ft_name: str, spec: JsonObject) -> tuple[SurfaceBucket, list[Path]] | None: - """The bucket and files of one file type, None when the file type contributes nothing.""" +def _discover_surface( + scope: _AgentScope, ft_name: str, spec: JsonObject, *, gated: bool = True +) -> tuple[SurfaceBucket, list[Path]] | None: + """The bucket and files of one file type, None when the file type contributes nothing. + With `gated` false the agent's read gate leaves the files unnarrowed.""" target = scope.target patterns = _repo_scoped_patterns(extract_patterns(spec), ft_name, scope.repo_scoped, scope.user_dir) # A plugin component pattern resolves against each plugin root found here. @@ -430,21 +433,62 @@ def _discover_surface(scope: _AgentScope, ft_name: str, spec: JsonObject) -> tup if scope.exclude_globs: found = [p for p in found if not matches_any_glob(p, scope.exclude_globs, target)] # A file type the agent reads only under a condition keeps just the files it reads. - found = apply_read_gate(scope.agent_id, ft_name, found, target) + if gated: + found = apply_read_gate(scope.agent_id, ft_name, found, target) return bucket, drop_shadowed_fallbacks(found, scope.agent_id, ft_name, scope.project_config) -def discover_unread_candidates( - target: Path, agent_id: str, file_type_names: tuple[str, ...], excluded: frozenset[str] +def _agent_scope( + target: Path, + agent_id: str, + file_types: JsonObject, + extra_exclude_dirs: frozenset[str] | None, + *, + project_config: ProjectConfig | None, + repo_scoped: bool, +) -> _AgentScope: + """The scope one agent's discovery run shares across its file types.""" + # Union of every per-surface exclude declared for this agent. Some agents + # have multiple surfaces that match the same paths (e.g. cursor.rules and + # cursor.bugbot_rules both match `.cursor/rules/**/*.mdc`). Applying each + # surface's exclude only within its own loop iteration leaves the file + # surfaced from the other surface — counter to the user's mental model + # ("I excluded draft, draft should be gone"). The union closes the gap. + return _AgentScope( + target=target, + agent_id=agent_id, + project_config=project_config, + excluded=extra_exclude_dirs or load_project_exclude_dirs(target), + repo_scoped=repo_scoped, + user_dir=target if repo_scoped and user_level_agent_dir(target) == target else None, + exclude_globs=[ + g for ft_name in file_types for g in surface_exclude_patterns(agent_id, ft_name, project_config) + ], + ) + + +def discover_ungated( + target: Path, + agent_id: str, + file_type_names: tuple[str, ...], + extra_exclude_dirs: frozenset[str], + *, + project_config: ProjectConfig | None, ) -> list[Path]: - """The files the named file types of `agent_id` match under `target`, before any read gate narrows them.""" + """The files the named file types of `agent_id` match under `target` in a repo-scoped run, + found as discovery finds them but before the agent's read gate narrows them. Every path is + kept under its own name, whatever it links to.""" file_types = load_config_file_types(agent_id) or {} - found: list[Path] = [] + scope = _agent_scope( + target, agent_id, file_types, extra_exclude_dirs, project_config=project_config, repo_scoped=True + ) + found: set[Path] = set() for name in file_type_names: spec = file_types.get(name) - if isinstance(spec, dict): - found.extend(glob_file_type_patterns(target, extract_patterns(spec), extract_properties(spec), excluded)) - return _dedupe_by_canonical(found) + surface = _discover_surface(scope, name, spec, gated=False) if isinstance(spec, dict) else None + if surface is not None: + found.update(surface[1]) + return sorted(found) def discover_from_config( @@ -468,22 +512,8 @@ def discover_from_config( if file_types is None: return None - # Union of every per-surface exclude declared for this agent. Some agents - # have multiple surfaces that match the same paths (e.g. cursor.rules and - # cursor.bugbot_rules both match `.cursor/rules/**/*.mdc`). Applying each - # surface's exclude only within its own loop iteration leaves the file - # surfaced from the other surface — counter to the user's mental model - # ("I excluded draft, draft should be gone"). The union closes the gap. - scope = _AgentScope( - target=target, - agent_id=agent_id, - project_config=project_config, - excluded=extra_exclude_dirs or load_project_exclude_dirs(target), - repo_scoped=repo_scoped, - user_dir=target if repo_scoped and user_level_agent_dir(target) == target else None, - exclude_globs=[ - g for ft_name in file_types for g in surface_exclude_patterns(agent_id, ft_name, project_config) - ], + scope = _agent_scope( + target, agent_id, file_types, extra_exclude_dirs, project_config=project_config, repo_scoped=repo_scoped ) buckets: dict[SurfaceBucket, list[Path]] = {bucket: [] for bucket in SURFACE_BUCKETS} for ft_name, spec in file_types.items(): diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index b6eb507..4a83831 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import Callable +from collections.abc import Callable, Iterable from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -27,6 +27,7 @@ MODE_CLAUDE_MD_AND_AGENTS_MD, MODE_CLAUDE_MD_OR_AGENTS_MD, MODES_WITHOUT_AGENTS_MD, + MODES_WITHOUT_PROJECT_INSTRUCTIONS, claude_instruction_files_mode, ) @@ -113,9 +114,13 @@ class AgentsMdReads: aliases: list[AliasFile] -def claude_agents_md_reads(found: list[Path], target: Path) -> AgentsMdReads: +def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] = ()) -> AgentsMdReads: """Split the `AGENTS.md` files among `found` into those Claude Code loads for a session in - `target` and those it skips, each skipped file with its reason.""" + `target` and those it skips, each skipped file with its reason. A skipped file that is the same + file as a `CLAUDE.md`-family file among `read` (the instruction files the session reads) is + reported as an alias of it instead.""" + if not found: + return AgentsMdReads([], [], "", []) root = resolve_project_root(target) mode = claude_instruction_files_mode(root) kept: list[Path] = [] @@ -123,12 +128,13 @@ def claude_agents_md_reads(found: list[Path], target: Path) -> AgentsMdReads: aliases: list[AliasFile] = [] claude_above = mode == MODE_CLAUDE_MD_OR_AGENTS_MD and claude_file_at_or_above(root) user_file = _user_claude_md() + claude_files = {} if mode in MODES_WITHOUT_PROJECT_INSTRUCTIONS else _claude_files_by_identity(read, root) for path in found: reason = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) if reason is None: kept.append(path) continue - same_as = _linked_claude_md(path, root) + same_as = claude_files.get(safe_resolve(path)) if same_as is None: skipped.append(SkippedFile(path, reason)) else: @@ -136,19 +142,21 @@ def claude_agents_md_reads(found: list[Path], target: Path) -> AgentsMdReads: return AgentsMdReads(kept, skipped, mode, aliases) -def _linked_claude_md(path: Path, root: Path) -> Path | None: - """The `CLAUDE.md` Claude Code reads that `path` links to, None when it is not such a link.""" - target = safe_resolve(path) - if target.name.lower() != "claude.md" or not target.is_file(): - return None - resolved_root = safe_resolve(root) - read_dirs = {resolved_root, *resolved_root.parents, safe_resolve(path.parent)} - if resolved_root in (target.parent, *target.parent.parents): - read_dirs.add(target.parent) - folder = target.parent - if folder.name == ".claude": - folder = folder.parent - return target if folder in read_dirs else None +def _claude_files_by_identity(read: Iterable[Path], root: Path) -> dict[Path, Path]: + """The `CLAUDE.md`-family files among `read`, each under the resolved path it is the same file as. + A file under a `.agents/` directory of the project is not one Claude Code reads.""" + return { + safe_resolve(path): path + for path in read + if path.name.lower() in _CLAUDE_FAMILY and not _in_agents_dir(path, root) + } + + +def _in_agents_dir(path: Path, root: Path) -> bool: + try: + return ".agents" in (part.lower() for part in path.relative_to(root).parts[:-1]) + except ValueError: + return False def _skip_reason(path: Path, root: Path, mode: str, *, claude_above: bool, user_file: Path) -> SkipReason | None: diff --git a/src/reporails_cli/core/mapper/inspect.py b/src/reporails_cli/core/mapper/inspect.py index a5600bf..6ff8a8f 100644 --- a/src/reporails_cli/core/mapper/inspect.py +++ b/src/reporails_cli/core/mapper/inspect.py @@ -103,7 +103,6 @@ def _load_registry() -> dict[str, JsonObject]: _ACTIVATION_KEYS = ( - "loading", "precedence", "activation", "path_key", diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 895b867..b3972bd 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -4,17 +4,25 @@ from pathlib import Path -from reporails_cli.core.discovery.agent_discovery import discover_unread_candidates +from reporails_cli.core.discovery.agent_discovery import discover_ungated from reporails_cli.core.discovery.project_root import resolve_project_root -from reporails_cli.core.discovery.read_gates import claude_agents_md_reads, gated_file_types +from reporails_cli.core.discovery.read_gates import SkippedFile, claude_agents_md_reads, gated_file_types from reporails_cli.core.discovery.walk import safe_resolve from reporails_cli.core.mapper.inspect import _load_registry, file_read_by -from reporails_cli.core.pipeline.mapping import discover_scope +from reporails_cli.core.pipeline.mapping import ScopeDiscovery, discover_scope from reporails_cli.core.platform.config.bootstrap import get_xray_log_dir +from reporails_cli.core.platform.config.claude_settings import ( + MODES_WITHOUT_PROJECT_INSTRUCTIONS, + claude_instruction_files_mode, +) +from reporails_cli.core.platform.config.config import get_project_config from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue AGENT = "claude" +# Precedence scopes whose instruction files an organization-managed-only setting turns off. +_OWN_SCOPES = frozenset({"project", "user"}) + def expected_loads(target: Path) -> JsonObject: """The files Claude Code loads for a session in `target` (instruction and rule files, no config @@ -22,32 +30,27 @@ def expected_loads(target: Path) -> JsonObject: root = resolve_project_root(target) scope = discover_scope(root, AGENT, None, scan_files=False) registry = _load_registry() - paths = sorted({p for agent in scope.filtered for p in (*agent.instruction_files, *agent.rule_files)}) - files: list[JsonValue] = [] - for path in paths: - read = file_read_by(AGENT, path, root, registry) - if read is not None: - files.append( - { - "path": str(path), - "file_type": read.file_type, - "loading": read.loading, - "precedence": read.precedence, - } - ) + mode = claude_instruction_files_mode(root) + files, read_instructions, switched_off = _loaded_files(scope, root, registry, mode) + skipped = [SkippedFile(path, "setting_off") for path in switched_off] exclusions = scope.exclusions - candidates = discover_unread_candidates( - root, AGENT, gated_file_types(AGENT), exclusions.walk_dirs if exclusions else frozenset() + candidates = discover_ungated( + root, + AGENT, + gated_file_types(AGENT), + exclusions.walk_dirs, + project_config=get_project_config(root), ) - reads = claude_agents_md_reads([p for p in candidates if exclusions is None or not exclusions.leaves_out(p)], root) - skipped: list[JsonValue] = [ + reads = claude_agents_md_reads([p for p in candidates if not exclusions.leaves_out(p)], root, read_instructions) + skipped.extend(reads.skipped) + skipped_out: list[JsonValue] = [ { "path": str(item.path), - "file_type": _file_type_of_skipped(item.path, root, registry), + "file_type": switched_off.get(item.path) or _file_type_of_skipped(item.path, root, registry), "reason": item.reason, - "setting": reads.mode, + "setting": mode, } - for item in sorted(reads.skipped, key=lambda s: s.path) + for item in sorted(skipped, key=lambda s: s.path) ] aliases: list[JsonValue] = [ {"path": str(item.path), "same_as": str(item.same_as)} for item in sorted(reads.aliases, key=lambda a: a.path) @@ -58,11 +61,37 @@ def expected_loads(target: Path) -> JsonObject: "root": str(safe_resolve(root)), "event_log_dir": str(get_xray_log_dir()), "files": files, - "skipped": skipped, + "skipped": skipped_out, "aliases": aliases, } +def _loaded_files( + scope: ScopeDiscovery, root: Path, registry: dict[str, JsonObject], mode: str +) -> tuple[list[JsonValue], list[Path], dict[Path, str]]: + """The files Claude Code loads among the ones discovery found, the instruction files among + them, and the project and user instruction files the `instructionFiles` setting turns off + (each with its file type).""" + instruction = {p for agent in scope.filtered for p in agent.instruction_files} + rules = {p for agent in scope.filtered for p in agent.rule_files} + files: list[JsonValue] = [] + read_instructions: list[Path] = [] + switched_off: dict[Path, str] = {} + for path in sorted(instruction | rules): + read = file_read_by(AGENT, path, root, registry) + if read is None: + continue + if path in instruction and mode in MODES_WITHOUT_PROJECT_INSTRUCTIONS and read.precedence in _OWN_SCOPES: + switched_off[path] = read.file_type + continue + if path in instruction: + read_instructions.append(path) + files.append( + {"path": str(path), "file_type": read.file_type, "loading": read.loading, "precedence": read.precedence} + ) + return files, read_instructions, switched_off + + def _file_type_of_skipped(path: Path, root: Path, registry: dict[str, JsonObject]) -> str: read = file_read_by(AGENT, path, root, registry) return read.file_type if read is not None else "agents_md" diff --git a/src/reporails_cli/core/pipeline/mapping.py b/src/reporails_cli/core/pipeline/mapping.py index 0d05b66..adf40b1 100644 --- a/src/reporails_cli/core/pipeline/mapping.py +++ b/src/reporails_cli/core/pipeline/mapping.py @@ -61,9 +61,9 @@ class ScopeDiscovery(NamedTuple): filtered: list[DetectedAgent] files: list[Path] # The `(folders, file globs)` this pass left out because the user set them; empty when it listed no files. - excluded: tuple[tuple[str, ...], tuple[str, ...]] = ((), ()) + excluded: tuple[tuple[str, ...], tuple[str, ...]] # The run's exclusions: the command-line patterns joined with the project's config. - exclusions: RunExclusions | None = None + exclusions: RunExclusions def discover_scope( diff --git a/src/reporails_cli/core/platform/config/claude_settings.py b/src/reporails_cli/core/platform/config/claude_settings.py index 87cba1c..60a7625 100644 --- a/src/reporails_cli/core/platform/config/claude_settings.py +++ b/src/reporails_cli/core/platform/config/claude_settings.py @@ -40,6 +40,9 @@ # Modes under which Claude Code reads no AGENTS.md at all. MODES_WITHOUT_AGENTS_MD = frozenset({MODE_CLAUDE_MD, MODE_MANAGED_ONLY}) +# Modes under which Claude Code reads no project or user instruction file, `CLAUDE.md` included. +MODES_WITHOUT_PROJECT_INSTRUCTIONS = frozenset({MODE_MANAGED_ONLY}) + def _settings_patterns() -> dict[str, list[str]]: """Scope name -> settings-file patterns, from Claude's `config` file type.""" diff --git a/src/reporails_cli/interfaces/cli/expected_loads_command.py b/src/reporails_cli/interfaces/cli/expected_loads_command.py index 1e1323c..59ffb43 100644 --- a/src/reporails_cli/interfaces/cli/expected_loads_command.py +++ b/src/reporails_cli/interfaces/cli/expected_loads_command.py @@ -26,4 +26,8 @@ def expected_loads_command( if agent != AGENT: typer.echo(f"Error: only --agent {AGENT} is supported", err=True) raise typer.Exit(2) - typer.echo(compact_json(expected_loads(Path(path).resolve()))) + folder = Path(path).resolve() + if not folder.is_dir(): + typer.echo(f"Error: Path not found or not a folder: {folder}", err=True) + raise typer.Exit(2) + typer.echo(compact_json(expected_loads(folder))) diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index a0e3cb6..b638f9f 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -169,3 +169,127 @@ def test_agents_md_linked_to_claude_md_is_reported_as_that_file(tmp_path: Path) ] skipped = {Path(i["path"]).relative_to(resolved).as_posix(): i["reason"] for i in out["skipped"]} assert skipped == {"sub2/AGENTS.md": "claude_md_present", "sub3/AGENTS.md": "claude_md_present"} + + +def _link(link: Path, target: str) -> None: + link.parent.mkdir(parents=True, exist_ok=True) + try: + link.symlink_to(target) + except OSError: + pytest.skip("symlinks cannot be created here") + + +def _skipped_reasons(out: dict, root: Path) -> dict[str, str]: + return {k: v["reason"] for k, v in _by_name(out["skipped"], root).items()} + + +def _alias_names(out: dict, root: Path) -> dict[str, str]: + resolved = root.resolve() + return { + Path(a["path"]).relative_to(resolved).as_posix(): Path(a["same_as"]).relative_to(resolved).as_posix() + for a in out["aliases"] + } + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_claude_md_linking_to_regular_agents_md_makes_agents_md_an_alias(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "AGENTS.md") + _link(root / "CLAUDE.md", "AGENTS.md") + out = expected_loads(root) + assert _alias_names(out, root) == {"AGENTS.md": "CLAUDE.md"} + assert out["skipped"] == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_linking_to_claude_local_md_is_an_alias(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.local.md") + _link(root / "AGENTS.md", "CLAUDE.local.md") + out = expected_loads(root) + assert _alias_names(out, root) == {"AGENTS.md": "CLAUDE.local.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_linking_into_agents_dir_or_excluded_folder_stays_skipped(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / ".agents" / "CLAUDE.md") + _write(root / "node_modules" / "pkg" / "CLAUDE.md") + _link(root / "AGENTS.md", ".agents/CLAUDE.md") + _link(root / "a" / "AGENTS.md", "../node_modules/pkg/CLAUDE.md") + out = expected_loads(root) + assert out["aliases"] == [] + assert _skipped_reasons(out, root) == {"AGENTS.md": "claude_md_present", "a/AGENTS.md": "claude_md_present"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_links_to_one_file_are_each_reported_under_their_own_name(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _link(root / "AGENTS.md", "CLAUDE.md") + _link(root / ".claude" / "AGENTS.md", "../CLAUDE.md") + assert _alias_names(expected_loads(root), root) == { + "AGENTS.md": "CLAUDE.md", + ".claude/AGENTS.md": "CLAUDE.md", + } + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_excluded_in_config_is_not_reported_as_skipped(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / "AGENTS.md") + _write(root / "legacy" / "AGENTS.md") + _write( + root / ".ails" / "config.yml", + 'schema_version: "0.1.0"\nsurfaces:\n claude.agents_md:\n exclude: ["legacy/**"]\n', + ) + assert set(_skipped_reasons(expected_loads(root), root)) == {"AGENTS.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_managed_only_skips_project_instruction_files_and_reports_no_alias(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _link(root / "AGENTS.md", "CLAUDE.md") + _write(root / ".claude" / "rules" / "style.md") + _mode("managed-only") + out = expected_loads(root) + assert out["aliases"] == [] + assert set(_by_name(out["files"], root)) == {".claude/rules/style.md"} + skipped = _by_name(out["skipped"], root) + assert {k: (v["reason"], v["setting"]) for k, v in skipped.items()} == { + "CLAUDE.md": ("setting_off", "managed-only"), + "AGENTS.md": ("setting_off", "managed-only"), + } + assert (skipped["CLAUDE.md"]["file_type"], skipped["AGENTS.md"]["file_type"]) == ("main", "agents_md") + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_no_candidates_reads_no_settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from reporails_cli.core.discovery import read_gates + + def boom(_root: Path) -> str: + raise AssertionError("settings read for an empty input") + + monkeypatch.setattr(read_gates, "claude_instruction_files_mode", boom) + reads = read_gates.claude_agents_md_reads([], tmp_path) + assert (reads.kept, reads.skipped, reads.aliases) == ([], [], []) + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_command_rejects_a_path_that_is_not_a_folder(tmp_path: Path) -> None: + _write(tmp_path / "CLAUDE.md") + for bad in (tmp_path / "typo-dir", tmp_path / "CLAUDE.md"): + result = runner.invoke(app, ["expected-loads", str(bad)]) + assert result.exit_code == 2 + assert "Path not found or not a folder" in result.output diff --git a/tests/unit/test_inspect_activation_modes.py b/tests/unit/test_inspect_activation_modes.py index 3d38cd2..d252cf5 100644 --- a/tests/unit/test_inspect_activation_modes.py +++ b/tests/unit/test_inspect_activation_modes.py @@ -71,3 +71,22 @@ def test_activation_mode(tmp_path: Path, rel: str, frontmatter: str, expected) - def test_copilot_reads_claude_rules_by_paths(tmp_path: Path, frontmatter: str, expected) -> None: registry = {"copilot": _load_registry()["copilot"]} assert _loading(tmp_path, ".claude/rules/a.md", frontmatter, registry) == expected + + +@pytest.mark.unit +@pytest.mark.subsys_map +@pytest.mark.parametrize( + ("rel", "frontmatter", "expected"), + [ + (".claude/rules/py.md", "", ("session_start", "global", ())), + (".claude/rules/py.md", 'paths: ["src/**/*.py"]\n', ("on_demand", "path_scoped", ("src/**/*.py",))), + (".gemini/config/rules/a.md", "trigger: manual\n", ("on_demand", "global", ())), + (".gemini/config/rules/a.md", 'trigger: glob\nglobs: "src/**"\n', ("on_demand", "path_scoped", ("src/**",))), + ], +) +def test_user_rule_keeps_its_frontmatter_conditions(tmp_path: Path, rel: str, frontmatter: str, expected) -> None: + """A rule in the user rules folder loads at session start unless its own frontmatter makes it conditional.""" + path = Path.home() / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"---\n{frontmatter}---\n\nBody.\n" if frontmatter else "Body.\n", encoding="utf-8") + assert _detect_file_activation(path, tmp_path, _load_registry())[:3] == expected From 84e0bc1586e85ea175fb1f939003068707cf6a7d Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sat, 17 Oct 2026 16:19:40 +0200 Subject: [PATCH 05/31] expected-loads: your own ~/.claude files are listed and an empty file is not expected; managed-only and the plugin ID follow the Claude Code docs; a subdirectory's .claude/rules/ loads on demand --- UNRELEASED.md | 5 +- framework/rules/claude/config.yml | 19 +++- .../core/discovery/read_gates.py | 6 +- src/reporails_cli/core/mapper/inspect.py | 1 + .../core/pipeline/expected_loads.py | 51 ++++++---- .../core/platform/config/claude_settings.py | 41 +++++--- .../test_expected_loads_managed_only.py | 69 ++++++++++++++ tests/unit/test_claude_agents_md_gate.py | 43 ++++++++- tests/unit/test_expected_loads.py | 93 ++++++++++++++++--- tests/unit/test_inspect_activation_modes.py | 1 + 10 files changed, 274 insertions(+), 55 deletions(-) create mode 100644 tests/integration/test_expected_loads_managed_only.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 6aaa029..8c388d0 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,12 +2,15 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md is reported as that file rather than as skipped. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, which Claude Code sends nothing for, is not expected to load. ### Changed ### Fixed +- `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. +- A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. + ### Removed ### Internal diff --git a/framework/rules/claude/config.yml b/framework/rules/claude/config.yml index 7377aad..ad44b1f 100644 --- a/framework/rules/claude/config.yml +++ b/framework/rules/claude/config.yml @@ -75,8 +75,9 @@ file_types: maintainer: human agents_md: - # AGENTS.md read through Claude Code's built-in `agents-md` plugin. The user's - # `pluginConfigs["agents-md@builtin"].options.instructionFiles` setting decides when: + # AGENTS.md read through Claude Code's built-in plugin. The user's + # `pluginConfigs["cc-plugin-agents-md@builtin"].options.instructionFiles` setting (saved + # under `agents-md@builtin` before Claude Code 2.1.285) decides when: # `claude-md-or-agents-md` (the default) loads it only when no CLAUDE.md, # .claude/CLAUDE.md or CLAUDE.local.md sits in the working directory or above it; # `claude-md-and-agents-md` loads it alongside CLAUDE.md; `claude-md` and @@ -117,18 +118,26 @@ file_types: format: [frontmatter, freeform] scope: path_scoped # `paths:` (a list, or one comma-separated string) scopes a rule; a rule - # without it loads at session start. + # without it loads at session start, except in a subdirectory's `.claude/rules/`, + # which loads on demand. path_key: paths cardinality: collection lifecycle: static loading: on_demand scopes: project: - # https://code.claude.com/docs/en/memory#organize-rules-with-clauderules: "rules in nested `.claude/rules/` directories" - patterns: [".claude/rules/**/*.md", "**/.claude/rules/**/*.md"] + patterns: [".claude/rules/**/*.md"] precedence: project vcs: committed maintainer: human + nested: + # https://code.claude.com/docs/en/memory#choose-which-instruction-files-load: "A subdirectory's + # `CLAUDE.md` and `.claude/rules/` files, and path-scoped rules, still load on demand". + patterns: ["**/.claude/rules/**/*.md"] + precedence: project + vcs: committed + maintainer: human + scope: nested user: patterns: ["~/.claude/rules/*.md"] precedence: user diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index 4a83831..4915f80 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -1,6 +1,6 @@ """Files an agent reads only under a condition its own settings decide. -Claude Code reads `AGENTS.md` through its built-in `agents-md` plugin, so a project's +Claude Code reads `AGENTS.md` through a built-in plugin, so a project's `AGENTS.md` belongs to a Claude check only when Claude would load it: - With the default setting, only when no `CLAUDE.md`, `.claude/CLAUDE.md` or @@ -27,7 +27,7 @@ MODE_CLAUDE_MD_AND_AGENTS_MD, MODE_CLAUDE_MD_OR_AGENTS_MD, MODES_WITHOUT_AGENTS_MD, - MODES_WITHOUT_PROJECT_INSTRUCTIONS, + MODES_WITHOUT_LAUNCH_INSTRUCTIONS, claude_instruction_files_mode, ) @@ -128,7 +128,7 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] aliases: list[AliasFile] = [] claude_above = mode == MODE_CLAUDE_MD_OR_AGENTS_MD and claude_file_at_or_above(root) user_file = _user_claude_md() - claude_files = {} if mode in MODES_WITHOUT_PROJECT_INSTRUCTIONS else _claude_files_by_identity(read, root) + claude_files = {} if mode in MODES_WITHOUT_LAUNCH_INSTRUCTIONS else _claude_files_by_identity(read, root) for path in found: reason = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) if reason is None: diff --git a/src/reporails_cli/core/mapper/inspect.py b/src/reporails_cli/core/mapper/inspect.py index 6ff8a8f..e941a75 100644 --- a/src/reporails_cli/core/mapper/inspect.py +++ b/src/reporails_cli/core/mapper/inspect.py @@ -104,6 +104,7 @@ def _load_registry() -> dict[str, JsonObject]: _ACTIVATION_KEYS = ( "precedence", + "scope", "activation", "path_key", "always_key", diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index b3972bd..fdfed21 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -4,42 +4,41 @@ from pathlib import Path -from reporails_cli.core.discovery.agent_discovery import discover_ungated +from reporails_cli.core.discovery.agent_discovery import discover_from_config, discover_ungated +from reporails_cli.core.discovery.exclusions import RunExclusions, load_run_exclusions from reporails_cli.core.discovery.project_root import resolve_project_root from reporails_cli.core.discovery.read_gates import SkippedFile, claude_agents_md_reads, gated_file_types from reporails_cli.core.discovery.walk import safe_resolve from reporails_cli.core.mapper.inspect import _load_registry, file_read_by -from reporails_cli.core.pipeline.mapping import ScopeDiscovery, discover_scope from reporails_cli.core.platform.config.bootstrap import get_xray_log_dir from reporails_cli.core.platform.config.claude_settings import ( - MODES_WITHOUT_PROJECT_INSTRUCTIONS, + LAUNCH_INSTRUCTION_FILE_TYPES, + MODES_WITHOUT_LAUNCH_INSTRUCTIONS, claude_instruction_files_mode, ) from reporails_cli.core.platform.config.config import get_project_config from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue +from reporails_cli.core.platform.dto.results import ProjectConfig AGENT = "claude" -# Precedence scopes whose instruction files an organization-managed-only setting turns off. -_OWN_SCOPES = frozenset({"project", "user"}) - def expected_loads(target: Path) -> JsonObject: """The files Claude Code loads for a session in `target` (instruction and rule files, no config surfaces) with their file type, loading mode and precedence, and the AGENTS.md files it skips.""" root = resolve_project_root(target) - scope = discover_scope(root, AGENT, None, scan_files=False) + exclusions = load_run_exclusions(root, None, None) + project_config = get_project_config(root) registry = _load_registry() mode = claude_instruction_files_mode(root) - files, read_instructions, switched_off = _loaded_files(scope, root, registry, mode) + files, read_instructions, switched_off = _loaded_files(exclusions, project_config, root, registry, mode) skipped = [SkippedFile(path, "setting_off") for path in switched_off] - exclusions = scope.exclusions candidates = discover_ungated( root, AGENT, gated_file_types(AGENT), exclusions.walk_dirs, - project_config=get_project_config(root), + project_config=project_config, ) reads = claude_agents_md_reads([p for p in candidates if not exclusions.leaves_out(p)], root, read_instructions) skipped.extend(reads.skipped) @@ -67,21 +66,26 @@ def expected_loads(target: Path) -> JsonObject: def _loaded_files( - scope: ScopeDiscovery, root: Path, registry: dict[str, JsonObject], mode: str + exclusions: RunExclusions, project_config: ProjectConfig, root: Path, registry: dict[str, JsonObject], mode: str ) -> tuple[list[JsonValue], list[Path], dict[Path, str]]: - """The files Claude Code loads among the ones discovery found, the instruction files among - them, and the project and user instruction files the `instructionFiles` setting turns off - (each with its file type).""" - instruction = {p for agent in scope.filtered for p in agent.instruction_files} - rules = {p for agent in scope.filtered for p in agent.rule_files} + """The non-empty instruction and rule files Claude Code loads (the user's own included), the instruction + files among them, and the launch files the `instructionFiles` setting turns off (each with its + file type).""" + found = discover_from_config(root, AGENT, exclusions.walk_dirs, project_config=project_config, repo_scoped=False) + instruction, rules = (set(bucket) for bucket in found[:2]) if found is not None else (set(), set()) files: list[JsonValue] = [] read_instructions: list[Path] = [] switched_off: dict[Path, str] = {} - for path in sorted(instruction | rules): + for path in sorted(p for p in instruction | rules if not exclusions.leaves_out(p)): read = file_read_by(AGENT, path, root, registry) - if read is None: + if read is None or _is_blank(path): continue - if path in instruction and mode in MODES_WITHOUT_PROJECT_INSTRUCTIONS and read.precedence in _OWN_SCOPES: + if ( + mode in MODES_WITHOUT_LAUNCH_INSTRUCTIONS + and read.file_type in LAUNCH_INSTRUCTION_FILE_TYPES + and read.loading == "session_start" + and read.precedence != "managed" + ): switched_off[path] = read.file_type continue if path in instruction: @@ -92,6 +96,15 @@ def _loaded_files( return files, read_instructions, switched_off +def _is_blank(path: Path) -> bool: + """Whether the file holds nothing but whitespace; Claude Code sends nothing for it. A file that + cannot be read stays listed.""" + try: + return not path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + return False + + def _file_type_of_skipped(path: Path, root: Path, registry: dict[str, JsonObject]) -> str: read = file_read_by(AGENT, path, root, registry) return read.file_type if read is not None else "agents_md" diff --git a/src/reporails_cli/core/platform/config/claude_settings.py b/src/reporails_cli/core/platform/config/claude_settings.py index 60a7625..943edb4 100644 --- a/src/reporails_cli/core/platform/config/claude_settings.py +++ b/src/reporails_cli/core/platform/config/claude_settings.py @@ -1,7 +1,8 @@ """Claude Code's own setting for which project instruction files it loads. -Claude Code reads `AGENTS.md` through its built-in `agents-md` plugin. The value at -`pluginConfigs["agents-md@builtin"].options.instructionFiles` picks what loads: +Claude Code reads `AGENTS.md` through a built-in plugin. The value at +`pluginConfigs["cc-plugin-agents-md@builtin"].options.instructionFiles` picks what loads; +before Claude Code 2.1.285 the plugin's ID was `agents-md@builtin`, and both IDs are read: - `claude-md-or-agents-md` (the default): `CLAUDE.md` files, or `AGENTS.md` files when no `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` sits in the working directory @@ -30,7 +31,9 @@ logger = logging.getLogger(__name__) -AGENTS_MD_PLUGIN = "agents-md@builtin" +# The built-in plugin's ID since Claude Code 2.1.285, then its earlier one. When one file sets +# both, the current ID wins. +AGENTS_MD_PLUGINS = ("cc-plugin-agents-md@builtin", "agents-md@builtin") MODE_CLAUDE_MD_OR_AGENTS_MD = "claude-md-or-agents-md" MODE_CLAUDE_MD_AND_AGENTS_MD = "claude-md-and-agents-md" MODE_CLAUDE_MD = "claude-md" @@ -40,8 +43,12 @@ # Modes under which Claude Code reads no AGENTS.md at all. MODES_WITHOUT_AGENTS_MD = frozenset({MODE_CLAUDE_MD, MODE_MANAGED_ONLY}) -# Modes under which Claude Code reads no project or user instruction file, `CLAUDE.md` included. -MODES_WITHOUT_PROJECT_INSTRUCTIONS = frozenset({MODE_MANAGED_ONLY}) +# Modes under which Claude Code leaves out every project, local and user `CLAUDE.md` and rules file +# that loads at launch, keeping the managed `CLAUDE.md` and everything that loads on demand. +MODES_WITHOUT_LAUNCH_INSTRUCTIONS = frozenset({MODE_MANAGED_ONLY}) + +# The file types of Claude's `CLAUDE.md` and rules files, the ones those modes switch off at launch. +LAUNCH_INSTRUCTION_FILE_TYPES = frozenset({"main", "override", "rules"}) def _settings_patterns() -> dict[str, list[str]]: @@ -93,16 +100,26 @@ def _managed_files(scopes: dict[str, list[str]]) -> list[Path]: def _mode_value(data: JsonObject) -> str | None: configs = data.get("pluginConfigs") - entry = configs.get(AGENTS_MD_PLUGIN) if isinstance(configs, dict) else None - options = entry.get("options") if isinstance(entry, dict) else None - value = options.get("instructionFiles") if isinstance(options, dict) else None - return value if isinstance(value, str) else None + if not isinstance(configs, dict): + return None + for plugin in AGENTS_MD_PLUGINS: + entry = configs.get(plugin) + options = entry.get("options") if isinstance(entry, dict) else None + value = options.get("instructionFiles") if isinstance(options, dict) else None + if isinstance(value, str): + return value + return None def _enabled_value(data: JsonObject) -> bool | None: enabled = data.get("enabledPlugins") - value = enabled.get(AGENTS_MD_PLUGIN) if isinstance(enabled, dict) else None - return value if isinstance(value, bool) else None + if not isinstance(enabled, dict): + return None + for plugin in AGENTS_MD_PLUGINS: + value = enabled.get(plugin) + if isinstance(value, bool): + return value + return None def _last_set[T](files: list[Path], read: Callable[[JsonObject], T | None]) -> T | None: @@ -119,7 +136,7 @@ def claude_instruction_files_mode(project_root: Path | None = None) -> str: """The Claude Code `instructionFiles` mode in effect for a session in `project_root`. Managed settings outrank the user's settings. A missing or unknown value is the - default mode. When the `agents-md` plugin is turned off, the mode is `claude-md`: + default mode. When the built-in `AGENTS.md` plugin is turned off, the mode is `claude-md`: `enabledPlugins` is read from every scope, managed first, then the project's local and shared settings, then the user's. """ diff --git a/tests/integration/test_expected_loads_managed_only.py b/tests/integration/test_expected_loads_managed_only.py new file mode 100644 index 0000000..1762c17 --- /dev/null +++ b/tests/integration/test_expected_loads_managed_only.py @@ -0,0 +1,69 @@ +"""`ails expected-loads` under Claude Code's `managed-only` setting, run through the real `ails` binary.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +from pathlib import Path + +import pytest + +PLUGIN_ID = "cc-plugin-agents-md@builtin" + + +def _write(path: Path, text: str = "# Notes\n\nRun the tests.\n") -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def _expected_loads(project: Path, home: Path) -> dict: + ails = shutil.which("ails") + assert ails is not None, "the `ails` binary is not on PATH" + env = {**os.environ, "HOME": str(home), "USERPROFILE": str(home), "NO_COLOR": "1"} + proc = subprocess.run( + [ails, "expected-loads", str(project), "--agent", "claude"], + capture_output=True, + text=True, + encoding="utf-8", + check=False, + env=env, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.mark.integration +@pytest.mark.subsys_classify +def test_managed_only_leaves_out_launch_files_and_keeps_on_demand_ones(tmp_path: Path) -> None: + home = tmp_path / "home" + project = tmp_path / "proj" + _write( + home / ".claude" / "settings.json", + json.dumps({"pluginConfigs": {PLUGIN_ID: {"options": {"instructionFiles": "managed-only"}}}}), + ) + _write(home / ".claude" / "rules" / "pref.md") + _write(home / ".claude" / "CLAUDE.md", "") + _write(project / "CLAUDE.md") + _write(project / ".claude" / "rules" / "style.md") + _write(project / "sub" / "CLAUDE.md") + _write(project / "sub" / ".claude" / "rules" / "x.md") + + out = _expected_loads(project, home) + + skipped = {Path(i["path"]): i for i in out["skipped"]} + for path in ( + project / "CLAUDE.md", + project / ".claude" / "rules" / "style.md", + home / ".claude" / "rules" / "pref.md", + ): + assert skipped[path]["reason"] == "setting_off", path + assert skipped[path]["setting"] == "managed-only", path + loaded = {Path(i["path"]): i for i in out["files"]} + assert {p: i["loading"] for p, i in loaded.items()} == { + project / "sub" / "CLAUDE.md": "on_demand", + project / "sub" / ".claude" / "rules" / "x.md": "on_demand", + } + assert home / ".claude" / "CLAUDE.md" not in skipped + assert home / ".claude" / "CLAUDE.md" not in loaded diff --git a/tests/unit/test_claude_agents_md_gate.py b/tests/unit/test_claude_agents_md_gate.py index 078585b..36aaf9c 100644 --- a/tests/unit/test_claude_agents_md_gate.py +++ b/tests/unit/test_claude_agents_md_gate.py @@ -27,12 +27,16 @@ def _write(path: Path, text: str = "# Notes\n\nRun the tests.\n") -> Path: return path -def _settings(path: Path, *, mode: str | None = None, enabled: bool | None = None) -> None: +CURRENT_ID = "cc-plugin-agents-md@builtin" +LEGACY_ID = "agents-md@builtin" + + +def _settings(path: Path, *, mode: str | None = None, enabled: bool | None = None, plugin_id: str = LEGACY_ID) -> None: data: dict = {} if mode is not None: - data["pluginConfigs"] = {"agents-md@builtin": {"options": {"instructionFiles": mode}}} + data["pluginConfigs"] = {plugin_id: {"options": {"instructionFiles": mode}}} if enabled is not None: - data["enabledPlugins"] = {"agents-md@builtin": enabled} + data["enabledPlugins"] = {plugin_id: enabled} _write(path, json.dumps(data)) @@ -74,6 +78,39 @@ def test_user_settings_pick_the_mode(no_managed: Path, tmp_path: Path, mode: str assert claude_instruction_files_mode(tmp_path) == mode +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize("plugin_id", [CURRENT_ID, LEGACY_ID]) +def test_the_mode_is_read_under_either_plugin_id(no_managed: Path, tmp_path: Path, plugin_id: str) -> None: + _settings(_user_settings(), mode="managed-only", plugin_id=plugin_id) + assert claude_instruction_files_mode(tmp_path) == "managed-only" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_the_current_id_wins_when_one_file_sets_both(no_managed: Path, tmp_path: Path) -> None: + _write( + _user_settings(), + json.dumps( + { + "pluginConfigs": { + LEGACY_ID: {"options": {"instructionFiles": "claude-md"}}, + CURRENT_ID: {"options": {"instructionFiles": "managed-only"}}, + } + } + ), + ) + assert claude_instruction_files_mode(tmp_path) == "managed-only" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize("plugin_id", [CURRENT_ID, LEGACY_ID]) +def test_a_turned_off_plugin_reads_claude_md_under_either_id(no_managed: Path, tmp_path: Path, plugin_id: str) -> None: + _settings(_user_settings(), mode="claude-md-and-agents-md", enabled=False, plugin_id=plugin_id) + assert claude_instruction_files_mode(tmp_path) == "claude-md" + + @pytest.mark.unit @pytest.mark.subsys_classify def test_project_and_local_settings_never_pick_the_mode(no_managed: Path, tmp_path: Path) -> None: diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index b638f9f..bdfd568 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -21,9 +21,9 @@ def _write(path: Path, text: str = "# Notes\n\nRun the tests.\n") -> Path: return path -def _mode(mode: str) -> None: +def _mode(mode: str, plugin_id: str = "agents-md@builtin") -> None: settings = Path.home() / ".claude" / "settings.json" - data = {"pluginConfigs": {"agents-md@builtin": {"options": {"instructionFiles": mode}}}} + data = {"pluginConfigs": {plugin_id: {"options": {"instructionFiles": mode}}}} _write(settings, json.dumps(data)) @@ -48,6 +48,12 @@ def _by_name(items: list[dict], root: Path) -> dict[str, dict]: return {Path(i["path"]).relative_to(root).as_posix(): i for i in items} +def _home_items(items: list[dict]) -> dict[str, dict]: + """The entries under the user's `~/.claude/`, by path relative to it.""" + home = Path.home() / ".claude" + return {Path(i["path"]).relative_to(home).as_posix(): i for i in items if Path(i["path"]).is_relative_to(home)} + + @pytest.mark.unit @pytest.mark.subsys_classify def test_agents_md_beside_claude_md_is_skipped(tmp_path: Path) -> None: @@ -253,23 +259,86 @@ def test_agents_md_excluded_in_config_is_not_reported_as_skipped(tmp_path: Path) assert set(_skipped_reasons(expected_loads(root), root)) == {"AGENTS.md"} +def _managed_only_project(root: Path) -> None: + _write(root / "CLAUDE.md") + _write(root / "CLAUDE.local.md") + _link(root / "AGENTS.md", "CLAUDE.md") + _write(root / ".claude" / "rules" / "style.md") + _write(root / ".claude" / "rules" / "scoped.md", "---\npaths:\n - src/**\n---\n\nUse type hints.\n") + _write(root / "sub" / "CLAUDE.md") + _write(root / "sub" / ".claude" / "rules" / "x.md") + _write(Path.home() / ".claude" / "CLAUDE.md") + _write(Path.home() / ".claude" / "rules" / "pref.md") + + @pytest.mark.unit @pytest.mark.subsys_classify -def test_managed_only_skips_project_instruction_files_and_reports_no_alias(tmp_path: Path) -> None: +@pytest.mark.parametrize("plugin_id", ["agents-md@builtin", "cc-plugin-agents-md@builtin"]) +def test_managed_only_leaves_out_what_claude_leaves_out_at_launch(tmp_path: Path, plugin_id: str) -> None: + root = _project(tmp_path) + _managed_only_project(root) + _mode("managed-only", plugin_id) + out = expected_loads(root) + assert out["aliases"] == [] + files = _by_name([i for i in out["files"] if Path(i["path"]).is_relative_to(root)], root) + assert {k: v["loading"] for k, v in files.items()} == { + "sub/CLAUDE.md": "on_demand", + "sub/.claude/rules/x.md": "on_demand", + ".claude/rules/scoped.md": "on_demand", + } + assert not _home_items(out["files"]) + skipped = _by_name([i for i in out["skipped"] if Path(i["path"]).is_relative_to(root)], root) + assert {k: (v["reason"], v["setting"]) for k, v in skipped.items()} == dict.fromkeys( + ("CLAUDE.md", "CLAUDE.local.md", ".claude/rules/style.md", "AGENTS.md"), ("setting_off", "managed-only") + ) + assert (skipped[".claude/rules/style.md"]["file_type"], skipped["AGENTS.md"]["file_type"]) == ("rules", "agents_md") + home = _home_items(out["skipped"]) + assert {k: (v["reason"], v["file_type"]) for k, v in home.items()} == { + "CLAUDE.md": ("setting_off", "main"), + "rules/pref.md": ("setting_off", "rules"), + } + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_claude_md_mode_keeps_every_instruction_file_loaded(tmp_path: Path) -> None: root = _project(tmp_path) _write(root / "CLAUDE.md") - _link(root / "AGENTS.md", "CLAUDE.md") _write(root / ".claude" / "rules" / "style.md") - _mode("managed-only") + _mode("claude-md", "cc-plugin-agents-md@builtin") out = expected_loads(root) - assert out["aliases"] == [] - assert set(_by_name(out["files"], root)) == {".claude/rules/style.md"} - skipped = _by_name(out["skipped"], root) - assert {k: (v["reason"], v["setting"]) for k, v in skipped.items()} == { - "CLAUDE.md": ("setting_off", "managed-only"), - "AGENTS.md": ("setting_off", "managed-only"), + names = set(_by_name([i for i in out["files"] if Path(i["path"]).is_relative_to(root)], root)) + assert {"CLAUDE.md", ".claude/rules/style.md"} <= names + assert out["skipped"] == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_empty_files_are_not_expected_to_load(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md", "") + _write(root / ".claude" / "rules" / "blank.md", " \n\n") + _write(root / ".claude" / "rules" / "real.md") + out = expected_loads(root) + names = set(_by_name([i for i in out["files"] if Path(i["path"]).is_relative_to(root)], root)) + assert names == {".claude/rules/real.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_user_instruction_files_are_listed_with_their_loading(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(Path.home() / ".claude" / "CLAUDE.md") + _write(Path.home() / ".claude" / "rules" / "pref.md") + _write(Path.home() / ".claude" / "rules" / "scoped.md", "---\npaths:\n - src/**\n---\n\nUse type hints.\n") + out = expected_loads(root) + home = _home_items(out["files"]) + assert {k: (v["file_type"], v["loading"], v["precedence"]) for k, v in home.items()} == { + "CLAUDE.md": ("main", "session_start", "user"), + "rules/pref.md": ("rules", "session_start", "user"), + "rules/scoped.md": ("rules", "on_demand", "user"), } - assert (skipped["CLAUDE.md"]["file_type"], skipped["AGENTS.md"]["file_type"]) == ("main", "agents_md") @pytest.mark.unit diff --git a/tests/unit/test_inspect_activation_modes.py b/tests/unit/test_inspect_activation_modes.py index d252cf5..83f8925 100644 --- a/tests/unit/test_inspect_activation_modes.py +++ b/tests/unit/test_inspect_activation_modes.py @@ -50,6 +50,7 @@ def _loading(root: Path, rel: str, frontmatter: str, registry=None): (ANTI, "trigger: glob\n", ("on_demand", "global", ())), # Claude unchanged (".claude/rules/a.md", "", ("session_start", "global", ())), + ("sub/.claude/rules/a.md", "", ("on_demand", "nested", ())), (".claude/rules/a.md", 'paths: "src/**"\n', ("on_demand", "path_scoped", ("src/**",))), ], ) From 45c1636ebb9e45507769a182372c35a110d72033 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sat, 17 Oct 2026 16:47:00 +0200 Subject: [PATCH 06/31] expected-loads: a subdirectory rule keeps its paths filter, a file holding only a byte-order mark or frontmatter is not expected, and one directory listing serves the whole lookup --- UNRELEASED.md | 2 +- src/reporails_cli/core/mapper/inspect.py | 19 ++++++++++---- .../core/pipeline/expected_loads.py | 26 ++++++++++--------- .../test_expected_loads_managed_only.py | 5 ++++ tests/unit/test_expected_loads.py | 3 +++ tests/unit/test_inspect_activation_modes.py | 1 + 6 files changed, 38 insertions(+), 18 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 8c388d0..d7c2ad4 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, which Claude Code sends nothing for, is not expected to load. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed diff --git a/src/reporails_cli/core/mapper/inspect.py b/src/reporails_cli/core/mapper/inspect.py index e941a75..2524c20 100644 --- a/src/reporails_cli/core/mapper/inspect.py +++ b/src/reporails_cli/core/mapper/inspect.py @@ -295,6 +295,12 @@ def _trigger_activation(path: Path, props: JsonObject) -> tuple[str, str, tuple[ return loading, scope, () +def _nested_activation(path: Path, props: JsonObject, loading: str, scope: str) -> tuple[str, str, tuple[str, ...]]: + """Scope a nested file by location, reading a non-empty `path_key` filter to make it path scoped.""" + globs = _parse_frontmatter_globs(path, str(props["path_key"])) if "path_key" in props else () + return (loading, "path_scoped", globs) if globs else (loading, scope, ()) + + def _resolve_activation(path: Path, props: JsonObject, loading: str, scope: str) -> tuple[str, str, tuple[str, ...]]: """The loading, scope and globs a file's frontmatter gives it, from the activation keys its file type (or location scope) declares. @@ -303,10 +309,11 @@ def _resolve_activation(path: Path, props: JsonObject, loading: str, scope: str) start; a non-empty `path_key` filter makes it path scoped; with no filter, a `description_key` value lets the agent pick the file by description (`on_invocation`), and a declared `always_key` or `description_key` without either leaves it for manual attachment (`on_demand`). A path-filtered - type declaring none of those loads at session start without its filter. A nested file is scoped by - where it lives, never by frontmatter. + type declaring none of those loads at session start without its filter. """ - if scope == "nested" or loading not in ("on_demand", "on_invocation"): + if scope == "nested": + return _nested_activation(path, props, loading, scope) + if loading not in ("on_demand", "on_invocation"): return loading, scope, () always_key = props.get("always_key") if loading == "on_demand" and props.get("trigger_key"): @@ -318,8 +325,10 @@ def _resolve_activation(path: Path, props: JsonObject, loading: str, scope: str) return loading, scope, globs description_key = props.get("description_key") if description_key and _frontmatter_text(path, str(description_key)): - return "on_invocation", "global", () - return ("on_demand" if always_key or description_key else "session_start"), "global", () + activation = "on_invocation" + else: + activation = "on_demand" if always_key or description_key else "session_start" + return activation, "global", () # File types whose body loads when the agent or the user invokes them. diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index fdfed21..0b06d20 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -8,7 +8,7 @@ from reporails_cli.core.discovery.exclusions import RunExclusions, load_run_exclusions from reporails_cli.core.discovery.project_root import resolve_project_root from reporails_cli.core.discovery.read_gates import SkippedFile, claude_agents_md_reads, gated_file_types -from reporails_cli.core.discovery.walk import safe_resolve +from reporails_cli.core.discovery.walk import safe_resolve, shared_dir_listings from reporails_cli.core.mapper.inspect import _load_registry, file_read_by from reporails_cli.core.platform.config.bootstrap import get_xray_log_dir from reporails_cli.core.platform.config.claude_settings import ( @@ -19,6 +19,7 @@ from reporails_cli.core.platform.config.config import get_project_config from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue from reporails_cli.core.platform.dto.results import ProjectConfig +from reporails_cli.core.platform.utils.utils import strip_frontmatter AGENT = "claude" @@ -31,15 +32,16 @@ def expected_loads(target: Path) -> JsonObject: project_config = get_project_config(root) registry = _load_registry() mode = claude_instruction_files_mode(root) - files, read_instructions, switched_off = _loaded_files(exclusions, project_config, root, registry, mode) + with shared_dir_listings(): + files, read_instructions, switched_off = _loaded_files(exclusions, project_config, root, registry, mode) + candidates = discover_ungated( + root, + AGENT, + gated_file_types(AGENT), + exclusions.walk_dirs, + project_config=project_config, + ) skipped = [SkippedFile(path, "setting_off") for path in switched_off] - candidates = discover_ungated( - root, - AGENT, - gated_file_types(AGENT), - exclusions.walk_dirs, - project_config=project_config, - ) reads = claude_agents_md_reads([p for p in candidates if not exclusions.leaves_out(p)], root, read_instructions) skipped.extend(reads.skipped) skipped_out: list[JsonValue] = [ @@ -97,10 +99,10 @@ def _loaded_files( def _is_blank(path: Path) -> bool: - """Whether the file holds nothing but whitespace; Claude Code sends nothing for it. A file that - cannot be read stays listed.""" + """Whether the file holds nothing but whitespace once its byte-order mark and frontmatter are + removed; Claude Code sends nothing for it. A file that cannot be read stays listed.""" try: - return not path.read_text(encoding="utf-8").strip() + return not strip_frontmatter(path.read_text(encoding="utf-8-sig")).strip() except (OSError, UnicodeDecodeError): return False diff --git a/tests/integration/test_expected_loads_managed_only.py b/tests/integration/test_expected_loads_managed_only.py index 1762c17..99017d5 100644 --- a/tests/integration/test_expected_loads_managed_only.py +++ b/tests/integration/test_expected_loads_managed_only.py @@ -11,6 +11,8 @@ import pytest PLUGIN_ID = "cc-plugin-agents-md@builtin" +# Claude Code's machine-wide managed folders, which `expected-loads` reads whatever HOME is. +MANAGED_FOLDERS = ("/etc/claude-code", "/Library/Application Support/ClaudeCode", "C:/Program Files/ClaudeCode") def _write(path: Path, text: str = "# Notes\n\nRun the tests.\n") -> None: @@ -37,6 +39,9 @@ def _expected_loads(project: Path, home: Path) -> dict: @pytest.mark.integration @pytest.mark.subsys_classify def test_managed_only_leaves_out_launch_files_and_keeps_on_demand_ones(tmp_path: Path) -> None: + for folder in MANAGED_FOLDERS: + if Path(folder).exists(): + pytest.skip(f"managed Claude Code files at {folder} would be listed beside the isolated HOME's") home = tmp_path / "home" project = tmp_path / "proj" _write( diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index bdfd568..83eb891 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -318,10 +318,13 @@ def test_empty_files_are_not_expected_to_load(tmp_path: Path) -> None: root = _project(tmp_path) _write(root / "CLAUDE.md", "") _write(root / ".claude" / "rules" / "blank.md", " \n\n") + (root / ".claude" / "rules" / "bom.md").write_bytes(b"\xef\xbb\xbf\n") + _write(root / ".claude" / "rules" / "fmonly.md", "---\npaths: src/**\n---\n") _write(root / ".claude" / "rules" / "real.md") out = expected_loads(root) names = set(_by_name([i for i in out["files"] if Path(i["path"]).is_relative_to(root)], root)) assert names == {".claude/rules/real.md"} + assert not [i for i in out["skipped"] if Path(i["path"]).is_relative_to(root)] @pytest.mark.unit diff --git a/tests/unit/test_inspect_activation_modes.py b/tests/unit/test_inspect_activation_modes.py index 83f8925..37f3e46 100644 --- a/tests/unit/test_inspect_activation_modes.py +++ b/tests/unit/test_inspect_activation_modes.py @@ -51,6 +51,7 @@ def _loading(root: Path, rel: str, frontmatter: str, registry=None): # Claude unchanged (".claude/rules/a.md", "", ("session_start", "global", ())), ("sub/.claude/rules/a.md", "", ("on_demand", "nested", ())), + ("sub/.claude/rules/a.md", 'paths: "src/**"\n', ("on_demand", "path_scoped", ("src/**",))), (".claude/rules/a.md", 'paths: "src/**"\n', ("on_demand", "path_scoped", ("src/**",))), ], ) From ecdafa5729c0d101bbf476dc4db720a5414e677d Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Sun, 18 Oct 2026 21:11:25 +0200 Subject: [PATCH 07/31] mcp: the validate tool tells the agent which messages are announcements from reporails, and the getting-started docs say the notices list carries them --- UNRELEASED.md | 2 ++ docs/getting-started.md | 2 +- src/reporails_cli/interfaces/mcp/server.py | 7 +++++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index d7c2ad4..ae01eb3 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,6 +6,8 @@ ### Changed +- The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. + ### Fixed - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. diff --git a/docs/getting-started.md b/docs/getting-started.md index 7e6e16f..b292a6e 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -114,7 +114,7 @@ ails check --heal --cwd # with --heal: opt into rewriting the whole pro `--heal` needs an explicit target — a path or a capability like `skills` — or `--cwd` to opt into rewriting the whole project; a bare `ails check --heal` exits with an error naming both options. It also needs a Pro account: run `ails login` with one. Without one (anonymous, or signed in on a free account) the run still prints the full diagnosis, then declines the fix pass with `Applying fixes needs a Pro account.` and applies nothing. See [Tiers and Limits](tiers.md). -The JSON output groups findings under `files{path: {findings: [...], count: N}}` plus aggregate `stats`, a `notices` list of messages about your account, and (when present) `cross_file` blocks — see [Configuration → Output format](configuration.md#output-format) for the full shape, including which fields are tier-conditional. +The JSON output groups findings under `files{path: {findings: [...], count: N}}` plus aggregate `stats`, a `notices` list of messages about your account and announcements from reporails, and (when present) `cross_file` blocks — see [Configuration → Output format](configuration.md#output-format) for the full shape, including which fields are tier-conditional. ## Focus on one file or capability diff --git a/src/reporails_cli/interfaces/mcp/server.py b/src/reporails_cli/interfaces/mcp/server.py index bfdd175..7dfec83 100644 --- a/src/reporails_cli/interfaces/mcp/server.py +++ b/src/reporails_cli/interfaces/mcp/server.py @@ -461,8 +461,11 @@ def _text_result(text: str) -> CallToolResult: " `introduced`, and a" " `feedback` list of the file's remaining findings, with any problem the rewrite newly" " introduced listed first." - " `notices`, when present, are messages for the user about their account (for example a" - " failed payment): show each one to the user." + " `notices`, when present, are short messages for the user: either about their account" + " (for example a failed payment) or an announcement from reporails, marked" + ' `kind: "promo"` (a `promo:` line in the text view). Show each one to the user,' + " presenting an announcement as an announcement, not as an account message." + " `AILS_NO_PROMO=1` in the server's environment leaves announcements out." " Use when user asks to check, validate, or improve instruction files." ), annotations=_READ_ONLY, From effb6a11ea474cb8e49a527a36955536769df594 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Mon, 19 Oct 2026 20:53:42 +0200 Subject: [PATCH 08/31] expected-loads: each skipped AGENTS.md names the CLAUDE.md, .claude/CLAUDE.md or CLAUDE.local.md that made Claude Code skip it --- UNRELEASED.md | 1 + .../core/discovery/read_gates.py | 73 ++++++++++--------- .../core/pipeline/expected_loads.py | 3 +- tests/unit/test_expected_loads.py | 54 ++++++++++++++ 4 files changed, 96 insertions(+), 35 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index ae01eb3..84f7a6c 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,6 +6,7 @@ ### Changed +- `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. - The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. ### Fixed diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index 4915f80..e626d39 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -35,39 +35,39 @@ _CLAUDE_FAMILY = ("claude.md", "claude.local.md") -def _names_in(directory: Path) -> set[str]: - return {entry.name.lower() for entry in list_dir(str(directory)) or () if entry.is_file} - - -def _dot_claude_md(directory: Path) -> Path | None: - """`directory/.claude/CLAUDE.md` (any case), or None.""" - dot_claude = directory / ".claude" - for entry in list_dir(str(dot_claude)) or (): - if entry.name.lower() == "claude.md" and entry.is_file: +def _file_named(directory: Path, name: str) -> Path | None: + """The file in `directory` called `name` (any case), or None.""" + for entry in list_dir(str(directory)) or (): + if entry.name.lower() == name and entry.is_file: return Path(entry.path) return None -def _has_claude_file(directory: Path, user_file: Path) -> bool: - """Whether `directory` holds a `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md`.""" - if _names_in(directory) & set(_CLAUDE_FAMILY): - return True - dot = _dot_claude_md(directory) - if dot is None: - return False - return safe_resolve(dot) != user_file +def _claude_file_in(directory: Path, user_file: Path) -> Path | None: + """The `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` in `directory`, in that order, or None.""" + plain = _file_named(directory, "claude.md") + if plain is not None: + return plain + dot = _file_named(directory / ".claude", "claude.md") + if dot is not None and safe_resolve(dot) != user_file: + return dot + return _file_named(directory, "claude.local.md") def _user_claude_md() -> Path: return safe_resolve(Path.home() / ".claude" / "CLAUDE.md") -def claude_file_at_or_above(root: Path) -> bool: - """Whether a `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` sits in `root` or - any directory above it, other than the user's own `~/.claude/CLAUDE.md`.""" +def claude_file_at_or_above(root: Path) -> Path | None: + """The `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` nearest `root`, looking in `root` + and then each directory above it, other than the user's own `~/.claude/CLAUDE.md`; None when there is none.""" user_file = _user_claude_md() start = safe_resolve(root) - return any(_has_claude_file(directory, user_file) for directory in (start, *start.parents)) + for directory in (start, *start.parents): + found = _claude_file_in(directory, user_file) + if found is not None: + return found + return None def _never_read(path: Path, root: Path) -> bool: @@ -89,10 +89,12 @@ def _loads_at_session_start(path: Path, root: Path) -> bool: @dataclass(frozen=True) class SkippedFile: - """An `AGENTS.md` Claude Code does not read, and why.""" + """An `AGENTS.md` Claude Code does not read, and why; `cause` is the `CLAUDE.md`-family file that made + it skip the file (`claude_md_present`, `subdir_has_claude_md`), None for the other reasons.""" path: Path reason: SkipReason + cause: Path | None @dataclass(frozen=True) @@ -126,17 +128,17 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] kept: list[Path] = [] skipped: list[SkippedFile] = [] aliases: list[AliasFile] = [] - claude_above = mode == MODE_CLAUDE_MD_OR_AGENTS_MD and claude_file_at_or_above(root) + claude_above = claude_file_at_or_above(root) if mode == MODE_CLAUDE_MD_OR_AGENTS_MD else None user_file = _user_claude_md() claude_files = {} if mode in MODES_WITHOUT_LAUNCH_INSTRUCTIONS else _claude_files_by_identity(read, root) for path in found: - reason = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) - if reason is None: + skip = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) + if skip is None: kept.append(path) continue same_as = claude_files.get(safe_resolve(path)) if same_as is None: - skipped.append(SkippedFile(path, reason)) + skipped.append(SkippedFile(path, *skip)) else: aliases.append(AliasFile(path, same_as)) return AgentsMdReads(kept, skipped, mode, aliases) @@ -159,19 +161,22 @@ def _in_agents_dir(path: Path, root: Path) -> bool: return False -def _skip_reason(path: Path, root: Path, mode: str, *, claude_above: bool, user_file: Path) -> SkipReason | None: - """Why Claude Code skips `path`, None when it loads it.""" +def _skip_reason( + path: Path, root: Path, mode: str, *, claude_above: Path | None, user_file: Path +) -> tuple[SkipReason, Path | None] | None: + """Why Claude Code skips `path` and the `CLAUDE.md`-family file that made it, None when it loads it.""" if _never_read(path, root): - return "never_read" + return "never_read", None if mode in MODES_WITHOUT_AGENTS_MD: - return "setting_off" + return "setting_off", None if mode == MODE_CLAUDE_MD_AND_AGENTS_MD: return None - if claude_above: - return "claude_md_present" - if _loads_at_session_start(path, root) or not _has_claude_file(path.parent, user_file): + if claude_above is not None: + return "claude_md_present", claude_above + if _loads_at_session_start(path, root): return None - return "subdir_has_claude_md" + beside = _claude_file_in(path.parent, user_file) + return None if beside is None else ("subdir_has_claude_md", beside) def claude_agents_md_files(found: list[Path], target: Path) -> list[Path]: diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 0b06d20..251ece2 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -41,7 +41,7 @@ def expected_loads(target: Path) -> JsonObject: exclusions.walk_dirs, project_config=project_config, ) - skipped = [SkippedFile(path, "setting_off") for path in switched_off] + skipped = [SkippedFile(path, "setting_off", None) for path in switched_off] reads = claude_agents_md_reads([p for p in candidates if not exclusions.leaves_out(p)], root, read_instructions) skipped.extend(reads.skipped) skipped_out: list[JsonValue] = [ @@ -49,6 +49,7 @@ def expected_loads(target: Path) -> JsonObject: "path": str(item.path), "file_type": switched_off.get(item.path) or _file_type_of_skipped(item.path, root, registry), "reason": item.reason, + "cause": None if item.cause is None else str(item.cause), "setting": mode, } for item in sorted(skipped, key=lambda s: s.path) diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 83eb891..6e1b610 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -365,3 +365,57 @@ def test_command_rejects_a_path_that_is_not_a_folder(tmp_path: Path) -> None: result = runner.invoke(app, ["expected-loads", str(bad)]) assert result.exit_code == 2 assert "Path not found or not a folder" in result.output + + +def _causes(out: dict, root: Path) -> dict[str, str | None]: + return {name: item["cause"] for name, item in _by_name(out["skipped"], root).items()} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_skipped_agents_md_names_the_plain_claude_md_that_caused_it(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + _write(root / "AGENTS.md") + assert _causes(expected_loads(root), root) == {"AGENTS.md": str(root / "CLAUDE.md")} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_skipped_agents_md_names_a_dot_claude_claude_md(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / ".claude" / "CLAUDE.md") + _write(root / "AGENTS.md") + assert _causes(expected_loads(root), root) == {"AGENTS.md": str(root / ".claude" / "CLAUDE.md")} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_subfolder_agents_md_names_the_claude_local_md_beside_it(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "sub" / "CLAUDE.local.md") + _write(root / "sub" / "AGENTS.md") + _write(root / "AGENTS.md") + out = expected_loads(root) + assert _causes(out, root) == {"sub/AGENTS.md": str(root / "sub" / "CLAUDE.local.md")} + assert _by_name(out["skipped"], root)["sub/AGENTS.md"]["reason"] == "subdir_has_claude_md" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_below_a_parent_folders_claude_md_names_that_file(tmp_path: Path) -> None: + _write(tmp_path / "CLAUDE.md") + root = _project(tmp_path) + _write(root / "AGENTS.md") + out = expected_loads(root) + assert _causes(out, root) == {"AGENTS.md": str(tmp_path / "CLAUDE.md")} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_skip_without_a_claude_file_has_no_cause(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "AGENTS.md") + _write(root / ".agents" / "AGENTS.md") + _mode("claude-md") + assert set(_causes(expected_loads(root), root).values()) == {None} From fd2a7cf12d8da5e8240e93023f5203d7bf13e3c3 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 11:42:27 +0200 Subject: [PATCH 09/31] npm: a pinned npx or global npm install runs the version you name, ails update prints the npm command for it, and npx install puts ails on PATH; a build check refuses enum values spelled as plain text --- UNRELEASED.md | 6 ++ docs/configuration.md | 2 +- docs/getting-started.md | 2 + packages/npm/bin/reporails.mjs | 11 ++- src/reporails_cli/core/install/self_update.py | 33 +++++++++ .../core/platform/config/bootstrap.py | 5 ++ .../core/platform/policy/levels.py | 26 +++---- src/reporails_cli/interfaces/cli/commands.py | 9 ++- src/reporails_cli/interfaces/cli/install.py | 4 +- .../test_one_owner_per_vocabulary.py | 73 ++++++++++++++++--- tests/unit/test_levels_mut.py | 3 +- tests/unit/test_npm_install_method.py | 55 ++++++++++++++ 12 files changed, 198 insertions(+), 31 deletions(-) create mode 100644 tests/unit/test_npm_install_method.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 84f7a6c..da7d6cb 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,14 +6,20 @@ ### Changed +- A global npm install stays on its version until you update it; on an npm or npx install `ails update` prints the command that updates it, and `ails version` shows `npm` or `npx`. +- A run through the npm package no longer re-checks the package index for a newer release every time it starts. - `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. - The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. ### Fixed +- `npx @reporails/cli install` puts `ails` on your PATH instead of reporting the temporary npx copy as already installed. +- A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` still runs the latest. - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. ### Removed ### Internal + +- A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. diff --git a/docs/configuration.md b/docs/configuration.md index 4a74acf..989b69a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,7 +123,7 @@ steps: - run: ails check ``` -Install a pinned version as shown (or `uvx --from "reporails-cli==" ails check` where `uv` is set up). `npx @reporails/cli` always runs the latest CLI whatever version you give it, so it cannot be pinned for this key. `actions/cache` saves only when the job succeeds: a job that fails on `ails check --strict` findings saves nothing, and the next run downloads the model again. The GitHub Action keeps the model even when the check fails. +Install a pinned version as shown (or `uvx --from "reporails-cli==" ails check` where `uv` is set up). A pinned `npx @reporails/cli@` or `npm install -g @reporails/cli@` runs exactly that version from 0.6.5 on (earlier npm releases ran the latest release whatever version was named), so it can key the cache too; a bare `npx @reporails/cli` runs the latest release. `actions/cache` saves only when the job succeeds: a job that fails on `ails check --strict` findings saves nothing, and the next run downloads the model again. The GitHub Action keeps the model even when the check fails. When `ails` downloads the model inside GitHub Actions, it prints a line pointing to this section. diff --git a/docs/getting-started.md b/docs/getting-started.md index b292a6e..25b5f6a 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,6 +63,8 @@ uv tool install reporails-cli npm install -g @reporails/cli ``` +A global npm install stays on the version it installed; `ails update` prints the npm command that moves it to the latest release. + `ails install` then ensures the binary is on PATH. After it runs, `ails check` works from anywhere without the `npx` / `uvx` prefix. **2. Add the reporails plugin to your agent.** The plugin carries the `ails` skill and the MCP server; installing it registers both in one step — no per-agent config editing. The per-agent install commands are listed in [Agent Support](agent-support.md#plugin-support); `ails install` runs them for Claude Code and Codex and prints the rest. Add `--project` to install the plugin for the current repository only, shared with collaborators (Codex installs for your user); `ails update` refreshes the plugin in each agent that has it. What the plugin needs to start is stated there. Without the plugin, `ails check` in your terminal scores your instruction files, and `--heal` applies fixes on a Pro account; rewriting them needs the plugin and Pro (`/reporails:ails heal` in Claude Code). diff --git a/packages/npm/bin/reporails.mjs b/packages/npm/bin/reporails.mjs index c46a2b5..38aa815 100755 --- a/packages/npm/bin/reporails.mjs +++ b/packages/npm/bin/reporails.mjs @@ -1,11 +1,17 @@ #!/usr/bin/env node import { execSync, spawn } from "node:child_process"; +import { readFileSync } from "node:fs"; import { platform } from "node:os"; -import { argv, exit } from "node:process"; +import { argv, env, exit } from "node:process"; +import { fileURLToPath } from "node:url"; const PYPI_PACKAGE = "reporails-cli"; const CLI_COMMAND = "ails"; +const VERSION = JSON.parse( + readFileSync(new URL("../package.json", import.meta.url), "utf8"), +).version; +const LAUNCHER = /[\\/]_npx[\\/]/.test(fileURLToPath(import.meta.url)) ? "npx" : "npm"; const HELP = ` ails — Validate and score AI instruction files @@ -83,8 +89,9 @@ function ensureUv() { function proxy(args) { ensureUv(); - const child = spawn("uvx", ["--refresh-package", PYPI_PACKAGE, "--from", PYPI_PACKAGE, CLI_COMMAND, ...args], { + const child = spawn("uvx", ["--from", `${PYPI_PACKAGE}==${VERSION}`, CLI_COMMAND, ...args], { stdio: "inherit", + env: { ...env, AILS_LAUNCHER: LAUNCHER }, }); child.on("error", (err) => { diff --git a/src/reporails_cli/core/install/self_update.py b/src/reporails_cli/core/install/self_update.py index 5f0cd9c..5bad362 100644 --- a/src/reporails_cli/core/install/self_update.py +++ b/src/reporails_cli/core/install/self_update.py @@ -7,6 +7,8 @@ from importlib.metadata import PackageNotFoundError, distribution from pathlib import Path +from reporails_cli.core.platform.config.bootstrap import npm_launcher + PKG_NAME = "reporails-cli" @@ -16,11 +18,42 @@ class InstallMethod(str, Enum): UV = "uv" PIP = "pip" PIPX = "pipx" + NPM = "npm" + NPX = "npx" DEV = "dev" UNKNOWN = "unknown" +NPM_LAUNCHES = frozenset({InstallMethod.NPM, InstallMethod.NPX}) + + def detect_install_method() -> InstallMethod: + """Detect how reporails-cli was installed: the npm launcher when it names one, else package metadata.""" + try: + launcher = InstallMethod(npm_launcher()) + except ValueError: + return _method_from_metadata() + if launcher in NPM_LAUNCHES: + return launcher + return _method_from_metadata() + + +def npm_update_hint(method: InstallMethod) -> str | None: + """The sentence `ails update` prints for an npm or npx install; None for other methods.""" + if method == InstallMethod.NPX: + return ( + "This ails ran through npx. A bare `npx @reporails/cli` runs the latest release on every run; " + "a pinned `npx @reporails/cli@X.Y.Z` runs that version, so raise the version to move it." + ) + if method == InstallMethod.NPM: + return ( + "This ails runs from the npm package. Update a global install with " + "`npm install -g @reporails/cli@latest`; for a project dependency, raise its version in package.json." + ) + return None + + +def _method_from_metadata() -> InstallMethod: """Detect how reporails-cli was installed using package metadata.""" try: dist = distribution(PKG_NAME) diff --git a/src/reporails_cli/core/platform/config/bootstrap.py b/src/reporails_cli/core/platform/config/bootstrap.py index a281165..4b2a040 100644 --- a/src/reporails_cli/core/platform/config/bootstrap.py +++ b/src/reporails_cli/core/platform/config/bootstrap.py @@ -13,6 +13,11 @@ def env_flag(var_name: str) -> bool: return os.environ.get(var_name, "").strip().lower() not in ("", "0", "false", "no", "off") +def npm_launcher() -> str: + """The launcher named by AILS_LAUNCHER, stripped; blank when unset.""" + return os.environ.get("AILS_LAUNCHER", "").strip() + + def parse_idle_timeout_env(var_name: str, default_s: int) -> int | None: """Parse an idle-timeout env var (seconds): unset/blank/non-numeric → default, a positive int → that value, 0 or negative → None (idle shutdown disabled).""" diff --git a/src/reporails_cli/core/platform/policy/levels.py b/src/reporails_cli/core/platform/policy/levels.py index e740e2d..fabd17a 100644 --- a/src/reporails_cli/core/platform/policy/levels.py +++ b/src/reporails_cli/core/platform/policy/levels.py @@ -40,15 +40,15 @@ # per the ladder's "highest architectural capability present" rule — a # project may skip a level (skills + sub-agents + memory with no path-scoped # rules) and still reach the level its highest surface supports. -LEVEL_CAPS: dict[str, list[str]] = { - "L0": [], - "L1": ["instruction_file"], - "L2": ["multiple_files"], - "L3": ["path_scoping"], - "L4": ["skills"], - "L5": ["agents"], - "L6": ["governance"], - "L7": ["adaptive_memory"], +LEVEL_CAPS: dict[Level, list[str]] = { + Level.L0: [], + Level.L1: ["instruction_file"], + Level.L2: ["multiple_files"], + Level.L3: ["path_scoping"], + Level.L4: ["skills"], + Level.L5: ["agents"], + Level.L6: ["governance"], + Level.L7: ["adaptive_memory"], } # Capability → detection mapping (CLI-owned). @@ -82,19 +82,19 @@ def determine_level_from_gates(features: DetectedFeatures) -> Level: reaches the level its richest surface supports instead of being capped at L2. A project with no instruction file at all is L0. """ - if not _level_has_capability(features, Level.L1.value): + if not _level_has_capability(features, Level.L1): return Level.L0 highest = Level.L0 for level in _LEVEL_ORDER: - if _level_has_capability(features, level.value): + if _level_has_capability(features, level): highest = level return highest -def _level_has_capability(features: DetectedFeatures, level_key: str) -> bool: +def _level_has_capability(features: DetectedFeatures, level: Level) -> bool: """Check if at least one capability at the given level is detected (OR).""" - capabilities = LEVEL_CAPS.get(level_key, []) + capabilities = LEVEL_CAPS.get(level, []) if not capabilities: return True return any(_detect_capability(features, cap_id) for cap_id in capabilities) diff --git a/src/reporails_cli/interfaces/cli/commands.py b/src/reporails_cli/interfaces/cli/commands.py index 2fb1502..dabc9a7 100644 --- a/src/reporails_cli/interfaces/cli/commands.py +++ b/src/reporails_cli/interfaces/cli/commands.py @@ -6,7 +6,7 @@ import typer -from reporails_cli.core.install.self_update import detect_install_method +from reporails_cli.core.install.self_update import detect_install_method, npm_update_hint from reporails_cli.core.platform.utils.version import installed_version from reporails_cli.interfaces.cli.helpers import console, run_uv_tool from reporails_cli.interfaces.cli.typer_app import app @@ -40,7 +40,12 @@ def show_version() -> None: def _update_engine() -> bool: - """Upgrade the engine with uv; True when it is current or was installed.""" + """Upgrade the engine with the tool that installed it; True when it is current or was installed.""" + hint = npm_update_hint(detect_install_method()) + if hint: + console.print(hint) + return True + uv = shutil.which("uv") if not uv: console.print("[red]uv not found.[/red] Install it: https://docs.astral.sh/uv/") diff --git a/src/reporails_cli/interfaces/cli/install.py b/src/reporails_cli/interfaces/cli/install.py index 1148351..e65bc50 100644 --- a/src/reporails_cli/interfaces/cli/install.py +++ b/src/reporails_cli/interfaces/cli/install.py @@ -65,7 +65,9 @@ def _uv_cache_dir(uv: str) -> str | None: def _is_temporary_ails(ails_path: str, uv: str | None) -> bool: - """True when `ails_path` sits in uv's cache, i.e. a one-shot `uvx` environment.""" + """True when `ails_path` sits in an npx cache or in uv's cache, i.e. a one-shot copy.""" + if "_npx" in Path(ails_path).parts: + return True cache = _uv_cache_dir(uv) if uv else None if cache: return Path(os.path.realpath(ails_path)).is_relative_to(os.path.realpath(cache)) diff --git a/tests/unit/architecture/test_one_owner_per_vocabulary.py b/tests/unit/architecture/test_one_owner_per_vocabulary.py index 79faaa1..321ecc2 100644 --- a/tests/unit/architecture/test_one_owner_per_vocabulary.py +++ b/tests/unit/architecture/test_one_owner_per_vocabulary.py @@ -9,8 +9,10 @@ - A1 private group: a string-collection literal (set, tuple or list display, `frozenset(...)` / `set(...)` / `tuple(...)` / `list(...)` of one literal, or the members of an inline `Literal[...]`) with at least two distinct members, outside the owner module, whose members all belong to one - vocabulary. Import the owner's named group, or add the group beside the owner. Dict displays are - not groups. + vocabulary. Import the owner's named group, or add the group beside the owner. For a `str`-Enum / + `StrEnum` vocabulary a string group or a dict keyed by the members' string values fails anywhere, + the owner module included: use a group of members, a dict keyed by members, or `X(value)`. Dict + displays over a `Literal` vocabulary are not groups (mypy checks their keys). - A2 unowned shared group: two such literals in different modules that share at least two members while one contains the other, or whose Jaccard similarity is above 0.5. The shared members belong in one named group in one module. `__all__` and dict displays are not compared. @@ -68,6 +70,7 @@ class Vocabulary: name: str owner: str members: frozenset[str] + enum: bool = False @dataclass(frozen=True) @@ -77,6 +80,7 @@ class Group: module: str line: int members: frozenset[str] + is_dict: bool = False @property def site(self) -> str: @@ -108,9 +112,11 @@ def _string_elements(elts: Iterable[ast.expr]) -> list[str] | None: def _display_members(node: ast.AST) -> list[str] | None: - """Members of a set/tuple/list display of string constants, else None.""" + """Members of a set/tuple/list display of string constants (or a dict display's keys), else None.""" if isinstance(node, (ast.Set, ast.Tuple, ast.List)): return _string_elements(node.elts) + if isinstance(node, ast.Dict): + return _string_elements([k if k is not None else ast.Pass() for k in node.keys]) return None @@ -166,9 +172,9 @@ def _vocabulary_definitions(tree: ast.Module) -> Iterator[tuple[str, list[str], def collect_vocabularies(sources: list[Path], root: Path) -> list[Vocabulary]: """Every closed string vocabulary defined in `sources`, owned by its defining module.""" return [ - Vocabulary(name, _rel(py, root), frozenset(members)) + Vocabulary(name, _rel(py, root), frozenset(members), isinstance(definition, ast.ClassDef)) for py in sources - for name, members, _ in _vocabulary_definitions(_parse(py)) + for name, members, definition in _vocabulary_definitions(_parse(py)) ] @@ -214,16 +220,22 @@ def collect_groups(sources: list[Path], root: Path) -> list[Group]: consumed.add(id(inner)) if id(inner) in skip or len(set(members)) < 2: continue - groups.append(Group(_rel(py, root), getattr(node, "lineno", 0), frozenset(members))) + groups.append( + Group(_rel(py, root), getattr(node, "lineno", 0), frozenset(members), isinstance(node, ast.Dict)) + ) return groups def private_group_violations(sources: list[Path], root: Path, groups: list[Group] | None = None) -> list[Violation]: - """A1: a group whose members all belong to one vocabulary, outside the owner module.""" + """A1: a group inside one vocabulary, outside its owner; for enum vocabularies anywhere, dicts included.""" vocabularies = collect_vocabularies(sources, root) out: list[Violation] = [] for group in groups if groups is not None else collect_groups(sources, root): - matches = [v for v in vocabularies if group.members <= v.members and v.owner != group.module] + matches = [ + v + for v in vocabularies + if group.members <= v.members and (v.enum or (v.owner != group.module and not group.is_dict)) + ] if not matches: continue vocab = min(matches, key=lambda v: (len(v.members), v.name)) @@ -240,7 +252,7 @@ def private_group_violations(sources: list[Path], root: Path, groups: list[Group def shared_group_violations(sources: list[Path], root: Path, groups: list[Group] | None = None) -> list[Violation]: """A2: two groups in different modules where one contains the other or most members are shared.""" - groups = groups if groups is not None else collect_groups(sources, root) + groups = [g for g in (groups if groups is not None else collect_groups(sources, root)) if not g.is_dict] out: list[Violation] = [] for i, left in enumerate(groups): for right in groups[i + 1 :]: @@ -409,16 +421,55 @@ def test_a1_private_subset_of_a_vocabulary_fires(tmp_path: Path) -> None: assert "Fmt" in found[0].detail and "vocab.py" in found[0].detail +_ENUM = 'from enum import StrEnum\nclass InstallMethod(StrEnum):\n NPM = "npm"\n NPX = "npx"\n UV = "uv"\n' + + +@pytest.mark.architecture +def test_a1_dict_keyed_by_vocabulary_values_fires(tmp_path: Path) -> None: + """A string-keyed dict over a StrEnum's values fires inside and outside the enum's module.""" + sources = _plant( + tmp_path, + { + "vocab.py": _VOCAB, + "install.py": _ENUM + '_LAUNCHERS = {"npm": InstallMethod.NPM, "npx": InstallMethod.NPX}\n', + "red.py": 'from install import InstallMethod\nT = {"npm": 1, "uv": 2}\n', + }, + ) + found = private_group_violations(sources, tmp_path) + assert {v.key for v in found} == {"A1 red.py:2", "A1 install.py:6"} + + +@pytest.mark.architecture +def test_a1_stays_quiet_on_member_keys_literal_tables_and_constructor(tmp_path: Path) -> None: + """Member-keyed dicts, string-keyed tables over a Literal vocabulary and `X(value)` pass.""" + sources = _plant( + tmp_path, + { + "vocab.py": _VOCAB + 'RANK = {"prose": 0, "list": 1}\n', + "install.py": ( + _ENUM + "RANK = {InstallMethod.NPM: 1, InstallMethod.NPX: 2}\n" + "GROUP = frozenset({InstallMethod.NPM, InstallMethod.NPX})\n" + "def parse(value: str) -> InstallMethod:\n return InstallMethod(value)\n" + ), + "other.py": ( + "from install import InstallMethod\nT = {InstallMethod.NPM: 1, InstallMethod.UV: 2}\n" + 'LITERAL_TABLE = {"prose": 1, "table": 2}\n' + ), + }, + ) + assert private_group_violations(sources, tmp_path) == [] + + @pytest.mark.architecture def test_a1_stays_quiet_on_owner_group_and_unrelated_sets(tmp_path: Path) -> None: - """The owner's subgroup, a group with a foreign member, a one-member set and a dict pass.""" + """The owner's subgroup, a group with a foreign member, a one-member set and a foreign-keyed dict pass.""" sources = _plant( tmp_path, { "vocab.py": _VOCAB, "green.py": ( 'OTHER = {"prose", "unrelated"}\nSINGLE = {"prose"}\n' - 'MAP = {"prose": 1, "list": 2}\n' + 'MAP = {"prose": 1, "unrelated": 2}\n' "from vocab import LISTY\nUSES = LISTY\n" ), }, diff --git a/tests/unit/test_levels_mut.py b/tests/unit/test_levels_mut.py index 2e71d79..b15b011 100644 --- a/tests/unit/test_levels_mut.py +++ b/tests/unit/test_levels_mut.py @@ -10,6 +10,7 @@ import pytest +from reporails_cli.core.platform.dto.models import Level from reporails_cli.core.platform.dto.results import DetectedFeatures, MemoryFeatures from reporails_cli.core.platform.policy.levels import ( _detect_capability, @@ -50,7 +51,7 @@ class TestLevelHasCapability: def test_level_with_no_capabilities_passes_vacuously(self) -> None: # Kills L199 `return True -> False`: a level whose capability list is # empty (L0) passes vacuously. - assert _level_has_capability(DetectedFeatures(), "L0") is True + assert _level_has_capability(DetectedFeatures(), Level.L0) is True # ── Detector dispatch (L206, L207) ─────────────────────────────────── diff --git a/tests/unit/test_npm_install_method.py b/tests/unit/test_npm_install_method.py new file mode 100644 index 0000000..3d3a0b8 --- /dev/null +++ b/tests/unit/test_npm_install_method.py @@ -0,0 +1,55 @@ +"""npm / npx installs: detection, `ails update` hint and one-shot npx copies.""" + +from __future__ import annotations + +import subprocess + +import pytest + +from reporails_cli.core.install.self_update import InstallMethod, detect_install_method +from reporails_cli.interfaces.cli import commands as commands_module +from reporails_cli.interfaces.cli.install import _is_temporary_ails + +pytestmark = [pytest.mark.unit, pytest.mark.subsys_cli_ux] + + +@pytest.mark.parametrize( + ("launcher", "expected"), + [("npm", InstallMethod.NPM), ("npx", InstallMethod.NPX)], +) +def test_launcher_selects_install_method(monkeypatch, launcher, expected): + monkeypatch.setenv("AILS_LAUNCHER", launcher) + assert detect_install_method() == expected + + +@pytest.mark.parametrize("launcher", ["", "pip", "NPM", "yarn", "uv"]) +def test_other_launcher_values_fall_through(monkeypatch, launcher): + monkeypatch.setenv("AILS_LAUNCHER", launcher) + assert detect_install_method() not in (InstallMethod.NPM, InstallMethod.NPX) + + +@pytest.mark.parametrize( + ("launcher", "expected_text"), + [ + ("npm", "npm install -g @reporails/cli@latest"), + ("npx", "A bare `npx @reporails/cli` runs the latest release on every run"), + ], +) +def test_update_prints_hint_and_runs_nothing(monkeypatch, capsys, launcher, expected_text): + monkeypatch.setenv("AILS_LAUNCHER", launcher) + calls: list[object] = [] + monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(a)) + assert commands_module._update_engine() is True + assert expected_text in " ".join(capsys.readouterr().out.split()) + assert calls == [] + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/home/u/.npm/_npx/ab12cd/node_modules/.bin/ails", True), + ("/usr/local/bin/ails", False), + ], +) +def test_npx_copy_is_temporary(path, expected): + assert _is_temporary_ails(path, None) is expected From 97f1f095ee2d0ea2d86c09934ddb013abad5bad3 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 13:44:37 +0200 Subject: [PATCH 10/31] npm: ails update prints one npm hint, the launch marker is read once and not passed on, ails install looks past project and one-shot copies on PATH, and the npx smoke check verifies the version it ran --- .github/workflows/post-publish-smoke.yml | 5 +- UNRELEASED.md | 6 +- docs/configuration.md | 2 +- docs/getting-started.md | 2 +- packages/npm/bin/reporails.mjs | 4 +- src/reporails_cli/core/install/self_update.py | 27 ++--- .../core/platform/config/bootstrap.py | 8 +- src/reporails_cli/interfaces/cli/install.py | 15 ++- src/reporails_cli/interfaces/cli/typer_app.py | 2 + tests/conftest.py | 11 ++ tests/integration/test_npm_launcher.py | 71 ++++++++++++ .../test_one_owner_per_vocabulary.py | 3 +- tests/unit/test_install_command.py | 12 +- tests/unit/test_npm_install_method.py | 108 ++++++++++++++---- 14 files changed, 214 insertions(+), 62 deletions(-) create mode 100644 tests/integration/test_npm_launcher.py diff --git a/.github/workflows/post-publish-smoke.yml b/.github/workflows/post-publish-smoke.yml index 23a456d..0b865b5 100644 --- a/.github/workflows/post-publish-smoke.yml +++ b/.github/workflows/post-publish-smoke.yml @@ -165,7 +165,10 @@ jobs: - name: Verify npx + smoke check shell: bash run: | - npx --yes "@reporails/cli@$VERSION" version + out="$(npx --yes "@reporails/cli@$VERSION" version | tr -d '\r')" + echo "$out" + echo "$out" | grep -qxF "CLI: $VERSION" + echo "$out" | grep -qxF "Install: npm" mkdir -p smoke-project cat > smoke-project/CLAUDE.md << 'FIXTURE' # My Project diff --git a/UNRELEASED.md b/UNRELEASED.md index da7d6cb..d44bb16 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -6,15 +6,15 @@ ### Changed -- A global npm install stays on its version until you update it; on an npm or npx install `ails update` prints the command that updates it, and `ails version` shows `npm` or `npx`. +- A global npm install stays on its version until you update it; `ails version` shows `npm` for an install from the npm package (npx included) and `ails update` prints the command that updates it. - A run through the npm package no longer re-checks the package index for a newer release every time it starts. - `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. - The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. ### Fixed -- `npx @reporails/cli install` puts `ails` on your PATH instead of reporting the temporary npx copy as already installed. -- A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` still runs the latest. +- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. +- A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. diff --git a/docs/configuration.md b/docs/configuration.md index 989b69a..5fb228b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,7 +123,7 @@ steps: - run: ails check ``` -Install a pinned version as shown (or `uvx --from "reporails-cli==" ails check` where `uv` is set up). A pinned `npx @reporails/cli@` or `npm install -g @reporails/cli@` runs exactly that version from 0.6.5 on (earlier npm releases ran the latest release whatever version was named), so it can key the cache too; a bare `npx @reporails/cli` runs the latest release. `actions/cache` saves only when the job succeeds: a job that fails on `ails check --strict` findings saves nothing, and the next run downloads the model again. The GitHub Action keeps the model even when the check fails. +Install a pinned version as shown (or `uvx --from "reporails-cli==" ails check` where `uv` is set up). A pinned `npx @reporails/cli@` or `npm install -g @reporails/cli@` runs exactly that version from 0.6.5 on (earlier npm releases ran the latest release whatever version was named), so it can key the cache too; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project, where npx runs that copy. `actions/cache` saves only when the job succeeds: a job that fails on `ails check --strict` findings saves nothing, and the next run downloads the model again. The GitHub Action keeps the model even when the check fails. When `ails` downloads the model inside GitHub Actions, it prints a line pointing to this section. diff --git a/docs/getting-started.md b/docs/getting-started.md index 25b5f6a..0c9a2bc 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -63,7 +63,7 @@ uv tool install reporails-cli npm install -g @reporails/cli ``` -A global npm install stays on the version it installed; `ails update` prints the npm command that moves it to the latest release. +A global npm install stays on the version it installed; `ails update` prints the npm command that moves it to the latest release. A bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project, where npx runs that copy. `ails install` then ensures the binary is on PATH. After it runs, `ails check` works from anywhere without the `npx` / `uvx` prefix. diff --git a/packages/npm/bin/reporails.mjs b/packages/npm/bin/reporails.mjs index 38aa815..34febf2 100755 --- a/packages/npm/bin/reporails.mjs +++ b/packages/npm/bin/reporails.mjs @@ -4,14 +4,12 @@ import { execSync, spawn } from "node:child_process"; import { readFileSync } from "node:fs"; import { platform } from "node:os"; import { argv, env, exit } from "node:process"; -import { fileURLToPath } from "node:url"; const PYPI_PACKAGE = "reporails-cli"; const CLI_COMMAND = "ails"; const VERSION = JSON.parse( readFileSync(new URL("../package.json", import.meta.url), "utf8"), ).version; -const LAUNCHER = /[\\/]_npx[\\/]/.test(fileURLToPath(import.meta.url)) ? "npx" : "npm"; const HELP = ` ails — Validate and score AI instruction files @@ -91,7 +89,7 @@ function proxy(args) { const child = spawn("uvx", ["--from", `${PYPI_PACKAGE}==${VERSION}`, CLI_COMMAND, ...args], { stdio: "inherit", - env: { ...env, AILS_LAUNCHER: LAUNCHER }, + env: { ...env, AILS_LAUNCHER: "npm" }, }); child.on("error", (err) => { diff --git a/src/reporails_cli/core/install/self_update.py b/src/reporails_cli/core/install/self_update.py index 5bad362..42300f2 100644 --- a/src/reporails_cli/core/install/self_update.py +++ b/src/reporails_cli/core/install/self_update.py @@ -7,7 +7,7 @@ from importlib.metadata import PackageNotFoundError, distribution from pathlib import Path -from reporails_cli.core.platform.config.bootstrap import npm_launcher +from reporails_cli.core.platform.config.bootstrap import consume_npm_launch PKG_NAME = "reporails-cli" @@ -19,36 +19,23 @@ class InstallMethod(str, Enum): PIP = "pip" PIPX = "pipx" NPM = "npm" - NPX = "npx" DEV = "dev" UNKNOWN = "unknown" -NPM_LAUNCHES = frozenset({InstallMethod.NPM, InstallMethod.NPX}) - - def detect_install_method() -> InstallMethod: """Detect how reporails-cli was installed: the npm launcher when it names one, else package metadata.""" - try: - launcher = InstallMethod(npm_launcher()) - except ValueError: - return _method_from_metadata() - if launcher in NPM_LAUNCHES: - return launcher - return _method_from_metadata() + return InstallMethod.NPM if consume_npm_launch() else _method_from_metadata() def npm_update_hint(method: InstallMethod) -> str | None: - """The sentence `ails update` prints for an npm or npx install; None for other methods.""" - if method == InstallMethod.NPX: - return ( - "This ails ran through npx. A bare `npx @reporails/cli` runs the latest release on every run; " - "a pinned `npx @reporails/cli@X.Y.Z` runs that version, so raise the version to move it." - ) + """The sentence `ails update` prints for an npm install; None for other methods.""" if method == InstallMethod.NPM: return ( - "This ails runs from the npm package. Update a global install with " - "`npm install -g @reporails/cli@latest`; for a project dependency, raise its version in package.json." + "This ails runs from the npm package. A pinned `npx @reporails/cli@X.Y.Z` runs that version; a bare " + "`npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. " + "Update a global install with `npm install -g @reporails/cli@latest`; for a project dependency, " + "raise its version in package.json." ) return None diff --git a/src/reporails_cli/core/platform/config/bootstrap.py b/src/reporails_cli/core/platform/config/bootstrap.py index 4b2a040..da0c8f5 100644 --- a/src/reporails_cli/core/platform/config/bootstrap.py +++ b/src/reporails_cli/core/platform/config/bootstrap.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from functools import cache from pathlib import Path from reporails_cli.core.platform.contract.errors import ConfigUnreadableError @@ -13,9 +14,10 @@ def env_flag(var_name: str) -> bool: return os.environ.get(var_name, "").strip().lower() not in ("", "0", "false", "no", "off") -def npm_launcher() -> str: - """The launcher named by AILS_LAUNCHER, stripped; blank when unset.""" - return os.environ.get("AILS_LAUNCHER", "").strip() +@cache +def consume_npm_launch() -> bool: + """Whether the npm package started this process; the first call reads and clears the launch variable.""" + return os.environ.pop("AILS_LAUNCHER", "").strip() == "npm" def parse_idle_timeout_env(var_name: str, default_s: int) -> int | None: diff --git a/src/reporails_cli/interfaces/cli/install.py b/src/reporails_cli/interfaces/cli/install.py index e65bc50..bcd5b71 100644 --- a/src/reporails_cli/interfaces/cli/install.py +++ b/src/reporails_cli/interfaces/cli/install.py @@ -8,7 +8,7 @@ import re import shutil import subprocess -from pathlib import Path +from pathlib import Path, PureWindowsPath import typer @@ -65,8 +65,11 @@ def _uv_cache_dir(uv: str) -> str | None: def _is_temporary_ails(ails_path: str, uv: str | None) -> bool: - """True when `ails_path` sits in an npx cache or in uv's cache, i.e. a one-shot copy.""" - if "_npx" in Path(ails_path).parts: + """True for an `ails` inside a `node_modules` folder or in uv's cache. + + That is a one-shot or project copy, not a PATH install. + """ + if "node_modules" in PureWindowsPath(ails_path).parts: return True cache = _uv_cache_dir(uv) if uv else None if cache: @@ -78,8 +81,10 @@ def _install_to_path() -> bool: """Install ails to PATH via uv tool install. Returns True on success.""" uv = shutil.which("uv") # Already on PATH as a real install (not uvx one-shot)? - ails_path = shutil.which("ails") - if ails_path and not _is_temporary_ails(ails_path, uv): + entries = os.environ.get("PATH", "").split(os.pathsep) + hits = (shutil.which("ails", path=entry) for entry in entries if entry) + ails_path = next((hit for hit in hits if hit and not _is_temporary_ails(hit, uv)), None) + if ails_path: console.print(f" [dim]ails already on PATH: {ails_path}[/dim]") return True diff --git a/src/reporails_cli/interfaces/cli/typer_app.py b/src/reporails_cli/interfaces/cli/typer_app.py index 2cba02d..a7aee6d 100644 --- a/src/reporails_cli/interfaces/cli/typer_app.py +++ b/src/reporails_cli/interfaces/cli/typer_app.py @@ -12,6 +12,7 @@ import typer from typer.core import TyperGroup +from reporails_cli.core.platform.config.bootstrap import consume_npm_launch from reporails_cli.core.platform.contract.errors import ConfigUnreadableError from reporails_cli.core.platform.runtime import _torch_blocker @@ -22,6 +23,7 @@ class _AilsGroup(TyperGroup): """Root command group: an unreadable config file ends any command with one error line and exit code 2.""" def invoke(self, ctx: click.Context) -> object: + consume_npm_launch() try: return super().invoke(ctx) except ConfigUnreadableError as exc: diff --git a/tests/conftest.py b/tests/conftest.py index 17abe35..2d627ed 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,6 +22,17 @@ os.environ.update(_SUITE_ENV) +@pytest.fixture(autouse=True) +def _fresh_npm_launch(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]: + """No test inherits the npm launch variable or a cached read of it.""" + from reporails_cli.core.platform.config import bootstrap + + monkeypatch.delenv("AILS_LAUNCHER", raising=False) + bootstrap.consume_npm_launch.cache_clear() + yield + bootstrap.consume_npm_launch.cache_clear() + + @pytest.fixture(autouse=True) def _fresh_mcp_snapshots(monkeypatch: pytest.MonkeyPatch) -> None: """Each test starts with no briefed-file baselines held by the MCP server.""" diff --git a/tests/integration/test_npm_launcher.py b/tests/integration/test_npm_launcher.py new file mode 100644 index 0000000..c7061a7 --- /dev/null +++ b/tests/integration/test_npm_launcher.py @@ -0,0 +1,71 @@ +"""The npm launcher starts `uvx` pinned to its own version and marks the run as an npm launch.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +pytestmark = [ + pytest.mark.integration, + pytest.mark.subsys_cli_ux, + pytest.mark.skipif( + os.name == "nt", + reason=( + "the launcher spawns uvx without a shell; Windows is covered end to end by the post-publish smoke-npx job" + ), + ), +] + +NPM_DIR = Path(__file__).resolve().parents[2] / "packages" / "npm" +LAUNCHER = NPM_DIR / "bin" / "reporails.mjs" +STUB = ( + "import json, os, sys\n" + "with open(os.environ['STUB_OUT'], 'w', encoding='utf-8') as fh:\n" + " json.dump({'argv': sys.argv[1:], 'launcher': os.environ.get('AILS_LAUNCHER')}, fh)\n" + "sys.exit(int(os.environ.get('STUB_EXIT', '0')))\n" +) + + +def _run_launcher(tmp_path: Path, exit_code: int) -> tuple[int, dict[str, object]]: + node = shutil.which("node") + if node is None: + pytest.skip("node is not installed; the npm launcher cannot run") + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stub = bin_dir / "uvx" + stub.write_text(f"#!{sys.executable}\n{STUB}", encoding="utf-8") + stub.chmod(0o755) + noop_uv = bin_dir / "uv" + noop_uv.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + noop_uv.chmod(0o755) + out = tmp_path / "out.json" + env = { + **os.environ, + "PATH": os.pathsep.join([str(bin_dir), os.environ.get("PATH", "")]), + "STUB_OUT": str(out), + "STUB_EXIT": str(exit_code), + } + env.pop("AILS_LAUNCHER", None) + result = subprocess.run([node, str(LAUNCHER), "version"], env=env, capture_output=True, text=True, timeout=60) + if not out.exists(): + pytest.fail(f"the uvx stub never ran\nstdout: {result.stdout}\nstderr: {result.stderr}") + return result.returncode, json.loads(out.read_text(encoding="utf-8")) + + +def test_launcher_pins_its_version_and_marks_npm(tmp_path: Path) -> None: + version = json.loads((NPM_DIR / "package.json").read_text(encoding="utf-8"))["version"] + code, seen = _run_launcher(tmp_path, 0) + assert seen["argv"] == ["--from", f"reporails-cli=={version}", "ails", "version"] + assert seen["launcher"] == "npm" + assert code == 0 + + +def test_launcher_exits_with_the_engine_exit_code(tmp_path: Path) -> None: + code, _ = _run_launcher(tmp_path, 3) + assert code == 3 diff --git a/tests/unit/architecture/test_one_owner_per_vocabulary.py b/tests/unit/architecture/test_one_owner_per_vocabulary.py index 321ecc2..a478f93 100644 --- a/tests/unit/architecture/test_one_owner_per_vocabulary.py +++ b/tests/unit/architecture/test_one_owner_per_vocabulary.py @@ -433,10 +433,11 @@ def test_a1_dict_keyed_by_vocabulary_values_fires(tmp_path: Path) -> None: "vocab.py": _VOCAB, "install.py": _ENUM + '_LAUNCHERS = {"npm": InstallMethod.NPM, "npx": InstallMethod.NPX}\n', "red.py": 'from install import InstallMethod\nT = {"npm": 1, "uv": 2}\n', + "wrapped.py": 'WRAPPED = frozenset({"prose": 1, "list": 2})\n', }, ) found = private_group_violations(sources, tmp_path) - assert {v.key for v in found} == {"A1 red.py:2", "A1 install.py:6"} + assert {v.key for v in found} == {"A1 red.py:2", "A1 install.py:6", "A1 wrapped.py:1"} @pytest.mark.architecture diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index f6fb223..e63888c 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -47,7 +47,7 @@ def test_install_to_path_pins_a_floor_not_an_exact_version(monkeypatch: pytest.M `uv tool upgrade` keeps an `==` pin forever, so only a floor lets a later `ails update` / `uv tool upgrade` move the engine past this version. """ - monkeypatch.setattr(install_module.shutil, "which", lambda cmd: None if cmd == "ails" else "/usr/bin/uv") + monkeypatch.setattr(install_module.shutil, "which", lambda cmd, path=None: None if cmd == "ails" else "/usr/bin/uv") calls: list[list[str]] = [] @@ -723,8 +723,14 @@ def _path_install( monkeypatch: pytest.MonkeyPatch, ails: str, uv: str | None, cache_dir: str | None ) -> tuple[bool, list[list[str]]]: """Run `_install_to_path` with `ails` on PATH, uv reporting `cache_dir` (None: the command fails).""" - found = {"ails": ails, "uv": uv} - monkeypatch.setattr(install_module.shutil, "which", lambda cmd: found.get(cmd)) + monkeypatch.setenv("PATH", os.path.dirname(ails)) + + def which(cmd: str, mode: int = os.F_OK | os.X_OK, path: str | None = None) -> str | None: + if cmd == "uv": + return uv + return ails if path == os.path.dirname(ails) else None + + monkeypatch.setattr(install_module.shutil, "which", which) calls: list[list[str]] = [] def fake_run(cmd: list[str], **kwargs: Any) -> MagicMock: diff --git a/tests/unit/test_npm_install_method.py b/tests/unit/test_npm_install_method.py index 3d3a0b8..35a0c57 100644 --- a/tests/unit/test_npm_install_method.py +++ b/tests/unit/test_npm_install_method.py @@ -2,54 +2,120 @@ from __future__ import annotations +import os +import shutil import subprocess +from pathlib import Path import pytest from reporails_cli.core.install.self_update import InstallMethod, detect_install_method +from reporails_cli.core.platform.config import bootstrap from reporails_cli.interfaces.cli import commands as commands_module +from reporails_cli.interfaces.cli import install as install_module from reporails_cli.interfaces.cli.install import _is_temporary_ails pytestmark = [pytest.mark.unit, pytest.mark.subsys_cli_ux] -@pytest.mark.parametrize( - ("launcher", "expected"), - [("npm", InstallMethod.NPM), ("npx", InstallMethod.NPX)], -) -def test_launcher_selects_install_method(monkeypatch, launcher, expected): - monkeypatch.setenv("AILS_LAUNCHER", launcher) - assert detect_install_method() == expected +def test_npm_launch_selects_npm(monkeypatch): + monkeypatch.setenv("AILS_LAUNCHER", "npm") + assert detect_install_method() == InstallMethod.NPM -@pytest.mark.parametrize("launcher", ["", "pip", "NPM", "yarn", "uv"]) +@pytest.mark.parametrize("launcher", ["", "npx", "pip", "NPM", "yarn"]) def test_other_launcher_values_fall_through(monkeypatch, launcher): monkeypatch.setenv("AILS_LAUNCHER", launcher) - assert detect_install_method() not in (InstallMethod.NPM, InstallMethod.NPX) + assert detect_install_method() != InstallMethod.NPM -@pytest.mark.parametrize( - ("launcher", "expected_text"), - [ - ("npm", "npm install -g @reporails/cli@latest"), - ("npx", "A bare `npx @reporails/cli` runs the latest release on every run"), - ], -) -def test_update_prints_hint_and_runs_nothing(monkeypatch, capsys, launcher, expected_text): - monkeypatch.setenv("AILS_LAUNCHER", launcher) +def test_launcher_variable_is_consumed_once(monkeypatch): + monkeypatch.setenv("AILS_LAUNCHER", "npm") + assert bootstrap.consume_npm_launch() is True + assert "AILS_LAUNCHER" not in os.environ + assert bootstrap.consume_npm_launch() is True + + +def _stub(folder: Path) -> Path: + folder.mkdir(parents=True) + exe = folder / "ails" + exe.write_text("#!/bin/sh\n", encoding="utf-8") + exe.chmod(0o755) + return exe + + +_REAL_WHICH = shutil.which + + +@pytest.fixture +def two_folder_path(tmp_path, monkeypatch): + """A PATH of two folders; `uv` resolves to a fake and runs nothing.""" + first, second = tmp_path / "proj" / "node_modules" / ".bin", tmp_path / "real" / "bin" + first.mkdir(parents=True) + second.mkdir(parents=True) + monkeypatch.setenv("PATH", os.pathsep.join([str(first), str(second)])) + + def which(cmd, mode=os.F_OK | os.X_OK, path=None): + return "/fake/uv" if cmd == "uv" and path is None else _REAL_WHICH(cmd, mode=mode, path=path) + + monkeypatch.setattr(install_module.shutil, "which", which) + monkeypatch.setattr(install_module, "_uv_cache_dir", lambda uv: None) + return first, second + + +def _stub(folder: Path) -> Path: + exe = folder / "ails" + exe.write_text("#!/bin/sh\n", encoding="utf-8") + exe.chmod(0o755) + return exe + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +def test_install_to_path_installs_when_only_a_node_modules_copy(two_folder_path, monkeypatch): + _stub(two_folder_path[0]) + ran: list[tuple[object, ...]] = [] + monkeypatch.setattr( + install_module, "run_uv_tool", lambda *a, **k: ran.append(a) or subprocess.CompletedProcess(a, 0, "", "") + ) + assert install_module._install_to_path() is True + assert len(ran) == 1 + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +def test_install_to_path_sees_a_real_copy_behind_a_node_modules_one(two_folder_path, monkeypatch, capsys): + first, second = two_folder_path + _stub(first) + real = _stub(second) + ran: list[tuple[object, ...]] = [] + monkeypatch.setattr(install_module, "run_uv_tool", lambda *a, **k: ran.append(a)) + assert install_module._install_to_path() is True + assert str(real) in "".join(capsys.readouterr().out.split()) + assert ran == [] + + +def test_update_prints_hint_and_runs_nothing(monkeypatch, capsys): + monkeypatch.setenv("AILS_LAUNCHER", "npm") calls: list[object] = [] monkeypatch.setattr(subprocess, "run", lambda *a, **k: calls.append(a)) assert commands_module._update_engine() is True - assert expected_text in " ".join(capsys.readouterr().out.split()) + out = " ".join(capsys.readouterr().out.split()) + assert "npm install -g @reporails/cli@latest" in out + assert "unless a copy is installed" in out assert calls == [] @pytest.mark.parametrize( ("path", "expected"), [ - ("/home/u/.npm/_npx/ab12cd/node_modules/.bin/ails", True), + ("/home/u/.npm/_npx/ab/node_modules/.bin/ails", True), + ("/home/u/proj/node_modules/.bin/ails", True), + ("/home/u/.cache/pnpm/dlx/ab/node_modules/.bin/ails", True), + ("/tmp/bunx-1000-@reporails/cli@0.6.5/node_modules/.bin/ails", True), + ("C:\\Users\\u\\AppData\\Local\\npm-cache\\_npx\\ab\\node_modules\\.bin\\ails.cmd", True), ("/usr/local/bin/ails", False), + ("/home/linuxbrew/.linuxbrew/bin/ails", False), + ("C:\\Users\\u\\AppData\\Roaming\\npm\\ails.cmd", False), ], ) -def test_npx_copy_is_temporary(path, expected): +def test_node_modules_copy_is_temporary(path, expected): assert _is_temporary_ails(path, None) is expected From 587614b8a1e70525d4c2a42236fa1e1f29a12ff9 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 11:07:21 +0200 Subject: [PATCH 11/31] expected-loads: the x-ray's event logs are private to you, removed after 30 days (sooner under Claude Code's cleanupPeriodDays) and kept under 100 MB --- UNRELEASED.md | 1 + .../core/cache/full_map_cache.py | 16 +-- src/reporails_cli/core/cache/sweep.py | 63 +++++++++ .../core/pipeline/expected_loads.py | 35 +++++ .../core/platform/config/claude_settings.py | 22 +++ .../test_expected_loads_managed_only.py | 29 ++++ tests/unit/test_claude_cleanup_period.py | 88 ++++++++++++ tests/unit/test_expected_loads.py | 128 ++++++++++++++++++ tests/unit/test_sweep.py | 108 +++++++++++++++ 9 files changed, 477 insertions(+), 13 deletions(-) create mode 100644 src/reporails_cli/core/cache/sweep.py create mode 100644 tests/unit/test_claude_cleanup_period.py create mode 100644 tests/unit/test_sweep.py diff --git a/UNRELEASED.md b/UNRELEASED.md index d44bb16..764ee44 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -8,6 +8,7 @@ - A global npm install stays on its version until you update it; `ails version` shows `npm` for an install from the npm package (npx included) and `ails update` prints the command that updates it. - A run through the npm package no longer re-checks the package index for a newer release every time it starts. +- The context x-ray's event logs in `~/.reporails/xray/` are private to you, removed after 30 days (sooner if Claude Code is set to keep sessions for less), and the folder stays under 100 MB. - `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. - The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. diff --git a/src/reporails_cli/core/cache/full_map_cache.py b/src/reporails_cli/core/cache/full_map_cache.py index 5ec49f4..0a01af8 100644 --- a/src/reporails_cli/core/cache/full_map_cache.py +++ b/src/reporails_cli/core/cache/full_map_cache.py @@ -24,6 +24,7 @@ from pathlib import Path from reporails_cli.core.cache.map_cache import _CACHE_VERSION, content_hash +from reporails_cli.core.cache.sweep import sweep from reporails_cli.core.mapper.bio_graphs import multislot_fingerprint from reporails_cli.core.mapper.imports import expand_imports from reporails_cli.core.mapper.serialize import load_ruleset_map, save_ruleset_map @@ -149,16 +150,5 @@ def put(self, key: str, ruleset_map: RulesetMap) -> None: self._evict() def _evict(self) -> None: - """Delete least-recently-used entries beyond the cap. Parallel writers sweep the same - directory, so an entry listed here can be gone before it is stat'd — it is skipped, - never raised into the caller's map.""" - dated: list[tuple[float, Path]] = [] - for entry in self.dir.glob("*.json"): - try: - dated.append((entry.stat().st_mtime, entry)) - except OSError: - continue - dated.sort(key=lambda pair: pair[0]) - for _mtime, stale in dated[:-_MAX_ENTRIES]: - with contextlib.suppress(OSError): - stale.unlink(missing_ok=True) + """Delete least-recently-used entries beyond the cap.""" + sweep(self.dir.glob("*.json"), max_units=_MAX_ENTRIES) diff --git a/src/reporails_cli/core/cache/sweep.py b/src/reporails_cli/core/cache/sweep.py new file mode 100644 index 0000000..1fe545c --- /dev/null +++ b/src/reporails_cli/core/cache/sweep.py @@ -0,0 +1,63 @@ +"""One sweep for cache-like folders: drop units by age, by total size, or by count, oldest first. + +A unit is the files sharing a ``unit_of`` key (each file alone by default); its age is its +newest file's mtime and it is deleted whole. Several processes may sweep one folder, so a file +that vanishes or cannot be removed is skipped and never raised to the caller. +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path + + +@dataclass +class _Unit: + mtime: float = 0.0 + size: int = 0 + files: list[Path] = field(default_factory=list) + + +def sweep( + files: Iterable[Path], + *, + unit_of: Callable[[Path], str] | None = None, + now: float = 0.0, + max_age_s: float | None = None, + max_bytes: int | None = None, + max_units: int | None = None, +) -> None: + """Delete whole units older than `max_age_s` seconds before `now`; then, oldest first, until the + rest hold at most `max_bytes` and at most `max_units` units.""" + units: dict[str, _Unit] = {} + for path in files: + try: + info = path.stat() + except OSError: + continue + unit = units.setdefault(unit_of(path) if unit_of else str(path), _Unit()) + unit.mtime = max(unit.mtime, info.st_mtime) + unit.size += info.st_size + unit.files.append(path) + ordered = sorted(units.values(), key=lambda u: u.mtime) + if max_age_s is not None: + for unit in [u for u in ordered if now - u.mtime > max_age_s]: + _delete(unit) + ordered.remove(unit) + if max_bytes is not None: + total = sum(u.size for u in ordered) + while ordered and total > max_bytes: + unit = ordered.pop(0) + total -= unit.size + _delete(unit) + if max_units is not None: + while len(ordered) > max_units: + _delete(ordered.pop(0)) + + +def _delete(unit: _Unit) -> None: + for path in unit.files: + with contextlib.suppress(OSError): + path.unlink(missing_ok=True) diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 251ece2..98c19b4 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -2,8 +2,12 @@ from __future__ import annotations +import contextlib +import re +import time from pathlib import Path +from reporails_cli.core.cache.sweep import sweep from reporails_cli.core.discovery.agent_discovery import discover_from_config, discover_ungated from reporails_cli.core.discovery.exclusions import RunExclusions, load_run_exclusions from reporails_cli.core.discovery.project_root import resolve_project_root @@ -14,6 +18,7 @@ from reporails_cli.core.platform.config.claude_settings import ( LAUNCH_INSTRUCTION_FILE_TYPES, MODES_WITHOUT_LAUNCH_INSTRUCTIONS, + claude_cleanup_period_days, claude_instruction_files_mode, ) from reporails_cli.core.platform.config.config import get_project_config @@ -23,11 +28,41 @@ AGENT = "claude" +_LOG_RETENTION_DAYS = 30 +_LOG_FOLDER_MAX_BYTES = 100 * 1024 * 1024 +# `...jsonl` +_LOG_NAME = re.compile(r"^[^.]+\.\d+\.\d+\.jsonl$") + + +def _prune_event_logs(root: Path) -> None: + """Keep the x-ray's event-log folder private and small: mode 0700 folder, 0600 logs, whole + sessions removed after 30 days (sooner under Claude Code's `cleanupPeriodDays`), then oldest + sessions until the folder is under 100 MB. Best-effort: a folder or file that cannot be + read or changed is left alone and never changes the answer.""" + # Best-effort housekeeping: an OSError here must not fail the command. + with contextlib.suppress(OSError): + folder = get_xray_log_dir() + folder.mkdir(mode=0o700, parents=True, exist_ok=True) + folder.chmod(0o700) + logs = [p for p in folder.iterdir() if _LOG_NAME.match(p.name)] + for log in logs: + with contextlib.suppress(OSError): + log.chmod(0o600) + days = min(_LOG_RETENTION_DAYS, claude_cleanup_period_days(root) or _LOG_RETENTION_DAYS) + sweep( + logs, + unit_of=lambda p: p.name.split(".", 1)[0], + now=time.time(), + max_age_s=days * 86400, + max_bytes=_LOG_FOLDER_MAX_BYTES, + ) + def expected_loads(target: Path) -> JsonObject: """The files Claude Code loads for a session in `target` (instruction and rule files, no config surfaces) with their file type, loading mode and precedence, and the AGENTS.md files it skips.""" root = resolve_project_root(target) + _prune_event_logs(root) exclusions = load_run_exclusions(root, None, None) project_config = get_project_config(root) registry = _load_registry() diff --git a/src/reporails_cli/core/platform/config/claude_settings.py b/src/reporails_cli/core/platform/config/claude_settings.py index 943edb4..e8eb6e1 100644 --- a/src/reporails_cli/core/platform/config/claude_settings.py +++ b/src/reporails_cli/core/platform/config/claude_settings.py @@ -158,3 +158,25 @@ def claude_instruction_files_mode(project_root: Path | None = None) -> str: if value is not None: return value if value in _MODES else MODE_CLAUDE_MD_OR_AGENTS_MD return MODE_CLAUDE_MD_OR_AGENTS_MD + + +def _cleanup_days_value(data: JsonObject) -> int | None: + value = data.get("cleanupPeriodDays") + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + return None + return value + + +def claude_cleanup_period_days(project_root: Path | None = None) -> int | None: + """Days Claude Code keeps a session, from its `cleanupPeriodDays` setting, or None when no + settings file sets a positive whole number. Files are read in Claude Code's precedence: the + user's, then the project's shared and local ones, then managed settings, the last to set it + winning. It is Claude Code's setting, so an invalid value reads as unset.""" + scopes = _settings_patterns() + files = ( + _expand(scopes.get("user", []), None) + + _expand(scopes.get("project", []), project_root) + + _expand(scopes.get("local", []), project_root) + + _managed_files(scopes) + ) + return _last_set(files, _cleanup_days_value) diff --git a/tests/integration/test_expected_loads_managed_only.py b/tests/integration/test_expected_loads_managed_only.py index 99017d5..51d9946 100644 --- a/tests/integration/test_expected_loads_managed_only.py +++ b/tests/integration/test_expected_loads_managed_only.py @@ -5,7 +5,9 @@ import json import os import shutil +import stat import subprocess +import time from pathlib import Path import pytest @@ -72,3 +74,30 @@ def test_managed_only_leaves_out_launch_files_and_keeps_on_demand_ones(tmp_path: } assert home / ".claude" / "CLAUDE.md" not in skipped assert home / ".claude" / "CLAUDE.md" not in loaded + + +@pytest.mark.integration +@pytest.mark.subsys_classify +def test_expected_loads_prunes_the_event_logs_it_names(tmp_path: Path) -> None: + for folder in MANAGED_FOLDERS: + if Path(folder).exists(): + pytest.skip(f"managed Claude Code files at {folder} would be listed beside the isolated HOME's") + home = tmp_path / "home" + project = tmp_path / "proj" + _write(project / "CLAUDE.md") + xray = home / ".reporails" / "xray" + stale = xray / "old.1700000000000.0.jsonl" + fresh = xray / "new.1700000000000.0.jsonl" + _write(stale, "{}") + _write(fresh, "{}") + long_ago = time.time() - 40 * 86400 + os.utime(stale, (long_ago, long_ago)) + fresh.chmod(0o644) + + out = _expected_loads(project, home) + + assert out["event_log_dir"] == str(xray) + assert [Path(i["path"]).name for i in out["files"]] == ["CLAUDE.md"] + assert sorted(p.name for p in xray.iterdir()) == [fresh.name] + assert stat.S_IMODE(fresh.stat().st_mode) == 0o600 + assert stat.S_IMODE(xray.stat().st_mode) == 0o700 diff --git a/tests/unit/test_claude_cleanup_period.py b/tests/unit/test_claude_cleanup_period.py new file mode 100644 index 0000000..b157c1f --- /dev/null +++ b/tests/unit/test_claude_cleanup_period.py @@ -0,0 +1,88 @@ +"""Claude Code's `cleanupPeriodDays` setting, read from the settings files the module already reads.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from reporails_cli.core.platform.config import claude_settings +from reporails_cli.core.platform.config.claude_settings import claude_cleanup_period_days + + +@pytest.fixture(autouse=True) +def _no_managed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + real = claude_settings._settings_patterns + + def patterns() -> dict[str, list[str]]: + scopes = real() + scopes["managed"] = [str(tmp_path / "managed" / "managed-settings.json")] + scopes["managed_dropin"] = [str(tmp_path / "managed" / "d" / "*.json")] + return scopes + + monkeypatch.setattr(claude_settings, "_settings_patterns", patterns) + + +def _set(path: Path, value: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps({"cleanupPeriodDays": value}), encoding="utf-8") + + +def _user() -> Path: + return Path.home() / ".claude" / "settings.json" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_unset_reads_as_none(tmp_path: Path) -> None: + assert claude_cleanup_period_days(tmp_path / "proj") is None + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_set_reads_the_days(tmp_path: Path) -> None: + _set(_user(), 7) + assert claude_cleanup_period_days(tmp_path / "proj") == 7 + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize("bad", [0, -3, True, False, 2.5, "7", None, [7]]) +def test_invalid_value_reads_as_none(tmp_path: Path, bad: object) -> None: + _set(_user(), bad) + assert claude_cleanup_period_days(tmp_path / "proj") is None + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_invalid_project_value_does_not_hide_the_user_one(tmp_path: Path) -> None: + root = tmp_path / "proj" + _set(_user(), 9) + _set(root / ".claude" / "settings.json", 0) + assert claude_cleanup_period_days(root) == 9 + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize( + ("scopes", "expected"), + [ + (("user", "project"), 20), + (("user", "project", "local"), 30), + (("user", "project", "local", "managed"), 40), + (("user", "managed"), 40), + ], +) +def test_precedence_later_scope_wins(tmp_path: Path, scopes: tuple[str, ...], expected: int) -> None: + root = tmp_path / "proj" + paths = { + "user": (_user(), 10), + "project": (root / ".claude" / "settings.json", 20), + "local": (root / ".claude" / "settings.local.json", 30), + "managed": (tmp_path / "managed" / "managed-settings.json", 40), + } + for scope in scopes: + path, days = paths[scope] + _set(path, days) + assert claude_cleanup_period_days(root) == expected diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 6e1b610..360cd7f 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -3,6 +3,9 @@ from __future__ import annotations import json +import os +import stat +import time from pathlib import Path import pytest @@ -419,3 +422,128 @@ def test_skip_without_a_claude_file_has_no_cause(tmp_path: Path) -> None: _write(root / ".agents" / "AGENTS.md") _mode("claude-md") assert set(_causes(expected_loads(root), root).values()) == {None} + + +# --- event-log folder: private and pruned --------------------------------------------------- + +DAY = 86400.0 + + +def _log(folder: Path, session: str, part: int, age_days: float, size: int = 10) -> Path: + path = folder / f"{session}.1700000000000.{part}.jsonl" + folder.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"x" * size) + stamp = time.time() - age_days * DAY + os.utime(path, (stamp, stamp)) + return path + + +def _xray() -> Path: + return Path.home() / ".reporails" / "xray" + + +def _answer(root: Path) -> dict: + out = expected_loads(root) + return {k: v for k, v in out.items() if k != "event_log_dir"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_sessions_older_than_thirty_days_are_removed_whole(tmp_path: Path) -> None: + folder = _xray() + _log(folder, "stale", 0, 45) + _log(folder, "stale", 1, 5) # the newest file keeps the session + _log(folder, "dead", 0, 31) + _log(folder, "dead", 1, 40) + _log(folder, "fresh", 0, 29) + expected_loads(_project(tmp_path)) + assert sorted(p.name.split(".")[0] for p in folder.iterdir()) == ["fresh", "stale", "stale"] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_cleanup_period_days_shortens_the_retention(tmp_path: Path) -> None: + _write(Path.home() / ".claude" / "settings.json", json.dumps({"cleanupPeriodDays": 3})) + folder = _xray() + _log(folder, "old", 0, 4) + _log(folder, "new", 0, 2) + expected_loads(_project(tmp_path)) + assert [p.name.split(".")[0] for p in folder.iterdir()] == ["new"] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_cleanup_period_days_never_lengthens_the_retention(tmp_path: Path) -> None: + _write(Path.home() / ".claude" / "settings.json", json.dumps({"cleanupPeriodDays": 90})) + folder = _xray() + _log(folder, "old", 0, 40) + expected_loads(_project(tmp_path)) + assert list(folder.iterdir()) == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_oversize_folder_loses_oldest_sessions_first(tmp_path: Path) -> None: + mb = 1024 * 1024 + folder = _xray() + _log(folder, "a", 0, 20, 40 * mb) + _log(folder, "a", 1, 19, 10 * mb) + _log(folder, "b", 0, 10, 40 * mb) + _log(folder, "c", 0, 1, 40 * mb) # 130 MB in all + expected_loads(_project(tmp_path)) + assert sorted(p.name.split(".")[0] for p in folder.iterdir()) == ["b", "c"] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_non_log_files_are_left_alone_and_logs_become_private(tmp_path: Path) -> None: + folder = _xray() + other = _write(folder / "notes.txt", "mine") + oddly = _write(folder / "plain.jsonl", "{}") # not the log name pattern + for p in (other, oddly): + os.utime(p, (1, 1)) + log = _log(folder, "s", 0, 1) + log.chmod(0o644) + folder.chmod(0o755) + expected_loads(_project(tmp_path)) + assert other.read_text() == "mine" + assert oddly.exists() + assert stat.S_IMODE(log.stat().st_mode) == 0o600 + assert stat.S_IMODE(folder.stat().st_mode) == 0o700 + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_missing_folder_is_created_private(tmp_path: Path) -> None: + expected_loads(_project(tmp_path)) + assert stat.S_IMODE(_xray().stat().st_mode) == 0o700 + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_unusable_log_folder_leaves_the_answer_unchanged(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + before = _answer(root) + _xray().rmdir() + _write(_xray(), "a file where the folder should be") + assert _answer(root) == before + assert _xray().read_text() == "a file where the folder should be" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_unreadable_entry_leaves_the_answer_unchanged(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + before = _answer(root) + log = _log(_xray(), "s", 0, 1) + real = Path.chmod + + def refuse(self: Path, *args: object, **kwargs: object) -> None: + if self == log: + raise PermissionError(self) + real(self, *args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(Path, "chmod", refuse) + assert _answer(root) == before diff --git a/tests/unit/test_sweep.py b/tests/unit/test_sweep.py new file mode 100644 index 0000000..a8760bf --- /dev/null +++ b/tests/unit/test_sweep.py @@ -0,0 +1,108 @@ +"""The shared sweep: units dropped by age, total size or count, oldest first.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from reporails_cli.core.cache import sweep as sweep_module +from reporails_cli.core.cache.sweep import sweep + +NOW = 1_000_000.0 +DAY = 86400.0 + + +def _file(folder: Path, name: str, age_s: float, size: int = 1) -> Path: + path = folder / name + path.write_bytes(b"x" * size) + os.utime(path, (NOW - age_s, NOW - age_s)) + return path + + +def _names(folder: Path) -> set[str]: + return {p.name for p in folder.iterdir()} + + +@pytest.mark.unit +@pytest.mark.subsys_caching +@pytest.mark.parametrize( + ("age", "kept"), + [(10 * DAY - 1, True), (10 * DAY, True), (10 * DAY + 1, False)], + ids=["younger", "exactly-at-limit", "older"], +) +def test_age_limit_boundary(tmp_path: Path, age: float, kept: bool) -> None: + _file(tmp_path, "a", age) + sweep(tmp_path.iterdir(), now=NOW, max_age_s=10 * DAY) + assert (_names(tmp_path) == {"a"}) is kept + + +@pytest.mark.unit +@pytest.mark.subsys_caching +@pytest.mark.parametrize( + ("limit", "left"), + [(50, {"a", "b", "c"}), (49, {"b", "c"}), (30, {"b", "c"}), (29, {"c"}), (9, set())], +) +def test_size_boundary(tmp_path: Path, limit: int, left: set[str]) -> None: + _file(tmp_path, "a", 300, 20) + _file(tmp_path, "b", 200, 20) + _file(tmp_path, "c", 100, 10) + sweep(tmp_path.iterdir(), max_bytes=limit) + assert _names(tmp_path) == left + + +@pytest.mark.unit +@pytest.mark.subsys_caching +def test_count_limit_keeps_newest(tmp_path: Path) -> None: + for i, name in enumerate("abcd"): + _file(tmp_path, name, 400 - i * 100) + sweep(tmp_path.iterdir(), max_units=2) + assert _names(tmp_path) == {"c", "d"} + + +@pytest.mark.unit +@pytest.mark.subsys_caching +@pytest.mark.parametrize( + ("newest_age", "left"), + [(5 * DAY, {"s1.old", "s1.new", "s2.x"}), (11 * DAY, {"s2.x"})], + ids=["newest-file-keeps-the-unit", "whole-unit-aged-out"], +) +def test_unit_is_deleted_whole_by_newest_file(tmp_path: Path, newest_age: float, left: set[str]) -> None: + _file(tmp_path, "s1.old", 50 * DAY) + _file(tmp_path, "s1.new", newest_age) + _file(tmp_path, "s2.x", 1 * DAY) + sweep(tmp_path.iterdir(), unit_of=lambda p: p.name.split(".")[0], now=NOW, max_age_s=10 * DAY) + assert _names(tmp_path) == left + + +@pytest.mark.unit +@pytest.mark.subsys_caching +def test_unit_size_counts_every_file(tmp_path: Path) -> None: + _file(tmp_path, "s1.a", 300, 10) + _file(tmp_path, "s1.b", 290, 10) + _file(tmp_path, "s2.a", 100, 10) + sweep(tmp_path.iterdir(), unit_of=lambda p: p.name.split(".")[0], max_bytes=15) + assert _names(tmp_path) == {"s2.a"} + + +@pytest.mark.unit +@pytest.mark.subsys_caching +@pytest.mark.parametrize("vanishes", ["at-stat", "at-unlink"]) +def test_file_vanishing_mid_sweep_is_skipped(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, vanishes: str) -> None: + gone = _file(tmp_path, "gone", 300) + _file(tmp_path, "old", 200) + _file(tmp_path, "new", 100) + listing = list(tmp_path.iterdir()) + if vanishes == "at-stat": + gone.unlink() + else: + real = sweep_module._delete + + def racing(unit: sweep_module._Unit) -> None: + gone.unlink(missing_ok=True) # another sweeper got there first + real(unit) + + monkeypatch.setattr(sweep_module, "_delete", racing) + sweep(listing, max_units=1) + assert _names(tmp_path) == {"new"} From e002de8eece0dee9c0525ab20a8216e5848b6b73 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 11:19:28 +0200 Subject: [PATCH 12/31] expected-loads: an AGENTS.md that is a copy of a CLAUDE.md Claude Code reads is reported as that file, whatever the Project instructions setting, and every alias says whether it is a link or a copy --- UNRELEASED.md | 2 +- .../core/discovery/file_aliases.py | 14 ++++ .../core/discovery/read_gates.py | 27 +++++-- .../core/pipeline/expected_loads.py | 3 +- tests/unit/test_expected_loads.py | 72 +++++++++++++++++-- 5 files changed, 103 insertions(+), 15 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 764ee44..a0fcb70 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md, or is a copy with exactly the same text, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed diff --git a/src/reporails_cli/core/discovery/file_aliases.py b/src/reporails_cli/core/discovery/file_aliases.py index c124df1..5eb552c 100644 --- a/src/reporails_cli/core/discovery/file_aliases.py +++ b/src/reporails_cli/core/discovery/file_aliases.py @@ -11,12 +11,26 @@ import logging from collections.abc import Iterable from pathlib import Path +from typing import Literal from reporails_cli.core.cache.hashing import file_bytes_digest from reporails_cli.core.discovery.walk import has_symlink_loop, safe_resolve logger = logging.getLogger(__name__) +# How a file's text reaches the agent through another file: the same file under another +# name (`link`) or a separate file with exactly the same text (`copy`). +AliasHow = Literal["link", "copy"] + + +def has_same_text(first: Path, second: Path) -> bool: + """True when both files can be read and hold exactly the same bytes.""" + try: + return first.read_bytes() == second.read_bytes() + except OSError: + return False + + # Cache keyed on project root; cleared alongside the agent cache on --refresh. _alias_cache: dict[str, dict[Path, list[Path]]] = {} diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index e626d39..514c9dd 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -21,6 +21,7 @@ from pathlib import Path from typing import Literal +from reporails_cli.core.discovery.file_aliases import AliasHow, has_same_text from reporails_cli.core.discovery.project_root import resolve_project_root from reporails_cli.core.discovery.walk import list_dir, safe_resolve from reporails_cli.core.platform.config.claude_settings import ( @@ -99,10 +100,12 @@ class SkippedFile: @dataclass(frozen=True) class AliasFile: - """An `AGENTS.md` that links to a `CLAUDE.md` Claude Code reads: the same file under another name.""" + """An `AGENTS.md` whose text reaches Claude through a `CLAUDE.md` it reads: `link` when it is the + same file under another name, `copy` when it is a separate file with exactly the same text.""" path: Path same_as: Path + how: AliasHow @dataclass(frozen=True) @@ -119,8 +122,8 @@ class AgentsMdReads: def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] = ()) -> AgentsMdReads: """Split the `AGENTS.md` files among `found` into those Claude Code loads for a session in `target` and those it skips, each skipped file with its reason. A skipped file that is the same - file as a `CLAUDE.md`-family file among `read` (the instruction files the session reads) is - reported as an alias of it instead.""" + file as, or holds exactly the text of, a `CLAUDE.md`-family file among `read` (the instruction + files the session reads) is reported as an alias of it instead.""" if not found: return AgentsMdReads([], [], "", []) root = resolve_project_root(target) @@ -137,13 +140,23 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] kept.append(path) continue same_as = claude_files.get(safe_resolve(path)) - if same_as is None: - skipped.append(SkippedFile(path, *skip)) - else: - aliases.append(AliasFile(path, same_as)) + if same_as is not None: + aliases.append(AliasFile(path, same_as, "link")) + continue + twin = _file_with_same_text(path, claude_files.values(), prefer=skip[1]) + if twin is not None: + aliases.append(AliasFile(path, twin, "copy")) + continue + skipped.append(SkippedFile(path, *skip)) return AgentsMdReads(kept, skipped, mode, aliases) +def _file_with_same_text(path: Path, claude_files: Iterable[Path], *, prefer: Path | None) -> Path | None: + """The `CLAUDE.md`-family file Claude Code reads that holds exactly the text of `path`, `prefer` first.""" + candidates = sorted(claude_files, key=lambda file: (file != prefer, str(file))) + return next((file for file in candidates if has_same_text(path, file)), None) + + def _claude_files_by_identity(read: Iterable[Path], root: Path) -> dict[Path, Path]: """The `CLAUDE.md`-family files among `read`, each under the resolved path it is the same file as. A file under a `.agents/` directory of the project is not one Claude Code reads.""" diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 98c19b4..79fb520 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -90,7 +90,8 @@ def expected_loads(target: Path) -> JsonObject: for item in sorted(skipped, key=lambda s: s.path) ] aliases: list[JsonValue] = [ - {"path": str(item.path), "same_as": str(item.same_as)} for item in sorted(reads.aliases, key=lambda a: a.path) + {"path": str(item.path), "same_as": str(item.same_as), "how": item.how} + for item in sorted(reads.aliases, key=lambda a: a.path) ] return { "version": 1, diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 360cd7f..4738c6d 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -18,7 +18,9 @@ runner = CliRunner() -def _write(path: Path, text: str = "# Notes\n\nRun the tests.\n") -> Path: +def _write(path: Path, text: str | None = None) -> Path: + if text is None: + text = "# Agents\n\nBuild with make.\n" if path.name == "AGENTS.md" else "# Notes\n\nRun the tests.\n" path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") return path @@ -163,7 +165,7 @@ def test_agents_md_linked_to_claude_md_is_reported_as_that_file(tmp_path: Path) _write(root / "sub2" / "CLAUDE.md") _write(root / "sub2" / "AGENTS.md") _write(root / "sub3" / "CLAUDE.md") - _write(root / "sub3" / "notes.md") + _write(root / "sub3" / "notes.md", "# Other\n") try: (root / "AGENTS.md").symlink_to("CLAUDE.md") (root / "sub" / "AGENTS.md").symlink_to("CLAUDE.md") @@ -173,8 +175,12 @@ def test_agents_md_linked_to_claude_md_is_reported_as_that_file(tmp_path: Path) out = expected_loads(root) resolved = root.resolve() assert out["aliases"] == [ - {"path": str(resolved / "AGENTS.md"), "same_as": str(resolved / "CLAUDE.md")}, - {"path": str(resolved / "sub" / "AGENTS.md"), "same_as": str(resolved / "sub" / "CLAUDE.md")}, + {"path": str(resolved / "AGENTS.md"), "same_as": str(resolved / "CLAUDE.md"), "how": "link"}, + { + "path": str(resolved / "sub" / "AGENTS.md"), + "same_as": str(resolved / "sub" / "CLAUDE.md"), + "how": "link", + }, ] skipped = {Path(i["path"]).relative_to(resolved).as_posix(): i["reason"] for i in out["skipped"]} assert skipped == {"sub2/AGENTS.md": "claude_md_present", "sub3/AGENTS.md": "claude_md_present"} @@ -226,8 +232,8 @@ def test_agents_md_linking_to_claude_local_md_is_an_alias(tmp_path: Path) -> Non def test_agents_md_linking_into_agents_dir_or_excluded_folder_stays_skipped(tmp_path: Path) -> None: root = _project(tmp_path) _write(root / "CLAUDE.md") - _write(root / ".agents" / "CLAUDE.md") - _write(root / "node_modules" / "pkg" / "CLAUDE.md") + _write(root / ".agents" / "CLAUDE.md", "# Other\n") + _write(root / "node_modules" / "pkg" / "CLAUDE.md", "# Another\n") _link(root / "AGENTS.md", ".agents/CLAUDE.md") _link(root / "a" / "AGENTS.md", "../node_modules/pkg/CLAUDE.md") out = expected_loads(root) @@ -547,3 +553,57 @@ def refuse(self: Path, *args: object, **kwargs: object) -> None: monkeypatch.setattr(Path, "chmod", refuse) assert _answer(root) == before + + +def _how_by_name(out: dict, root: Path) -> dict[str, str]: + resolved = root.resolve() + return {Path(a["path"]).relative_to(resolved).as_posix(): a["how"] for a in out["aliases"]} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize( + ("agents_text", "how", "skipped"), + [ + ("# Notes\n\nRun the tests.\n", "copy", {}), + ("# Notes\n\nRun the tests!\n", None, {"AGENTS.md": "claude_md_present"}), + (None, "link", {}), + ], + ids=["copy", "near-copy", "link"], +) +def test_agents_md_reaching_claude_through_claude_md_is_an_alias_with_how( + tmp_path: Path, agents_text: str | None, how: str | None, skipped: dict[str, str] +) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md") + if agents_text is None: + _link(root / "AGENTS.md", "CLAUDE.md") + else: + _write(root / "AGENTS.md", agents_text) + out = expected_loads(root) + assert _how_by_name(out, root) == ({} if how is None else {"AGENTS.md": how}) + assert _skipped_reasons(out, root) == skipped + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize("reason", ["claude_md_present", "setting_off", "never_read"]) +@pytest.mark.parametrize("same_text", [True, False]) +def test_agents_md_with_the_text_of_a_read_claude_md_is_a_copy_whatever_the_skip_reason( + tmp_path: Path, reason: str, same_text: bool +) -> None: + root = _project(tmp_path) + claude_text = "# Notes\n\nRun the tests.\n" + _write(root / "CLAUDE.md", claude_text) + agents = Path(".agents/AGENTS.md") if reason == "never_read" else Path("AGENTS.md") + _write(root / agents, claude_text if same_text else claude_text + "Also lint.\n") + if reason == "setting_off": + _mode("claude-md") + out = expected_loads(root) + if same_text: + assert _alias_names(out, root) == {agents.as_posix(): "CLAUDE.md"} + assert {a["how"] for a in out["aliases"]} == {"copy"} + assert _skipped_reasons(out, root) == {} + else: + assert out["aliases"] == [] + assert _skipped_reasons(out, root) == {agents.as_posix(): reason} From 6972a15bc2330b2f9e1632607ba1b4a484f70001 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 13:05:22 +0200 Subject: [PATCH 13/31] expected-loads: an AGENTS.md is reported as a copy of a CLAUDE.md only when that file loads wherever the AGENTS.md would --- UNRELEASED.md | 2 +- .../core/discovery/file_aliases.py | 4 +- .../core/discovery/read_gates.py | 41 ++++++++++--- tests/unit/test_expected_loads.py | 60 +++++++++++++++++++ 4 files changed, 97 insertions(+), 10 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index a0fcb70..a2036f5 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md, or is a copy with exactly the same text, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md, or is a copy with exactly the same text of one that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed diff --git a/src/reporails_cli/core/discovery/file_aliases.py b/src/reporails_cli/core/discovery/file_aliases.py index 5eb552c..ec0ef19 100644 --- a/src/reporails_cli/core/discovery/file_aliases.py +++ b/src/reporails_cli/core/discovery/file_aliases.py @@ -24,9 +24,9 @@ def has_same_text(first: Path, second: Path) -> bool: - """True when both files can be read and hold exactly the same bytes.""" + """True when both files can be read and hold exactly the same bytes; bytes are read only when the sizes match.""" try: - return first.read_bytes() == second.read_bytes() + return first.stat().st_size == second.stat().st_size and first.read_bytes() == second.read_bytes() except OSError: return False diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index 514c9dd..eadf7bc 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -55,6 +55,12 @@ def _claude_file_in(directory: Path, user_file: Path) -> Path | None: return _file_named(directory, "claude.local.md") +def _claude_file_folder(claude_file: Path) -> Path: + """The folder a `CLAUDE.md`-family file belongs to: the one holding `.claude/` for a `.claude/CLAUDE.md`.""" + parent = claude_file.parent + return parent.parent if parent.name.lower() == ".claude" else parent + + def _user_claude_md() -> Path: return safe_resolve(Path.home() / ".claude" / "CLAUDE.md") @@ -143,7 +149,7 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] if same_as is not None: aliases.append(AliasFile(path, same_as, "link")) continue - twin = _file_with_same_text(path, claude_files.values(), prefer=skip[1]) + twin = _file_with_same_text(path, list(claude_files.items()), prefer=skip[1], user_file=user_file) if twin is not None: aliases.append(AliasFile(path, twin, "copy")) continue @@ -151,18 +157,39 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] return AgentsMdReads(kept, skipped, mode, aliases) -def _file_with_same_text(path: Path, claude_files: Iterable[Path], *, prefer: Path | None) -> Path | None: - """The `CLAUDE.md`-family file Claude Code reads that holds exactly the text of `path`, `prefer` first.""" - candidates = sorted(claude_files, key=lambda file: (file != prefer, str(file))) - return next((file for file in candidates if has_same_text(path, file)), None) +def _file_with_same_text( + path: Path, candidates: list[tuple[Path, Path]], *, prefer: Path | None, user_file: Path +) -> Path | None: + """The `CLAUDE.md`-family file among `candidates` (resolved path, path) that holds exactly the text + of `path` and is read in every session that reads `path`, the one at `prefer` first.""" + folder = safe_resolve(path.parent) + wanted = safe_resolve(prefer) if prefer is not None else None + ordered = [pair for pair in candidates if pair[0] == wanted] + [pair for pair in candidates if pair[0] != wanted] + return next( + ( + file + for resolved, file in ordered + if _read_wherever_read(resolved, file, folder, user_file) and has_same_text(path, file) + ), + None, + ) + + +def _read_wherever_read(resolved: Path, claude_file: Path, folder: Path, user_file: Path) -> bool: + """True when `claude_file` is the user's own `~/.claude/CLAUDE.md`, or sits in `folder` or a folder above it; + for a `.claude/CLAUDE.md` the folder that counts is the one holding `.claude/`.""" + if resolved == user_file: + return True + owner = safe_resolve(_claude_file_folder(claude_file)) + return owner == folder or owner in folder.parents def _claude_files_by_identity(read: Iterable[Path], root: Path) -> dict[Path, Path]: """The `CLAUDE.md`-family files among `read`, each under the resolved path it is the same file as. - A file under a `.agents/` directory of the project is not one Claude Code reads.""" + A file under a `.agents/` directory of the project is not one Claude Code reads. The dict is ordered by path.""" return { safe_resolve(path): path - for path in read + for path in sorted(read, key=str) if path.name.lower() in _CLAUDE_FAMILY and not _in_agents_dir(path, root) } diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 4738c6d..24358df 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -607,3 +607,63 @@ def test_agents_md_with_the_text_of_a_read_claude_md_is_a_copy_whatever_the_skip else: assert out["aliases"] == [] assert _skipped_reasons(out, root) == {agents.as_posix(): reason} + + +_SHARED = "# Shared\n\n- Write tests.\n" + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_is_not_a_copy_of_a_claude_md_that_loads_only_in_a_subfolder(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md", "# Root\n\n- Use pnpm.\n") + _write(root / "AGENTS.md", _SHARED) + _write(root / "pkg" / "CLAUDE.md", _SHARED) + out = expected_loads(root) + assert out["aliases"] == [] + assert _skipped_reasons(out, root) == {"AGENTS.md": "claude_md_present"} + causes = {k: v["cause"] for k, v in _by_name(out["skipped"], root).items()} + assert causes == {"AGENTS.md": str(root.resolve() / "CLAUDE.md")} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_subfolder_agents_md_is_a_copy_of_the_claude_md_beside_it(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "pkg" / "CLAUDE.md", _SHARED) + _write(root / "pkg" / "AGENTS.md", _SHARED) + out = expected_loads(root) + assert _alias_names(out, root) == {"pkg/AGENTS.md": "pkg/CLAUDE.md"} + assert _how_by_name(out, root) == {"pkg/AGENTS.md": "copy"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize("source", ["root", "user"]) +def test_agents_md_is_a_copy_of_a_claude_md_that_loads_wherever_it_would(tmp_path: Path, source: str) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md", _SHARED if source == "root" else "# Root\n\n- Use pnpm.\n") + if source == "user": + _write(Path.home() / ".claude" / "CLAUDE.md", _SHARED) + _write(root / "AGENTS.md", _SHARED) + out = expected_loads(root) + assert _how_by_name(out, root) == {"AGENTS.md": "copy"} + assert _skipped_reasons(out, root) == {} + if source == "user": + assert out["aliases"][0]["same_as"] == str(Path.home().resolve() / ".claude" / "CLAUDE.md") + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_symlinked_project_root_still_prefers_the_file_that_made_the_agents_md_skip(tmp_path: Path) -> None: + real = tmp_path / "real" + _write(real / "CLAUDE.md", _SHARED) + _write(real / "CLAUDE.local.md", _SHARED) + _write(real / "AGENTS.md", _SHARED) + link = tmp_path / "link" + try: + link.symlink_to(real, target_is_directory=True) + except OSError: + pytest.skip("symlinks cannot be created here") + out = expected_loads(link) + assert [Path(a["same_as"]).name for a in out["aliases"]] == ["CLAUDE.md"] From 9e7c9576b2d8d260e5234de31a160fe2421dec9d Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 16:17:14 +0200 Subject: [PATCH 14/31] install: ails install checks each PATH folder's own files, so an ails in the current folder no longer stands in for an install on Windows, and uv's cache is looked up only when needed --- UNRELEASED.md | 2 +- src/reporails_cli/interfaces/cli/install.py | 41 ++++++-- tests/unit/test_install_command.py | 10 +- tests/unit/test_npm_install_method.py | 100 ++++++++++++++++++-- 4 files changed, 131 insertions(+), 22 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index a2036f5..4ce75f1 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -14,7 +14,7 @@ ### Fixed -- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. +- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. An `ails` in the current folder that is not on PATH no longer counts as installed. - A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. diff --git a/src/reporails_cli/interfaces/cli/install.py b/src/reporails_cli/interfaces/cli/install.py index bcd5b71..db973a7 100644 --- a/src/reporails_cli/interfaces/cli/install.py +++ b/src/reporails_cli/interfaces/cli/install.py @@ -2,12 +2,14 @@ from __future__ import annotations +import functools import json import logging import os import re import shutil import subprocess +from collections.abc import Callable from pathlib import Path, PureWindowsPath import typer @@ -64,26 +66,49 @@ def _uv_cache_dir(uv: str) -> str | None: return result.stdout.strip() or None if result.returncode == 0 else None -def _is_temporary_ails(ails_path: str, uv: str | None) -> bool: +def _is_temporary_ails(ails_path: str, cache: Callable[[], str | None]) -> bool: """True for an `ails` inside a `node_modules` folder or in uv's cache. - That is a one-shot or project copy, not a PATH install. + That is a one-shot or project copy, not a PATH install. `cache` is a getter for uv's + cache folder, called only when the `node_modules` rule does not decide. """ if "node_modules" in PureWindowsPath(ails_path).parts: return True - cache = _uv_cache_dir(uv) if uv else None - if cache: - return Path(os.path.realpath(ails_path)).is_relative_to(os.path.realpath(cache)) + cache_dir = cache() + if cache_dir: + return Path(os.path.realpath(ails_path)).is_relative_to(os.path.realpath(cache_dir)) return "/.cache/uv/" in ails_path +def _ails_names(windows: bool, pathext: str) -> list[str]: + """File names that count as the `ails` program in one folder. + + Windows runs only a name with a PATHEXT extension, so there is no bare `ails` there. + """ + if windows: + return [f"ails{ext}" for ext in pathext.split(";") if ext] + return ["ails"] + + +def _ails_in_folder(entry: str) -> str | None: + """The `ails` executable directly inside PATH folder `entry`, or None.""" + pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD") + for name in _ails_names(os.name == "nt", pathext): + candidate = os.path.join(entry, name) + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + return None + + def _install_to_path() -> bool: """Install ails to PATH via uv tool install. Returns True on success.""" uv = shutil.which("uv") + cache = functools.cache(lambda: _uv_cache_dir(uv) if uv else None) + # Already on PATH as a real install (not uvx one-shot)? - entries = os.environ.get("PATH", "").split(os.pathsep) - hits = (shutil.which("ails", path=entry) for entry in entries if entry) - ails_path = next((hit for hit in hits if hit and not _is_temporary_ails(hit, uv)), None) + entries = [entry for entry in os.environ.get("PATH", "").split(os.pathsep) if entry] + hits = (_ails_in_folder(entry) for entry in entries) + ails_path = next((hit for hit in hits if hit and not _is_temporary_ails(hit, cache)), None) if ails_path: console.print(f" [dim]ails already on PATH: {ails_path}[/dim]") return True diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index e63888c..7835f08 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -47,7 +47,8 @@ def test_install_to_path_pins_a_floor_not_an_exact_version(monkeypatch: pytest.M `uv tool upgrade` keeps an `==` pin forever, so only a floor lets a later `ails update` / `uv tool upgrade` move the engine past this version. """ - monkeypatch.setattr(install_module.shutil, "which", lambda cmd, path=None: None if cmd == "ails" else "/usr/bin/uv") + monkeypatch.setattr(install_module.shutil, "which", lambda cmd, path=None: "/usr/bin/uv") + monkeypatch.setattr(install_module, "_ails_in_folder", lambda entry: None) calls: list[list[str]] = [] @@ -726,11 +727,12 @@ def _path_install( monkeypatch.setenv("PATH", os.path.dirname(ails)) def which(cmd: str, mode: int = os.F_OK | os.X_OK, path: str | None = None) -> str | None: - if cmd == "uv": - return uv - return ails if path == os.path.dirname(ails) else None + return uv if cmd == "uv" else None monkeypatch.setattr(install_module.shutil, "which", which) + monkeypatch.setattr( + install_module, "_ails_in_folder", lambda entry: ails if entry == os.path.dirname(ails) else None + ) calls: list[list[str]] = [] def fake_run(cmd: list[str], **kwargs: Any) -> MagicMock: diff --git a/tests/unit/test_npm_install_method.py b/tests/unit/test_npm_install_method.py index 35a0c57..e5fac94 100644 --- a/tests/unit/test_npm_install_method.py +++ b/tests/unit/test_npm_install_method.py @@ -36,14 +36,6 @@ def test_launcher_variable_is_consumed_once(monkeypatch): assert bootstrap.consume_npm_launch() is True -def _stub(folder: Path) -> Path: - folder.mkdir(parents=True) - exe = folder / "ails" - exe.write_text("#!/bin/sh\n", encoding="utf-8") - exe.chmod(0o755) - return exe - - _REAL_WHICH = shutil.which @@ -93,6 +85,96 @@ def test_install_to_path_sees_a_real_copy_behind_a_node_modules_one(two_folder_p assert ran == [] +def _no_install(monkeypatch) -> list[tuple[object, ...]]: + ran: list[tuple[object, ...]] = [] + monkeypatch.setattr( + install_module, "run_uv_tool", lambda *a, **k: ran.append(a) or subprocess.CompletedProcess(a, 0, "", "") + ) + return ran + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +def test_install_to_path_sees_the_path_copy_despite_one_in_the_current_folder( + two_folder_path, tmp_path, monkeypatch, capsys +): + """Windows searches the current folder first; the PATH copy must still be found.""" + real = _stub(two_folder_path[1]) + here = tmp_path / "cwd" + here.mkdir() + _stub(here) + monkeypatch.chdir(here) + ran = _no_install(monkeypatch) + assert install_module._install_to_path() is True + assert str(real) in "".join(capsys.readouterr().out.split()) + assert ran == [] + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +def test_install_to_path_ignores_an_ails_only_in_the_current_folder(two_folder_path, tmp_path, monkeypatch, capsys): + here = tmp_path / "cwd" + here.mkdir() + _stub(here) + monkeypatch.chdir(here) + ran = _no_install(monkeypatch) + assert install_module._install_to_path() is True + assert len(ran) == 1 + assert "already on PATH" not in capsys.readouterr().out + + +@pytest.mark.parametrize( + ("windows", "pathext", "expected"), + [ + (True, ".COM;.EXE;.BAT;.CMD", ["ails.COM", "ails.EXE", "ails.BAT", "ails.CMD"]), + (True, ".EXE;;.CMD", ["ails.EXE", "ails.CMD"]), + (False, ".EXE", ["ails"]), + ], +) +def test_ails_names(windows, pathext, expected): + assert install_module._ails_names(windows, pathext) == expected + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +def test_ails_in_folder_needs_an_executable_file(tmp_path): + assert install_module._ails_in_folder(str(tmp_path)) is None + plain = tmp_path / "ails" + plain.write_text("x", encoding="utf-8") + plain.chmod(0o644) + assert install_module._ails_in_folder(str(tmp_path)) is None + plain.chmod(0o755) + assert install_module._ails_in_folder(str(tmp_path)) == str(plain) + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +def test_uv_cache_dir_is_asked_once_per_install(two_folder_path, tmp_path, monkeypatch): + """Two PATH copies outside node_modules, the first inside uv's cache: one lookup.""" + cache = tmp_path / "cache" + one, two = cache / "bin", tmp_path / "other" / "bin" + one.mkdir(parents=True) + two.mkdir(parents=True) + _stub(one) + _stub(two) + monkeypatch.setenv("PATH", os.pathsep.join([str(one), str(two)])) + calls: list[str] = [] + monkeypatch.setattr(install_module, "_uv_cache_dir", lambda uv: calls.append(uv) or str(cache)) + _no_install(monkeypatch) + assert install_module._install_to_path() is True + assert len(calls) == 1 + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +@pytest.mark.parametrize("with_node_modules_copy", [True, False]) +def test_uv_cache_dir_is_not_asked_without_a_copy_outside_node_modules( + two_folder_path, monkeypatch, with_node_modules_copy +): + if with_node_modules_copy: + _stub(two_folder_path[0]) + calls: list[str] = [] + monkeypatch.setattr(install_module, "_uv_cache_dir", lambda uv: calls.append(uv)) + _no_install(monkeypatch) + assert install_module._install_to_path() is True + assert calls == [] + + def test_update_prints_hint_and_runs_nothing(monkeypatch, capsys): monkeypatch.setenv("AILS_LAUNCHER", "npm") calls: list[object] = [] @@ -118,4 +200,4 @@ def test_update_prints_hint_and_runs_nothing(monkeypatch, capsys): ], ) def test_node_modules_copy_is_temporary(path, expected): - assert _is_temporary_ails(path, None) is expected + assert _is_temporary_ails(path, lambda: None) is expected From eee2186b1ca3f7c86cf9fddc43ffd01ed13ffb15 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 15:09:39 +0200 Subject: [PATCH 15/31] expected-loads: an AGENTS.md is reported as a CLAUDE.md's link or copy only when that file loads wherever it would, and a CLAUDE.md a subfolder links to stays the main file --- UNRELEASED.md | 4 +- .../core/discovery/agent_discovery.py | 19 ++++---- .../core/discovery/read_gates.py | 47 ++++++++++--------- .../core/pipeline/expected_loads.py | 6 +-- tests/unit/test_expected_loads.py | 41 ++++++++++++++++ 5 files changed, 81 insertions(+), 36 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 4ce75f1..ae22899 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to a CLAUDE.md, or is a copy with exactly the same text of one that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed @@ -15,6 +15,8 @@ ### Fixed - `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. An `ails` in the current folder that is not on PATH no longer counts as installed. +- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. +- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. - A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. diff --git a/src/reporails_cli/core/discovery/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py index 7ba3d78..b30542f 100644 --- a/src/reporails_cli/core/discovery/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -546,20 +546,19 @@ def _canonical_path(path: Path) -> Path: def _dedupe_by_canonical(paths: list[Path]) -> list[Path]: - """Sort and dedupe paths by their canonical (symlink-resolved) target. + """Dedupe paths by their canonical (symlink-resolved) target, sorted. Two surface paths can refer to the same underlying file when one or both are symlinks (common pattern: `.claude/skills -> ../.agents/skills`). Naive `set(paths)` keeps both because path equality compares strings. - Canonicalizing via `Path.resolve(strict=True)` collapses symlinks; the - first surface path encountered for a canonical target wins. + Canonicalizing via `Path.resolve(strict=True)` collapses symlinks; of the + paths that share a target, the one with the fewest path parts (nearest the + scan root) stands for it, and equal depth keeps the first in sort order. """ - seen_canonical: set[Path] = set() - out: list[Path] = [] + best: dict[Path, Path] = {} for p in sorted(set(paths)): canonical = _canonical_path(p) - if canonical in seen_canonical: - continue - seen_canonical.add(canonical) - out.append(p) - return out + kept = best.get(canonical) + if kept is None or len(p.parts) < len(kept.parts): + best[canonical] = p + return sorted(best.values()) diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index eadf7bc..150c42e 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -16,7 +16,7 @@ from __future__ import annotations -from collections.abc import Callable, Iterable +from collections.abc import Callable, Mapping from dataclasses import dataclass from pathlib import Path from typing import Literal @@ -125,11 +125,12 @@ class AgentsMdReads: aliases: list[AliasFile] -def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] = ()) -> AgentsMdReads: +def claude_agents_md_reads(found: list[Path], target: Path, read: Mapping[Path, str] | None = None) -> AgentsMdReads: """Split the `AGENTS.md` files among `found` into those Claude Code loads for a session in `target` and those it skips, each skipped file with its reason. A skipped file that is the same file as, or holds exactly the text of, a `CLAUDE.md`-family file among `read` (the instruction - files the session reads) is reported as an alias of it instead.""" + files the session reads, each with its loading) is reported as an alias of it instead, when that file + loads in every session where the `AGENTS.md` would.""" if not found: return AgentsMdReads([], [], "", []) root = resolve_project_root(target) @@ -139,17 +140,18 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] aliases: list[AliasFile] = [] claude_above = claude_file_at_or_above(root) if mode == MODE_CLAUDE_MD_OR_AGENTS_MD else None user_file = _user_claude_md() - claude_files = {} if mode in MODES_WITHOUT_LAUNCH_INSTRUCTIONS else _claude_files_by_identity(read, root) + claude_files = {} if mode in MODES_WITHOUT_LAUNCH_INSTRUCTIONS else _claude_files_by_identity(read or {}, root) for path in found: skip = _skip_reason(path, root, mode, claude_above=claude_above, user_file=user_file) if skip is None: kept.append(path) continue + folder = safe_resolve(path.parent) same_as = claude_files.get(safe_resolve(path)) - if same_as is not None: - aliases.append(AliasFile(path, same_as, "link")) + if same_as is not None and _loads_wherever_read(*same_as, folder): + aliases.append(AliasFile(path, same_as[0], "link")) continue - twin = _file_with_same_text(path, list(claude_files.items()), prefer=skip[1], user_file=user_file) + twin = _file_with_same_text(path, claude_files, prefer=skip[1]) if twin is not None: aliases.append(AliasFile(path, twin, "copy")) continue @@ -157,38 +159,39 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Iterable[Path] return AgentsMdReads(kept, skipped, mode, aliases) -def _file_with_same_text( - path: Path, candidates: list[tuple[Path, Path]], *, prefer: Path | None, user_file: Path -) -> Path | None: - """The `CLAUDE.md`-family file among `candidates` (resolved path, path) that holds exactly the text - of `path` and is read in every session that reads `path`, the one at `prefer` first.""" +def _file_with_same_text(path: Path, candidates: dict[Path, tuple[Path, str]], *, prefer: Path | None) -> Path | None: + """The `CLAUDE.md`-family file among `candidates` (by resolved path) that holds exactly the text + of `path` and loads in every session that reads `path`, the one at `prefer` first.""" folder = safe_resolve(path.parent) wanted = safe_resolve(prefer) if prefer is not None else None - ordered = [pair for pair in candidates if pair[0] == wanted] + [pair for pair in candidates if pair[0] != wanted] + ordered = [(r, c) for r, c in candidates.items() if r == wanted] + [ + (r, c) for r, c in candidates.items() if r != wanted + ] return next( ( file - for resolved, file in ordered - if _read_wherever_read(resolved, file, folder, user_file) and has_same_text(path, file) + for _, (file, loading) in ordered + if _loads_wherever_read(file, loading, folder) and has_same_text(path, file) ), None, ) -def _read_wherever_read(resolved: Path, claude_file: Path, folder: Path, user_file: Path) -> bool: - """True when `claude_file` is the user's own `~/.claude/CLAUDE.md`, or sits in `folder` or a folder above it; +def _loads_wherever_read(claude_file: Path, loading: str, folder: Path) -> bool: + """True when `claude_file` loads at session start, or on demand for `folder` or a folder above it; for a `.claude/CLAUDE.md` the folder that counts is the one holding `.claude/`.""" - if resolved == user_file: + if loading == "session_start": return True owner = safe_resolve(_claude_file_folder(claude_file)) return owner == folder or owner in folder.parents -def _claude_files_by_identity(read: Iterable[Path], root: Path) -> dict[Path, Path]: - """The `CLAUDE.md`-family files among `read`, each under the resolved path it is the same file as. - A file under a `.agents/` directory of the project is not one Claude Code reads. The dict is ordered by path.""" +def _claude_files_by_identity(read: Mapping[Path, str], root: Path) -> dict[Path, tuple[Path, str]]: + """The `CLAUDE.md`-family files among `read`, each under the resolved path it is the same file as, + with its loading. A file under a `.agents/` directory of the project is not one Claude Code reads. + The dict is ordered by path.""" return { - safe_resolve(path): path + safe_resolve(path): (path, read[path]) for path in sorted(read, key=str) if path.name.lower() in _CLAUDE_FAMILY and not _in_agents_dir(path, root) } diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 79fb520..3810eab 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -106,14 +106,14 @@ def expected_loads(target: Path) -> JsonObject: def _loaded_files( exclusions: RunExclusions, project_config: ProjectConfig, root: Path, registry: dict[str, JsonObject], mode: str -) -> tuple[list[JsonValue], list[Path], dict[Path, str]]: +) -> tuple[list[JsonValue], dict[Path, str], dict[Path, str]]: """The non-empty instruction and rule files Claude Code loads (the user's own included), the instruction files among them, and the launch files the `instructionFiles` setting turns off (each with its file type).""" found = discover_from_config(root, AGENT, exclusions.walk_dirs, project_config=project_config, repo_scoped=False) instruction, rules = (set(bucket) for bucket in found[:2]) if found is not None else (set(), set()) files: list[JsonValue] = [] - read_instructions: list[Path] = [] + read_instructions: dict[Path, str] = {} switched_off: dict[Path, str] = {} for path in sorted(p for p in instruction | rules if not exclusions.leaves_out(p)): read = file_read_by(AGENT, path, root, registry) @@ -128,7 +128,7 @@ def _loaded_files( switched_off[path] = read.file_type continue if path in instruction: - read_instructions.append(path) + read_instructions[path] = read.loading files.append( {"path": str(path), "file_type": read.file_type, "loading": read.loading, "precedence": read.precedence} ) diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 24358df..83fa2a8 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -11,6 +11,7 @@ import pytest from typer.testing import CliRunner +from reporails_cli.core.discovery.read_gates import claude_agents_md_reads from reporails_cli.core.pipeline.expected_loads import expected_loads from reporails_cli.core.platform.config import claude_settings from reporails_cli.interfaces.cli.main import app @@ -667,3 +668,43 @@ def test_symlinked_project_root_still_prefers_the_file_that_made_the_agents_md_s pytest.skip("symlinks cannot be created here") out = expected_loads(link) assert [Path(a["same_as"]).name for a in out["aliases"]] == ["CLAUDE.md"] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_project_claude_md_a_subfolder_links_to_stays_the_main_file(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md", "# Root\n- Use pnpm.\n") + _link(root / "A" / "CLAUDE.md", "../CLAUDE.md") + _write(root / "AGENTS.md", "# Root\n- Use pnpm.\n") + out = expected_loads(root) + files = _by_name(out["files"], root.resolve()) + assert (files["CLAUDE.md"]["file_type"], files["CLAUDE.md"]["loading"]) == ("main", "session_start") + assert "A/CLAUDE.md" not in files + assert _alias_names(out, root) == {"AGENTS.md": "CLAUDE.md"} + assert _how_by_name(out, root) == {"AGENTS.md": "copy"} + assert out["skipped"] == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_is_a_copy_of_a_managed_claude_md(tmp_path: Path) -> None: + root = _project(tmp_path) + managed = _write(tmp_path / "managed" / "CLAUDE.md", _SHARED) + _write(root / "CLAUDE.md", "# Root\n\n- Use pnpm.\n") + _write(root / "AGENTS.md", _SHARED) + reads = claude_agents_md_reads([root / "AGENTS.md"], root, {managed: "session_start"}) + assert [(a.path, a.same_as, a.how) for a in reads.aliases] == [(root / "AGENTS.md", managed, "copy")] + assert reads.skipped == [] + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_agents_md_linked_to_an_on_demand_claude_md_stays_skipped(tmp_path: Path) -> None: + root = _project(tmp_path) + _write(root / "CLAUDE.md", "# Root\n\n- Use pnpm.\n") + _write(root / "pkg" / "CLAUDE.md", _SHARED) + _link(root / "AGENTS.md", "pkg/CLAUDE.md") + out = expected_loads(root) + assert out["aliases"] == [] + assert _skipped_reasons(out, root) == {"AGENTS.md": "claude_md_present"} From 93b785dce83b50845d2c4ed385858f0f4ad1fb76 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 15:47:04 +0200 Subject: [PATCH 16/31] expected-loads: a project CLAUDE.md that links to your own CLAUDE.md stays the project's main file --- UNRELEASED.md | 2 +- .../core/discovery/agent_discovery.py | 22 ++++++------ .../core/discovery/read_gates.py | 34 +++++++++++-------- tests/unit/test_expected_loads.py | 11 ++++++ 4 files changed, 44 insertions(+), 25 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index ae22899..cc594b9 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed diff --git a/src/reporails_cli/core/discovery/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py index b30542f..a657706 100644 --- a/src/reporails_cli/core/discovery/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -524,9 +524,9 @@ def discover_from_config( buckets[surface[0]].extend(surface[1]) return ( - _dedupe_by_canonical(buckets["instruction"]), - _dedupe_by_canonical(buckets["rule"]), - _dedupe_by_canonical(buckets["config"]), + _dedupe_by_canonical(buckets["instruction"], target), + _dedupe_by_canonical(buckets["rule"], target), + _dedupe_by_canonical(buckets["config"], target), ) @@ -545,20 +545,22 @@ def _canonical_path(path: Path) -> Path: return path -def _dedupe_by_canonical(paths: list[Path]) -> list[Path]: +def _dedupe_by_canonical(paths: list[Path], root: Path) -> list[Path]: """Dedupe paths by their canonical (symlink-resolved) target, sorted. Two surface paths can refer to the same underlying file when one or both are symlinks (common pattern: `.claude/skills -> ../.agents/skills`). Naive `set(paths)` keeps both because path equality compares strings. Canonicalizing via `Path.resolve(strict=True)` collapses symlinks; of the - paths that share a target, the one with the fewest path parts (nearest the - scan root) stands for it, and equal depth keeps the first in sort order. + paths that share a target, one inside `root` stands for it over one outside, + then the one with the fewest path parts below `root`, then the first in sort order. """ - best: dict[Path, Path] = {} + best: dict[Path, tuple[bool, int, Path]] = {} for p in sorted(set(paths)): canonical = _canonical_path(p) + inside = p.is_relative_to(root) + rank = (not inside, len(p.relative_to(root).parts) if inside else len(p.parts), p) kept = best.get(canonical) - if kept is None or len(p.parts) < len(kept.parts): - best[canonical] = p - return sorted(best.values()) + if kept is None or rank[:2] < kept[:2]: + best[canonical] = rank + return sorted(rank[2] for rank in best.values()) diff --git a/src/reporails_cli/core/discovery/read_gates.py b/src/reporails_cli/core/discovery/read_gates.py index 150c42e..4fb14d2 100644 --- a/src/reporails_cli/core/discovery/read_gates.py +++ b/src/reporails_cli/core/discovery/read_gates.py @@ -146,27 +146,33 @@ def claude_agents_md_reads(found: list[Path], target: Path, read: Mapping[Path, if skip is None: kept.append(path) continue - folder = safe_resolve(path.parent) - same_as = claude_files.get(safe_resolve(path)) - if same_as is not None and _loads_wherever_read(*same_as, folder): - aliases.append(AliasFile(path, same_as[0], "link")) - continue - twin = _file_with_same_text(path, claude_files, prefer=skip[1]) - if twin is not None: - aliases.append(AliasFile(path, twin, "copy")) + alias = _alias_of(path, claude_files, prefer=skip[1]) + if alias is not None: + aliases.append(alias) continue skipped.append(SkippedFile(path, *skip)) return AgentsMdReads(kept, skipped, mode, aliases) -def _file_with_same_text(path: Path, candidates: dict[Path, tuple[Path, str]], *, prefer: Path | None) -> Path | None: - """The `CLAUDE.md`-family file among `candidates` (by resolved path) that holds exactly the text - of `path` and loads in every session that reads `path`, the one at `prefer` first.""" +def _alias_of(path: Path, claude_files: dict[Path, tuple[Path, str]], *, prefer: Path | None) -> AliasFile | None: + """`path` as a `link` of, else a `copy` of, a `CLAUDE.md`-family file among `claude_files` (by resolved + path, with its loading) that loads in every session that reads `path`; None when it is neither.""" folder = safe_resolve(path.parent) + same_as = claude_files.get(safe_resolve(path)) + if same_as is not None and _loads_wherever_read(*same_as, folder): + return AliasFile(path, same_as[0], "link") + twin = _file_with_same_text(path, folder, claude_files, prefer=prefer) + return None if twin is None else AliasFile(path, twin, "copy") + + +def _file_with_same_text( + path: Path, folder: Path, candidates: dict[Path, tuple[Path, str]], *, prefer: Path | None +) -> Path | None: + """The `CLAUDE.md`-family file among `candidates` (by resolved path) that holds exactly the text + of `path` and loads in every session that reads `path` (whose resolved folder is `folder`), the one at + `prefer` first.""" wanted = safe_resolve(prefer) if prefer is not None else None - ordered = [(r, c) for r, c in candidates.items() if r == wanted] + [ - (r, c) for r, c in candidates.items() if r != wanted - ] + ordered = sorted(candidates.items(), key=lambda item: item[0] != wanted) return next( ( file diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index 83fa2a8..b8f23c1 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -708,3 +708,14 @@ def test_agents_md_linked_to_an_on_demand_claude_md_stays_skipped(tmp_path: Path out = expected_loads(root) assert out["aliases"] == [] assert _skipped_reasons(out, root) == {"AGENTS.md": "claude_md_present"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_project_claude_md_linking_to_the_user_claude_md_stays_the_project_main_file(tmp_path: Path) -> None: + root = tmp_path / "deep" / "a" / "b" / "c" / "p" + _write(Path.home() / ".claude" / "CLAUDE.md", _SHARED) + _link(root / "CLAUDE.md", str(Path.home() / ".claude" / "CLAUDE.md")) + out = expected_loads(root) + files = _by_name(out["files"], root.resolve()) + assert (files["CLAUDE.md"]["file_type"], files["CLAUDE.md"]["precedence"]) == ("main", "project") From 51224c8c5327657970d19e4558e00f41bc72deba Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 15:56:41 +0200 Subject: [PATCH 17/31] unreleased: the x-ray changelog line names what leaves files out under managed-only --- UNRELEASED.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index cc594b9..01f7e38 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed, and a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file, and under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` the x-ray leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed From 5ea08237bd9223b077f7741f2292310a68c89bc9 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 17:46:33 +0200 Subject: [PATCH 18/31] config: the server, tier, integer, text and CI settings are read in one place, and a space before the address in AILS_SERVER_URL no longer stops ails check from reaching the server --- UNRELEASED.md | 1 + src/reporails_cli/core/mapper/encode_pool.py | 20 ++- src/reporails_cli/core/mapper/model_fetch.py | 6 +- .../core/platform/adapters/api_client.py | 13 +- .../core/platform/adapters/api_key.py | 8 +- .../core/platform/adapters/rate_cooldown.py | 2 +- .../core/platform/config/bootstrap.py | 31 ++++- .../core/platform/config/credentials.py | 6 + .../core/platform/config/endpoints.py | 18 ++- .../interfaces/cli/check_support.py | 11 +- .../interfaces/mcp/scan_fingerprint.py | 7 +- tests/smoke/test_offline_server.py | 2 +- tests/unit/test_api_client_server_url.py | 2 +- tests/unit/test_env_readers.py | 121 ++++++++++++++++++ tests/unit/test_notices_text.py | 10 +- tests/unit/test_tier_refresh.py | 2 +- 16 files changed, 202 insertions(+), 58 deletions(-) create mode 100644 tests/unit/test_env_readers.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 01f7e38..5f9ceb2 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -20,6 +20,7 @@ - A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. +- A space before the address in `AILS_SERVER_URL` no longer stops `ails check` from reaching that server. ### Removed diff --git a/src/reporails_cli/core/mapper/encode_pool.py b/src/reporails_cli/core/mapper/encode_pool.py index b8c4393..c421b75 100644 --- a/src/reporails_cli/core/mapper/encode_pool.py +++ b/src/reporails_cli/core/mapper/encode_pool.py @@ -23,6 +23,8 @@ from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed +from reporails_cli.core.platform.config.bootstrap import env_int + def _cpu_count() -> int: return os.cpu_count() or 1 @@ -30,12 +32,9 @@ def _cpu_count() -> int: def encode_pool_workers() -> int: """Thread-pool size for the per-bucket encode. Default = cpu_count, min 1.""" - raw = os.environ.get("AILS_MAP_ENCODE_WORKERS", "").strip() - if raw: - try: - return max(1, int(raw)) - except ValueError: - pass + workers = env_int("AILS_MAP_ENCODE_WORKERS") + if workers is not None: + return max(1, workers) return max(1, _cpu_count()) @@ -48,12 +47,9 @@ def ort_intra_threads() -> int: intra-op threads so the serial path still spreads across cores. Either way ``pool * intra_op ~= cpu_count``. """ - env = os.environ.get("AILS_ORT_THREADS") - if env is not None: - try: - return max(1, int(env)) - except ValueError: - pass + threads = env_int("AILS_ORT_THREADS") + if threads is not None: + return max(1, threads) return 1 if encode_pool_workers() > 1 else _cpu_count() diff --git a/src/reporails_cli/core/mapper/model_fetch.py b/src/reporails_cli/core/mapper/model_fetch.py index 83b2480..8f854bc 100644 --- a/src/reporails_cli/core/mapper/model_fetch.py +++ b/src/reporails_cli/core/mapper/model_fetch.py @@ -45,7 +45,7 @@ models_present, ) from reporails_cli.core.platform.adapters.api_key import default_server_api_key -from reporails_cli.core.platform.config.bootstrap import env_flag +from reporails_cli.core.platform.config.bootstrap import env_flag, env_text, running_in_github_actions # ─── Host ─────────────────────────────────────────────────── @@ -265,7 +265,7 @@ def cache_damaged(version: str = MODEL_VERSION) -> bool: def _base_url(version: str) -> str: """Resolved host base URL: the env override if set, else the default host.""" - override = os.environ.get(_ENV_URL, "").strip() + override = env_text(_ENV_URL) if override: return override.rstrip("/") return _DEFAULT_BASE.format(version=version) @@ -391,7 +391,7 @@ def _download_all(root: Path, version: str, *, force: bool = False) -> None: _print(f"Repairing reporails model ({len(kept)} of {len(FETCH_SHA256)} checked files still valid)…") else: _print(f"Downloading reporails model (~{_APPROX_TOTAL_MB} MB, one time)…") - if os.environ.get("GITHUB_ACTIONS") == "true": + if running_in_github_actions(): _print(_CI_CACHE_HINT) staging = Path(tempfile.mkdtemp(prefix=f".{version}.staging.", dir=parent)) with _http_client(_auth_headers(base, version)) as client: diff --git a/src/reporails_cli/core/platform/adapters/api_client.py b/src/reporails_cli/core/platform/adapters/api_client.py index b982f9a..cf7f3a4 100644 --- a/src/reporails_cli/core/platform/adapters/api_client.py +++ b/src/reporails_cli/core/platform/adapters/api_client.py @@ -8,14 +8,13 @@ import json import logging -import os from collections.abc import Sequence from dataclasses import replace from pathlib import Path import httpx -from reporails_cli.core.platform.adapters.api_key import DEFAULT_SERVER_URL, resolve_api_key +from reporails_cli.core.platform.adapters.api_key import resolve_api_key from reporails_cli.core.platform.adapters.notices_wire import NOTICES_HEADER, notices_from_header from reporails_cli.core.platform.adapters.payload import encode_msgpack, mapped_paths, project_local, project_payload from reporails_cli.core.platform.adapters.rate_cooldown import active_cooldown, record_cooldown @@ -23,9 +22,11 @@ from reporails_cli.core.platform.config.bootstrap import env_flag from reporails_cli.core.platform.config.config import get_global_config from reporails_cli.core.platform.config.credentials import ( + env_tier, refresh_stored_tier, signed_in_recently, ) +from reporails_cli.core.platform.config.endpoints import DEFAULT_SERVER_URL, server_url from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue, as_int, as_object, as_str, opt_int from reporails_cli.core.platform.dto.diagnostics import ( DEFAULT_RETRY_AFTER_S, @@ -91,9 +92,9 @@ class AilsClient: """ def __init__(self, api_key: str | None = None) -> None: - self.base_url = os.environ.get("AILS_SERVER_URL") or DEFAULT_SERVER_URL + self.base_url = server_url() self.api_key = api_key or resolve_api_key() - self.tier = os.environ.get("AILS_TIER") or _tier_from_config() or "free" + self.tier = env_tier() or _tier_from_config() or "free" def lint( self, @@ -176,7 +177,7 @@ def _early_rejection(self, err: FunnelError) -> FunnelError: def _sent_stored_key(self) -> bool: """True only for a request to the real service that carried the key as its Authorization header.""" dev_mode = env_flag("AILS_DEV_MODE") - return bool(self.api_key) and not dev_mode and self.base_url.rstrip("/") == DEFAULT_SERVER_URL.rstrip("/") + return bool(self.api_key) and not dev_mode and self.base_url == DEFAULT_SERVER_URL def _remember_tier(self, tier: str) -> None: """Keep the stored tier in step with the tier a server reply named (stored key only).""" @@ -185,7 +186,7 @@ def _remember_tier(self, tier: str) -> None: def diagnose_request(self, content_type: str) -> tuple[str, dict[str, str]]: """The diagnose URL and request headers for this client, for a body of `content_type`.""" - base = self.base_url.rstrip("/") + base = self.base_url ua = _user_agent() if env_flag("AILS_DEV_MODE"): return f"{base}/diagnose", {"X-Tier": self.tier, "Content-Type": content_type, "User-Agent": ua} diff --git a/src/reporails_cli/core/platform/adapters/api_key.py b/src/reporails_cli/core/platform/adapters/api_key.py index c759d14..ce7153b 100644 --- a/src/reporails_cli/core/platform/adapters/api_key.py +++ b/src/reporails_cli/core/platform/adapters/api_key.py @@ -3,17 +3,14 @@ from __future__ import annotations import logging -import os from collections.abc import Callable from reporails_cli.core.platform.config.credentials import env_api_key, load_credentials_record +from reporails_cli.core.platform.config.endpoints import DEFAULT_SERVER_URL, server_url from reporails_cli.core.platform.contract.errors import PlatformError logger = logging.getLogger(__name__) -# The diagnostics server used when `AILS_SERVER_URL` is unset. -DEFAULT_SERVER_URL = "https://api.reporails.com" - def api_key_from_credentials() -> str: """Read API key from ~/.reporails/credentials.yml (set by `ails login`). @@ -51,8 +48,7 @@ def default_server_api_key() -> str: A key paired with another server (a local or staging one) is not returned. """ - server = os.environ.get("AILS_SERVER_URL", "").strip().rstrip("/") - if server and server != DEFAULT_SERVER_URL: + if server_url() != DEFAULT_SERVER_URL: return "" return resolve_api_key() diff --git a/src/reporails_cli/core/platform/adapters/rate_cooldown.py b/src/reporails_cli/core/platform/adapters/rate_cooldown.py index 4a52cf0..e9bab37 100644 --- a/src/reporails_cli/core/platform/adapters/rate_cooldown.py +++ b/src/reporails_cli/core/platform/adapters/rate_cooldown.py @@ -44,7 +44,7 @@ def _cooldown_path() -> Path: def _principal_key(base_url: str, api_key: str) -> str: """Opaque per-endpoint, per-credential key; the raw API key is never stored.""" - return hashlib.sha256(f"{base_url.rstrip('/')}\0{api_key}".encode()).hexdigest()[:32] + return hashlib.sha256(f"{base_url}\0{api_key}".encode()).hexdigest()[:32] def _load() -> JsonObject: diff --git a/src/reporails_cli/core/platform/config/bootstrap.py b/src/reporails_cli/core/platform/config/bootstrap.py index da0c8f5..1cef005 100644 --- a/src/reporails_cli/core/platform/config/bootstrap.py +++ b/src/reporails_cli/core/platform/config/bootstrap.py @@ -9,9 +9,30 @@ from reporails_cli.core.platform.contract.errors import ConfigUnreadableError +def env_text(var_name: str) -> str: + """The env var's value with surrounding spaces removed ("" when unset).""" + return os.environ.get(var_name, "").strip() + + +def env_int(var_name: str) -> int | None: + """The env var as an integer; None when unset, blank or not an integer.""" + raw = env_text(var_name) + if not raw: + return None + try: + return int(raw) + except ValueError: + return None + + +def running_in_github_actions() -> bool: + """True when the process runs under GitHub Actions.""" + return os.environ.get("GITHUB_ACTIONS") == "true" + + def env_flag(var_name: str) -> bool: """True when the env var is set to anything but blank, 0, false, no or off (case and spaces ignored).""" - return os.environ.get(var_name, "").strip().lower() not in ("", "0", "false", "no", "off") + return env_text(var_name).lower() not in ("", "0", "false", "no", "off") @cache @@ -23,12 +44,8 @@ def consume_npm_launch() -> bool: def parse_idle_timeout_env(var_name: str, default_s: int) -> int | None: """Parse an idle-timeout env var (seconds): unset/blank/non-numeric → default, a positive int → that value, 0 or negative → None (idle shutdown disabled).""" - raw = os.environ.get(var_name, "").strip() - if not raw: - return default_s - try: - value = int(raw) - except ValueError: + value = env_int(var_name) + if value is None: return default_s return value if value > 0 else None diff --git a/src/reporails_cli/core/platform/config/credentials.py b/src/reporails_cli/core/platform/config/credentials.py index 485afce..309a409 100644 --- a/src/reporails_cli/core/platform/config/credentials.py +++ b/src/reporails_cli/core/platform/config/credentials.py @@ -10,6 +10,7 @@ import yaml +from reporails_cli.core.platform.config.bootstrap import env_text from reporails_cli.core.platform.contract.errors import CredentialsUnreadableError from reporails_cli.core.platform.contract.json_value import JsonObject, as_object, as_str from reporails_cli.core.platform.utils.utils import fast_yaml_load @@ -51,6 +52,11 @@ def env_api_key() -> str: return os.environ.get("AILS_API_KEY", "") +def env_tier() -> str: + """The `AILS_TIER` env override, stripped ("" when unset).""" + return env_text("AILS_TIER") + + def identity_and_tier(creds: JsonObject, api_key: str) -> tuple[bool, str]: """(stored identity matches the key in effect, stored tier for that key or ""). diff --git a/src/reporails_cli/core/platform/config/endpoints.py b/src/reporails_cli/core/platform/config/endpoints.py index b091da8..9a00682 100644 --- a/src/reporails_cli/core/platform/config/endpoints.py +++ b/src/reporails_cli/core/platform/config/endpoints.py @@ -1,12 +1,24 @@ -"""Where the website lives: the sign-in address, from the environment or the default.""" +"""Where the website and the diagnostics server live, from the environment or the defaults.""" from __future__ import annotations -import os +from reporails_cli.core.platform.config.bootstrap import env_text DEFAULT_PLATFORM_URL = "https://reporails.com" +# The diagnostics server used when `AILS_SERVER_URL` is unset. +DEFAULT_SERVER_URL = "https://api.reporails.com" + + +def _address(var_name: str, default: str) -> str: + """The env var's address stripped of spaces and a trailing slash, else *default*.""" + return env_text(var_name).rstrip("/") or default def platform_url() -> str: """The website address without a trailing slash: `AILS_PLATFORM_URL` when set, else the default.""" - return os.environ.get("AILS_PLATFORM_URL", "").strip().rstrip("/") or DEFAULT_PLATFORM_URL + return _address("AILS_PLATFORM_URL", DEFAULT_PLATFORM_URL) + + +def server_url() -> str: + """The diagnostics server address without a trailing slash: `AILS_SERVER_URL` when set, else the default.""" + return _address("AILS_SERVER_URL", DEFAULT_SERVER_URL) diff --git a/src/reporails_cli/interfaces/cli/check_support.py b/src/reporails_cli/interfaces/cli/check_support.py index e74e3c9..a338ff1 100644 --- a/src/reporails_cli/interfaces/cli/check_support.py +++ b/src/reporails_cli/interfaces/cli/check_support.py @@ -6,7 +6,6 @@ from __future__ import annotations import logging -import os import signal import sys @@ -15,6 +14,7 @@ from reporails_cli.core.heal.apply import heal_signed_in from reporails_cli.core.mapper.model_manifest import ModelFetchError from reporails_cli.core.mapper.model_store import ensure_models_available +from reporails_cli.core.platform.config.bootstrap import env_int from reporails_cli.core.platform.dto.diagnostics import FunnelError from reporails_cli.interfaces.cli.helpers import console @@ -82,13 +82,8 @@ def _resume_check_timeout(remaining: float) -> None: def _check_timeout_ceiling() -> int: """Wall-clock ceiling (seconds) for a single `ails check`; 0 disables. Default 600.""" - raw = os.environ.get("AILS_CHECK_TIMEOUT_S", "").strip() - if not raw: - return 600 - try: - return int(raw) - except ValueError: - return 600 + timeout = env_int("AILS_CHECK_TIMEOUT_S") + return 600 if timeout is None else timeout def _arm_check_timeout() -> None: diff --git a/src/reporails_cli/interfaces/mcp/scan_fingerprint.py b/src/reporails_cli/interfaces/mcp/scan_fingerprint.py index 685648e..d056169 100644 --- a/src/reporails_cli/interfaces/mcp/scan_fingerprint.py +++ b/src/reporails_cli/interfaces/mcp/scan_fingerprint.py @@ -3,12 +3,11 @@ from __future__ import annotations import hashlib -import os from collections.abc import Iterable from pathlib import Path from reporails_cli.core.discovery.agents import clear_agent_cache -from reporails_cli.core.platform.adapters.api_key import DEFAULT_SERVER_URL, has_api_key +from reporails_cli.core.platform.adapters.api_key import has_api_key from reporails_cli.core.platform.config.bootstrap import ( get_global_config_path, get_local_config_path, @@ -16,6 +15,7 @@ ) from reporails_cli.core.platform.config.config import get_global_config from reporails_cli.core.platform.config.credentials import credentials_path +from reporails_cli.core.platform.config.endpoints import server_url from reporails_cli.core.platform.contract.json_value import JsonObject, as_object from reporails_cli.core.platform.dto.ruleset import RulesetMap from reporails_cli.interfaces.mcp.tools import _discover_files @@ -47,8 +47,7 @@ def scan_inputs_base(scan_root: Path) -> str: ] parts = _stat_parts({*scanned, *config_files}) tier = get_global_config().tier - server = os.environ.get("AILS_SERVER_URL", "").strip().rstrip("/") or DEFAULT_SERVER_URL - parts.append(f"signed_in={has_api_key()}:tier={tier}:server={server}") + parts.append(f"signed_in={has_api_key()}:tier={tier}:server={server_url()}") return hashlib.sha256("\n".join(parts).encode()).hexdigest() diff --git a/tests/smoke/test_offline_server.py b/tests/smoke/test_offline_server.py index c9aac0e..d9f9ff8 100644 --- a/tests/smoke/test_offline_server.py +++ b/tests/smoke/test_offline_server.py @@ -7,7 +7,7 @@ import pytest from reporails_cli.core.platform.adapters.api_client import AilsClient -from reporails_cli.core.platform.adapters.api_key import DEFAULT_SERVER_URL +from reporails_cli.core.platform.config.endpoints import DEFAULT_SERVER_URL @pytest.mark.e2e diff --git a/tests/unit/test_api_client_server_url.py b/tests/unit/test_api_client_server_url.py index 6e588ef..1951540 100644 --- a/tests/unit/test_api_client_server_url.py +++ b/tests/unit/test_api_client_server_url.py @@ -11,7 +11,7 @@ import pytest from reporails_cli.core.platform.adapters.api_client import AilsClient -from reporails_cli.core.platform.adapters.api_key import DEFAULT_SERVER_URL +from reporails_cli.core.platform.config.endpoints import DEFAULT_SERVER_URL class TestServerUrlEnvOverride: diff --git a/tests/unit/test_env_readers.py b/tests/unit/test_env_readers.py new file mode 100644 index 0000000..38dac57 --- /dev/null +++ b/tests/unit/test_env_readers.py @@ -0,0 +1,121 @@ +"""The config layer's environment readers: what each returns for unset, blank and padded values.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pytest + +from reporails_cli.core.platform.config.bootstrap import ( + env_int, + env_text, + parse_idle_timeout_env, + running_in_github_actions, +) +from reporails_cli.core.platform.config.credentials import env_tier +from reporails_cli.core.platform.config.endpoints import ( + DEFAULT_PLATFORM_URL, + DEFAULT_SERVER_URL, + platform_url, + server_url, +) + +_VAR = "AILS_TEST_ENV_READER" + + +@pytest.mark.unit +@pytest.mark.subsys_api +@pytest.mark.parametrize( + ("var", "reader", "default"), + [ + ("AILS_SERVER_URL", server_url, DEFAULT_SERVER_URL), + ("AILS_PLATFORM_URL", platform_url, DEFAULT_PLATFORM_URL), + ], +) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, None), + ("", None), + (" ", None), + ("/", None), + ("http://localhost:8001/", "http://localhost:8001"), + (" http://localhost:8001 ", "http://localhost:8001"), + ("http://localhost:8001", "http://localhost:8001"), + ], +) +def test_address_readers_are_stripped_without_trailing_slash_else_default( + monkeypatch: pytest.MonkeyPatch, + var: str, + reader: Callable[[], str], + default: str, + value: str | None, + expected: str | None, +) -> None: + if value is None: + monkeypatch.delenv(var, raising=False) + else: + monkeypatch.setenv(var, value) + assert reader() == (expected or default) + + +@pytest.mark.unit +@pytest.mark.subsys_api +@pytest.mark.parametrize(("value", "expected"), [(None, ""), ("", ""), (" ", ""), (" team ", "team")]) +def test_env_text_and_env_tier_strip_and_default_to_empty( + monkeypatch: pytest.MonkeyPatch, value: str | None, expected: str +) -> None: + for name in (_VAR, "AILS_TIER"): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + assert env_text(_VAR) == expected + assert env_tier() == expected + + +@pytest.mark.unit +@pytest.mark.subsys_api +@pytest.mark.parametrize( + ("value", "expected"), + [(None, None), ("", None), (" ", None), ("abc", None), ("1.5", None), ("4", 4), (" 7 ", 7), ("0", 0), ("-3", -3)], +) +def test_env_int_is_none_unless_an_integer( + monkeypatch: pytest.MonkeyPatch, value: str | None, expected: int | None +) -> None: + if value is None: + monkeypatch.delenv(_VAR, raising=False) + else: + monkeypatch.setenv(_VAR, value) + assert env_int(_VAR) == expected + + +@pytest.mark.unit +@pytest.mark.subsys_api +@pytest.mark.parametrize( + ("value", "expected"), + [(None, 30), ("junk", 30), ("5", 5), ("0", None), ("-1", None)], +) +def test_idle_timeout_env_uses_the_shared_integer_parse( + monkeypatch: pytest.MonkeyPatch, value: str | None, expected: int | None +) -> None: + if value is None: + monkeypatch.delenv(_VAR, raising=False) + else: + monkeypatch.setenv(_VAR, value) + assert parse_idle_timeout_env(_VAR, 30) == expected + + +@pytest.mark.unit +@pytest.mark.subsys_api +@pytest.mark.parametrize( + ("value", "expected"), [(None, False), ("", False), ("false", False), ("True", False), ("true", True)] +) +def test_running_in_github_actions_only_for_exactly_true( + monkeypatch: pytest.MonkeyPatch, value: str | None, expected: bool +) -> None: + if value is None: + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + else: + monkeypatch.setenv("GITHUB_ACTIONS", value) + assert running_in_github_actions() is expected diff --git a/tests/unit/test_notices_text.py b/tests/unit/test_notices_text.py index 57f16c5..b69d26a 100644 --- a/tests/unit/test_notices_text.py +++ b/tests/unit/test_notices_text.py @@ -55,7 +55,7 @@ def _colour(notices: list[Notice], width: int = 100, *, ascii_mode: bool = False _ANSI = re.compile(r"\x1b\[[0-9;]*m") -_BG = "103" # bright_yellow background in the standard palette +_BG = re.compile(r"\x1b\[(?:\d+;)*103(?:;\d+)*m") # bright_yellow background in the standard palette @pytest.mark.unit @@ -63,7 +63,7 @@ def _colour(notices: list[Notice], width: int = 100, *, ascii_mode: bool = False def test_a_long_warning_is_a_block_of_equal_width_lines_on_yellow() -> None: lines = _colour([Notice("a", "warn", "Your payment failed and the plan ends soon. " * 5)]) assert len(lines) > 1 - assert all(_BG in line for line in lines) + assert all(_BG.search(line) for line in lines) assert len({len(_ANSI.sub("", line)) for line in lines}) == 1 assert all(_ANSI.sub("", line).startswith(" ") for line in lines) @@ -72,9 +72,9 @@ def test_a_long_warning_is_a_block_of_equal_width_lines_on_yellow() -> None: @pytest.mark.subsys_cli_ux def test_the_link_under_a_warning_has_no_background() -> None: lines = _colour([Notice("a", "warn", "Pay now", "https://example.test/pay")]) - assert _BG in lines[0] + assert _BG.search(lines[0]) assert "https://example.test/pay" in lines[1] - assert _BG not in lines[1] + assert not _BG.search(lines[1]) @pytest.mark.unit @@ -83,7 +83,7 @@ def test_a_warning_without_colour_or_in_ascii_mode_has_no_background() -> None: plain = _printed([Notice("a", "warn", "Pay now")]) assert plain == " Pay now\n" ascii_lines = _colour([Notice("a", "warn", "Pay now")], ascii_mode=True) - assert all(_BG not in line for line in ascii_lines) + assert not any(_BG.search(line) for line in ascii_lines) assert "Pay now" in "".join(ascii_lines) diff --git a/tests/unit/test_tier_refresh.py b/tests/unit/test_tier_refresh.py index 3f16ce5..19d4a3e 100644 --- a/tests/unit/test_tier_refresh.py +++ b/tests/unit/test_tier_refresh.py @@ -13,8 +13,8 @@ import pytest import yaml -from reporails_cli.core.platform.adapters.api_key import DEFAULT_SERVER_URL from reporails_cli.core.platform.config.credentials import effective_tier +from reporails_cli.core.platform.config.endpoints import DEFAULT_SERVER_URL from reporails_cli.core.platform.dto.diagnostics import FunnelError from tests.conftest import ails_client from tests.unit.test_api_client import _make_map, _payload_with_file From e975c162e5f4e703649c0c68fc714bab1c916321 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 17:20:34 +0200 Subject: [PATCH 19/31] cli: programs start through one runner and environment variables are read in one folder, with floors; ails update stops when uv cannot run; on Windows an ails in uv's cache no longer counts as installed and an empty PATHEXT no longer hides one --- UNRELEASED.md | 5 +- .../core/platform/config/bootstrap.py | 12 ++ src/reporails_cli/interfaces/cli/commands.py | 8 +- src/reporails_cli/interfaces/cli/helpers.py | 41 ++++- src/reporails_cli/interfaces/cli/install.py | 102 ++++++----- .../interfaces/cli/login_flow.py | 25 +-- tests/suite_env.py | 2 +- .../architecture/test_env_reads_in_config.py | 153 +++++++++++++++++ .../architecture/test_one_process_runner.py | 161 ++++++++++++++++++ tests/unit/test_install_command.py | 11 +- tests/unit/test_npm_install_method.py | 31 +++- 11 files changed, 459 insertions(+), 92 deletions(-) create mode 100644 tests/unit/architecture/test_env_reads_in_config.py create mode 100644 tests/unit/architecture/test_one_process_runner.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 5f9ceb2..eacf81b 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -14,9 +14,10 @@ ### Fixed -- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. An `ails` in the current folder that is not on PATH no longer counts as installed. -- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. - `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. +- On Windows, `ails install` no longer counts an `ails` in the current folder or in uv's cache as installed, and an empty PATHEXT no longer hides an installed `ails`. +- `ails update` reports a uv run that cannot start or times out, instead of crashing. +- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. - A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. diff --git a/src/reporails_cli/core/platform/config/bootstrap.py b/src/reporails_cli/core/platform/config/bootstrap.py index 1cef005..f485517 100644 --- a/src/reporails_cli/core/platform/config/bootstrap.py +++ b/src/reporails_cli/core/platform/config/bootstrap.py @@ -3,6 +3,7 @@ from __future__ import annotations import os +from collections.abc import Iterable from functools import cache from pathlib import Path @@ -30,6 +31,17 @@ def running_in_github_actions() -> bool: return os.environ.get("GITHUB_ACTIONS") == "true" +def running_in_ci() -> bool: + """True when the process runs under a CI system.""" + return running_in_github_actions() or any(env_text(var) for var in ("CI", "GITLAB_CI", "JENKINS_URL", "CIRCLECI")) + + +def environment_without(names: Iterable[str]) -> dict[str, str]: + """The process environment minus the named variables.""" + dropped = set(names) + return {key: value for key, value in os.environ.items() if key not in dropped} + + def env_flag(var_name: str) -> bool: """True when the env var is set to anything but blank, 0, false, no or off (case and spaces ignored).""" return env_text(var_name).lower() not in ("", "0", "false", "no", "off") diff --git a/src/reporails_cli/interfaces/cli/commands.py b/src/reporails_cli/interfaces/cli/commands.py index dabc9a7..bc489aa 100644 --- a/src/reporails_cli/interfaces/cli/commands.py +++ b/src/reporails_cli/interfaces/cli/commands.py @@ -57,6 +57,9 @@ def _update_engine() -> bool: result = run_uv_tool(uv, "upgrade", "reporails-cli") + if result is None: + console.print("[red]Update failed:[/red] uv could not be run or did not finish in time.") + return False if result.returncode == 0: output = result.stdout.strip() or result.stderr.strip() if "already up to date" in output.lower() or "nothing to upgrade" in output.lower(): @@ -67,10 +70,11 @@ def _update_engine() -> bool: # Maybe not installed as a tool yet — install instead result2 = run_uv_tool(uv, "install", "reporails-cli", "--force") - if result2.returncode == 0: + if result2 is not None and result2.returncode == 0: console.print("[green]Installed latest version.[/green]") return True - console.print(f"[red]Update failed:[/red] {result2.stderr.strip()}") + detail = result2.stderr.strip() if result2 is not None else "uv could not be run or did not finish in time." + console.print(f"[red]Update failed:[/red] {detail}") return False diff --git a/src/reporails_cli/interfaces/cli/helpers.py b/src/reporails_cli/interfaces/cli/helpers.py index e50c3b7..c09e767 100644 --- a/src/reporails_cli/interfaces/cli/helpers.py +++ b/src/reporails_cli/interfaces/cli/helpers.py @@ -3,7 +3,6 @@ from __future__ import annotations import logging -import os import subprocess import sys from pathlib import Path @@ -13,6 +12,7 @@ from rich.console import Console from reporails_cli.core.discovery.agents import get_known_agents +from reporails_cli.core.platform.config.bootstrap import running_in_ci from reporails_cli.core.platform.dto.models import Level from reporails_cli.core.platform.policy.levels import LEVEL_LABELS @@ -26,15 +26,38 @@ console = Console(emoji=False, highlight=False) -def run_uv_tool(uv: str, *args: str) -> subprocess.CompletedProcess[str]: - """Run `uv tool ` and capture its output.""" - return subprocess.run([uv, "tool", *args], capture_output=True, text=True, timeout=120, check=False) +type ProgramRun = subprocess.CompletedProcess[str] -def _is_ci() -> bool: - """Check if running in CI environment.""" - ci_vars = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "CIRCLECI") - return any(os.environ.get(var) for var in ci_vars) +def run_program( + cmd: list[str], *, timeout: float, env: dict[str, str] | None = None, cwd: Path | None = None +) -> ProgramRun | None: + """Run a program and capture its text output; None when it cannot start or runs out of time.""" + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, check=False, env=env, cwd=cwd) + except (OSError, subprocess.TimeoutExpired) as exc: + logger.warning("%s failed: %s", cmd[0], exc) + return None + + +def run_uv_tool(uv: str, *args: str) -> ProgramRun | None: + """Run `uv tool ` and capture its output; None when uv cannot start or runs out of time.""" + return run_program([uv, "tool", *args], timeout=120) + + +def launch_detached(cmd: list[str]) -> None: + """Start `cmd` as a detached child that holds none of the caller's streams and outlives this call.""" + creationflags = 0 + if sys.platform == "win32": + creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW + subprocess.Popen( # pylint: disable=consider-using-with # detached browser launcher outlives this call + cmd, + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=sys.platform != "win32", + creationflags=creationflags, + ) def _warn_unresolved_skills(unresolved: list[UnresolvedSkill], project_root: Path) -> None: @@ -58,7 +81,7 @@ def _default_format() -> str: routes. Returning an undispatched value here silently fell through to the same renderer while promising a formatter that no longer runs. """ - if _is_ci(): + if running_in_ci(): return "json" return "text" diff --git a/src/reporails_cli/interfaces/cli/install.py b/src/reporails_cli/interfaces/cli/install.py index db973a7..4038fb8 100644 --- a/src/reporails_cli/interfaces/cli/install.py +++ b/src/reporails_cli/interfaces/cli/install.py @@ -2,23 +2,22 @@ from __future__ import annotations -import functools import json import logging import os import re import shutil -import subprocess -from collections.abc import Callable +from itertools import pairwise from pathlib import Path, PureWindowsPath import typer +from reporails_cli.core.platform.config.bootstrap import env_text, environment_without from reporails_cli.core.platform.config.credentials import effective_tier from reporails_cli.core.platform.dto.diagnostics import UNENTITLED_TIERS from reporails_cli.core.platform.utils.utils import is_rooted_pattern from reporails_cli.core.platform.utils.version import UNKNOWN_VERSION, installed_version -from reporails_cli.interfaces.cli.helpers import console, run_uv_tool +from reporails_cli.interfaces.cli.helpers import ProgramRun, console, run_program, run_uv_tool from reporails_cli.interfaces.cli.typer_app import app logger = logging.getLogger(__name__) @@ -30,6 +29,7 @@ _MARKETPLACE = "reporails" _STEP_TIMEOUT = 120 _WARM_TIMEOUT = 300 +_DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD" # The reporails plugin is a portable Agent Plugins package that carries the # `ails` skill and the `reporails` MCP server. Claude Code and Codex install it @@ -59,41 +59,50 @@ def _uv_cache_dir(uv: str) -> str | None: """uv's cache folder as uv reports it; None when uv cannot say.""" - try: - result = subprocess.run([uv, "cache", "dir"], capture_output=True, text=True, timeout=10, check=False) - except (OSError, subprocess.TimeoutExpired): + result = run_program([uv, "cache", "dir"], timeout=10) + if result is None or result.returncode != 0: return None - return result.stdout.strip() or None if result.returncode == 0 else None + return result.stdout.strip() or None + + +def _in_default_uv_cache(ails_path: str) -> bool: + """Whether the path sits in one of uv's default cache folders: `~/.cache/uv` or `%LOCALAPPDATA%\\uv\\cache`.""" + parts = [part.lower() for part in PureWindowsPath(ails_path).parts] + pairs = list(pairwise(parts)) + return (".cache", "uv") in pairs or ("uv", "cache") in pairs -def _is_temporary_ails(ails_path: str, cache: Callable[[], str | None]) -> bool: +def _under_node_modules(ails_path: str) -> bool: + return "node_modules" in PureWindowsPath(ails_path).parts + + +def _is_temporary_ails(ails_path: str, cache_dir: str | None) -> bool: """True for an `ails` inside a `node_modules` folder or in uv's cache. - That is a one-shot or project copy, not a PATH install. `cache` is a getter for uv's - cache folder, called only when the `node_modules` rule does not decide. + That is a one-shot or project copy, not a PATH install. `cache_dir` is uv's cache folder; without + it, uv's default cache folders count. """ - if "node_modules" in PureWindowsPath(ails_path).parts: + if _under_node_modules(ails_path): return True - cache_dir = cache() if cache_dir: return Path(os.path.realpath(ails_path)).is_relative_to(os.path.realpath(cache_dir)) - return "/.cache/uv/" in ails_path + return _in_default_uv_cache(ails_path) def _ails_names(windows: bool, pathext: str) -> list[str]: """File names that count as the `ails` program in one folder. - Windows runs only a name with a PATHEXT extension, so there is no bare `ails` there. + Windows runs only a name with a PATHEXT extension, so there is no bare `ails` there. An unset or + empty PATHEXT means `.COM;.EXE;.BAT;.CMD`. """ if windows: - return [f"ails{ext}" for ext in pathext.split(";") if ext] + return [f"ails{ext}" for ext in (pathext or _DEFAULT_PATHEXT).split(";") if ext] return ["ails"] def _ails_in_folder(entry: str) -> str | None: """The `ails` executable directly inside PATH folder `entry`, or None.""" - pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD") - for name in _ails_names(os.name == "nt", pathext): + for name in _ails_names(os.name == "nt", env_text("PATHEXT")): candidate = os.path.join(entry, name) if os.path.isfile(candidate) and os.access(candidate, os.X_OK): return candidate @@ -103,12 +112,12 @@ def _ails_in_folder(entry: str) -> str | None: def _install_to_path() -> bool: """Install ails to PATH via uv tool install. Returns True on success.""" uv = shutil.which("uv") - cache = functools.cache(lambda: _uv_cache_dir(uv) if uv else None) - # Already on PATH as a real install (not uvx one-shot)? - entries = [entry for entry in os.environ.get("PATH", "").split(os.pathsep) if entry] - hits = (_ails_in_folder(entry) for entry in entries) - ails_path = next((hit for hit in hits if hit and not _is_temporary_ails(hit, cache)), None) + # Already on PATH as a real install (not a uvx or node_modules one-shot)? + entries = [entry for entry in env_text("PATH").split(os.pathsep) if entry] + copies = [hit for hit in (_ails_in_folder(entry) for entry in entries) if hit] + cache_dir = _uv_cache_dir(uv) if uv and any(not _under_node_modules(copy) for copy in copies) else None + ails_path = next((copy for copy in copies if not _is_temporary_ails(copy, cache_dir)), None) if ails_path: console.print(f" [dim]ails already on PATH: {ails_path}[/dim]") return True @@ -124,19 +133,17 @@ def _install_to_path() -> bool: version = installed_version() spec = "reporails-cli" if version == UNKNOWN_VERSION else f"reporails-cli>={version}" console.print(" Installing ails to PATH...") - try: - result = run_uv_tool(uv, "install", spec, "--force") - if result.returncode == 0: - console.print(" [green]ails installed to PATH[/green]") - return True - logger.warning("uv tool install failed: %s", result.stderr.strip()) - console.print(f" [yellow]PATH install failed: {result.stderr.strip()}[/yellow]") - console.print(" [dim]You can still use: npx @reporails/cli check[/dim]") - return False - except (OSError, subprocess.TimeoutExpired) as exc: - logger.warning("uv tool install error: %s", exc) + result = run_uv_tool(uv, "install", spec, "--force") + if result is None: console.print(" [yellow]PATH install failed — use npx @reporails/cli instead[/yellow]") return False + if result.returncode == 0: + console.print(" [green]ails installed to PATH[/green]") + return True + logger.warning("uv tool install failed: %s", result.stderr.strip()) + console.print(f" [yellow]PATH install failed: {result.stderr.strip()}[/yellow]") + console.print(" [dim]You can still use: npx @reporails/cli check[/dim]") + return False def _is_local_path(source: str) -> bool: @@ -149,7 +156,7 @@ def _plugin_source() -> str: A path is made absolute so every command resolves it to the same folder. """ - value = os.environ.get(_PLUGIN_SOURCE_ENV, "").strip() or _PLUGIN_REPO + value = env_text(_PLUGIN_SOURCE_ENV) or _PLUGIN_REPO if _is_local_path(value): return os.path.abspath(os.path.expanduser(value)) return value @@ -171,7 +178,7 @@ def _git_root(start: Path) -> Path: def _codex_home() -> Path: - return Path(os.environ.get("CODEX_HOME") or "~/.codex").expanduser().resolve() + return Path(env_text("CODEX_HOME") or "~/.codex").expanduser().resolve() def _engine_spec(version: str) -> str: @@ -186,14 +193,9 @@ def _engine_spec(version: str) -> str: return f"reporails-cli>={major}.{minor}.{patch},<{major}.{minor + 1}" -def _run(cmd: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str] | None: - """Run one agent command without the agent-session variables; None on OSError or timeout.""" - env = {k: v for k, v in os.environ.items() if k not in _SESSION_ENV} - try: - return subprocess.run(cmd, capture_output=True, text=True, timeout=_STEP_TIMEOUT, check=False, env=env, cwd=cwd) - except (OSError, subprocess.TimeoutExpired) as exc: - logger.warning("%s failed: %s", cmd[0], exc) - return None +def _run(cmd: list[str], cwd: Path | None = None) -> ProgramRun | None: + """Run one agent command without the agent-session variables; None when it cannot start or runs out of time.""" + return run_program(cmd, timeout=_STEP_TIMEOUT, env=environment_without(_SESSION_ENV), cwd=cwd) def _normalize_source(value: str) -> str: @@ -442,16 +444,8 @@ def _warm_engine() -> None: return spec = _engine_spec(installed_version()) console.print(" Preparing the plugin's engine...") - try: - result = subprocess.run( - [uvx, "--from", spec, "ails", "--version"], - capture_output=True, - text=True, - timeout=_WARM_TIMEOUT, - check=False, - ) - except (OSError, subprocess.TimeoutExpired) as exc: - logger.warning("engine warm-up failed: %s", exc) + result = run_program([uvx, "--from", spec, "ails", "--version"], timeout=_WARM_TIMEOUT) + if result is None: return if result.returncode != 0: logger.warning("engine warm-up failed: %s", result.stderr.strip()) diff --git a/src/reporails_cli/interfaces/cli/login_flow.py b/src/reporails_cli/interfaces/cli/login_flow.py index c772730..62b9906 100644 --- a/src/reporails_cli/interfaces/cli/login_flow.py +++ b/src/reporails_cli/interfaces/cli/login_flow.py @@ -7,9 +7,7 @@ import logging import math -import os import socket -import subprocess import sys import time from datetime import UTC, datetime @@ -21,6 +19,7 @@ from reporails_cli.core.platform.adapters.key_check import check_api_key from reporails_cli.core.platform.adapters.notices_seen import due_notices, visible_notices from reporails_cli.core.platform.adapters.sign_in import poll_sign_in, revoke_sign_in, start_sign_in +from reporails_cli.core.platform.config.bootstrap import env_text, running_in_ci from reporails_cli.core.platform.config.credentials import ( clear_credentials, credentials_path, @@ -37,7 +36,7 @@ from reporails_cli.core.platform.dto.sign_in import SignedIn, SignInGrant from reporails_cli.formatters.text.funnel_cta import OTHER_AGENT_HEAL_TEXT, UNPAID_PITCH_LINE, upgrade_link_line from reporails_cli.formatters.text.notices import print_notices -from reporails_cli.interfaces.cli.helpers import _is_ci, console +from reporails_cli.interfaces.cli.helpers import console, launch_detached logger = logging.getLogger(__name__) @@ -148,30 +147,20 @@ def _can_show_browser() -> bool: Never in CI. Otherwise a non-blank `BROWSER` setting says how to open one. Without it, macOS and Windows can unless the session is over SSH, and every other system needs `DISPLAY` or `WAYLAND_DISPLAY`. """ - if _is_ci(): + if running_in_ci(): return False - if (os.environ.get("BROWSER") or "").strip(): + if env_text("BROWSER"): return True if sys.platform == "darwin" or sys.platform.startswith("win"): - return not any(os.environ.get(name) for name in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")) - return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) + return not any(env_text(name) for name in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")) + return bool(env_text("DISPLAY") or env_text("WAYLAND_DISPLAY")) def _open_in_browser(url: str) -> None: """Open `url` from a detached child that ignores the working directory and holds none of the caller's streams.""" if not sys.executable: return - creationflags = 0 - if sys.platform == "win32": - creationflags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.CREATE_NO_WINDOW - subprocess.Popen( # pylint: disable=consider-using-with # detached browser launcher outlives this call - [sys.executable, "-I", "-m", "webbrowser", "-t", url], - stdin=subprocess.DEVNULL, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - start_new_session=sys.platform != "win32", - creationflags=creationflags, - ) + launch_detached([sys.executable, "-I", "-m", "webbrowser", "-t", url]) def _is_the_websites_link(url: str, site: str) -> bool: diff --git a/tests/suite_env.py b/tests/suite_env.py index d7d8571..10f325a 100644 --- a/tests/suite_env.py +++ b/tests/suite_env.py @@ -9,7 +9,7 @@ from collections.abc import Mapping -CI_ENV_VARS = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "CIRCLECI") # mirrors helpers._is_ci +CI_ENV_VARS = ("CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS_URL", "CIRCLECI") # mirrors running_in_ci # Tests never download the model set, and never reach a hosted service: a closed local port # fails fast, so a check runs its offline path. diff --git a/tests/unit/architecture/test_env_reads_in_config.py b/tests/unit/architecture/test_env_reads_in_config.py new file mode 100644 index 0000000..09c20a7 --- /dev/null +++ b/tests/unit/architecture/test_env_reads_in_config.py @@ -0,0 +1,153 @@ +"""Environment variables are read only under `core/platform/config/`. + +One rule, pure `ast`, over every `src/reporails_cli/**/*.py` outside `core/platform/config/`: + +- E1 env read: a reference to `os.getenv`, `os.getenvb`, `os.environ` or `os.environb` (through + `import os`, `import os as x` or an attribute chain on either), or `from os import environ`, + `environb`, `getenv`, `getenvb`. `.get`, `.pop`, `.setdefault`, `.items`, `in os.environ`, + `os.environ[...]` loads and passing `os.environ` along all count. The one exception is a write: + `os.environ[...] = value` and `del os.environ[...]` set or remove a variable and read nothing. + +Why: a variable read in many places is trimmed, defaulted and validated many ways. One folder owns +the reads, so each variable has one meaning. + +The scanner takes the source files and their root, so the planted tests below run the same code on +tmp files. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.architecture + +ROOT = Path(__file__).resolve().parent.parent.parent.parent +SRC = ROOT / "src" / "reporails_cli" +CONFIG_DIR = "core/platform/config/" + +_READ_FUNCS = {"getenv", "getenvb"} +_ENVIRON = {"environ", "environb"} + + +@dataclass(frozen=True) +class Violation: + """One floor violation: the rule and a human line.""" + + rule: str + detail: str + + +def _os_names(tree: ast.Module) -> set[str]: + """The local names the `os` module is bound to: `import os`, `import os as x`, `import os.path`.""" + names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Import): + continue + for alias in node.names: + if alias.name == "os": + names.add(alias.asname or "os") + elif alias.name.startswith("os.") and not alias.asname: + names.add("os") + return names + + +def _is_write_target(node: ast.Attribute, parents: dict[ast.AST, ast.AST]) -> bool: + """True when `node` is the object of a subscript store or delete (`os.environ["X"] = "1"`).""" + parent = parents.get(node) + return isinstance(parent, ast.Subscript) and parent.value is node and isinstance(parent.ctx, (ast.Store, ast.Del)) + + +def _reads_env(node: ast.AST, modules: set[str], parents: dict[ast.AST, ast.AST]) -> bool: + """True for `os.getenv` / `os.environ` (any alias of `os`) used as anything but a write target.""" + if not (isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) and node.value.id in modules): + return False + return node.attr in _READ_FUNCS or (node.attr in _ENVIRON and not _is_write_target(node, parents)) + + +def env_read_violations(sources: list[Path], root: Path) -> list[Violation]: + """E1: an environment read outside `core/platform/config/`.""" + out: list[Violation] = [] + for py in sources: + rel = py.relative_to(root).as_posix() + if rel.startswith(CONFIG_DIR): + continue + tree = ast.parse(py.read_text(encoding="utf-8")) + parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)} + modules = _os_names(tree) + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module == "os": + out.extend( + Violation("E1", f"{rel}:{node.lineno} imports os.{alias.name}") + for alias in node.names + if alias.name in _READ_FUNCS | _ENVIRON + ) + elif _reads_env(node, modules, parents): + out.append(Violation("E1", f"{rel}:{node.lineno} reads os.{getattr(node, 'attr', '')}")) + return out + + +def _src_files() -> list[Path]: + return [p for p in sorted(SRC.rglob("*.py")) if "__pycache__" not in p.parts] + + +def test_environment_is_read_only_under_the_config_folder() -> None: + """E1: no env read in src outside `core/platform/config/`.""" + found = env_read_violations(_src_files(), SRC) + lines = [f"{len(found)} E1 violation(s): read the variable through core/platform/config/bootstrap.py"] + lines.extend(f" {v.detail}" for v in found) + assert not found, "\n".join(lines) + + +# ---- planted cases ------------------------------------------------------------------------- + + +def _plant(tmp_path: Path, files: dict[str, str]) -> list[Path]: + paths = [] + for name, text in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + paths.append(path) + return paths + + +@pytest.mark.parametrize( + ("body", "detail"), + [ + ("import os\nx = os.getenv('A')\n", "mod.py:2 reads os.getenv"), + ("import os\nx = os.getenvb(b'A')\n", "mod.py:2 reads os.getenvb"), + ("import os\nx = os.environ.get('A')\n", "mod.py:2 reads os.environ"), + ("import os\nx = os.environ['A']\n", "mod.py:2 reads os.environ"), + ("import os\nx = 'A' in os.environ\n", "mod.py:2 reads os.environ"), + ("import os\nx = os.environ.pop('A')\n", "mod.py:2 reads os.environ"), + ("import os\nx = dict(os.environ)\n", "mod.py:2 reads os.environ"), + ("import os\nx = os.environb\n", "mod.py:2 reads os.environb"), + ("from os import environ\n", "mod.py:1 imports os.environ"), + ("from os import getenv as g\n", "mod.py:1 imports os.getenv"), + ("import os as x\ny = x.environ\n", "mod.py:2 reads os.environ"), + ("import os.path\ny = os.getenv('A')\n", "mod.py:2 reads os.getenv"), + ], +) +def test_e1_env_read_fires(tmp_path: Path, body: str, detail: str) -> None: + """Each way of reading the environment is flagged with its site.""" + found = env_read_violations(_plant(tmp_path, {"mod.py": body}), tmp_path) + assert [v.detail for v in found] == [detail] + + +@pytest.mark.parametrize( + ("name", "body"), + [ + ("mod.py", "import os\nos.environ['A'] = '1'\n"), + ("mod.py", "import os\ndel os.environ['A']\n"), + ("mod.py", "import os\nx = os.path.join('a', 'b')\nos.fork\n"), + ("mod.py", "environ = {}\nx = environ.get('A')\n"), + ("core/platform/config/mod.py", "import os\nx = os.environ.get('A')\ny = os.getenv('B')\n"), + ], +) +def test_e1_writes_and_the_config_folder_are_quiet(tmp_path: Path, name: str, body: str) -> None: + """A subscript write or delete, an unrelated name and the config folder itself pass.""" + assert env_read_violations(_plant(tmp_path, {name: body}), tmp_path) == [] diff --git a/tests/unit/architecture/test_one_process_runner.py b/tests/unit/architecture/test_one_process_runner.py new file mode 100644 index 0000000..3af1b2e --- /dev/null +++ b/tests/unit/architecture/test_one_process_runner.py @@ -0,0 +1,161 @@ +"""Programs run only through `interfaces/cli/helpers.py`. + +One rule, pure `ast`, over every `src/reporails_cli/**/*.py`: + +- P1 stray process: `import subprocess`, `import subprocess as x` or `from subprocess import ...` in + any module but `interfaces/cli/helpers.py`; and, anywhere in src, a call to `os.system`, + `os.popen`, `os.startfile`, `os.spawn*`, `os.exec*` or `os.posix_spawn*` (through `import os`, + an alias of it, or a name imported from `os`). `os.fork` is allowed: the mapper daemon owns its + own start. + +Why: a program started in many places gets its timeout, error handling and environment many ways. +One runner gives every start the same answer to "it cannot start" and "it ran out of time". + +The scanner takes the source files and their root, so the planted tests below run the same code on +tmp files. +""" + +from __future__ import annotations + +import ast +from dataclasses import dataclass +from pathlib import Path + +import pytest + +pytestmark = pytest.mark.architecture + +ROOT = Path(__file__).resolve().parent.parent.parent.parent +SRC = ROOT / "src" / "reporails_cli" +RUNNER = "interfaces/cli/helpers.py" + +_OS_EXACT = {"system", "popen", "startfile"} +_OS_PREFIXES = ("spawn", "exec", "posix_spawn") + + +@dataclass(frozen=True) +class Violation: + """One floor violation: the rule and a human line.""" + + rule: str + detail: str + + +def _starts_a_program(name: str) -> bool: + return name in _OS_EXACT or name.startswith(_OS_PREFIXES) + + +def _os_names(tree: ast.Module) -> set[str]: + """The local names the `os` module is bound to: `import os`, `import os as x`, `import os.path`.""" + names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Import): + continue + for alias in node.names: + if alias.name == "os": + names.add(alias.asname or "os") + elif alias.name.startswith("os.") and not alias.asname: + names.add("os") + return names + + +def _is_subprocess(module: str | None) -> bool: + return module == "subprocess" or (module or "").startswith("subprocess.") + + +def stray_process_violations(sources: list[Path], root: Path) -> list[Violation]: + """P1: a program started outside the one runner.""" + out: list[Violation] = [] + for py in sources: + rel = py.relative_to(root).as_posix() + tree = ast.parse(py.read_text(encoding="utf-8")) + modules = _os_names(tree) + from_os = { + alias.asname or alias.name: alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module == "os" + for alias in node.names + if _starts_a_program(alias.name) + } + for node in ast.walk(tree): + if rel != RUNNER and isinstance(node, ast.Import): + out.extend( + Violation("P1", f"{rel}:{node.lineno} imports {alias.name}") + for alias in node.names + if _is_subprocess(alias.name) + ) + elif rel != RUNNER and isinstance(node, ast.ImportFrom) and _is_subprocess(node.module): + out.append(Violation("P1", f"{rel}:{node.lineno} imports from {node.module}")) + elif isinstance(node, ast.Call): + func = node.func + if ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id in modules + and _starts_a_program(func.attr) + ): + out.append(Violation("P1", f"{rel}:{node.lineno} calls os.{func.attr}")) + elif isinstance(func, ast.Name) and func.id in from_os: + out.append(Violation("P1", f"{rel}:{node.lineno} calls os.{from_os[func.id]}")) + return out + + +def _src_files() -> list[Path]: + return [p for p in sorted(SRC.rglob("*.py")) if "__pycache__" not in p.parts] + + +def test_programs_run_only_through_the_one_runner() -> None: + """P1: no subprocess import outside helpers.py and no os process call in src.""" + found = stray_process_violations(_src_files(), SRC) + lines = [f"{len(found)} P1 violation(s): run the program through interfaces/cli/helpers.py"] + lines.extend(f" {v.detail}" for v in found) + assert not found, "\n".join(lines) + + +# ---- planted cases ------------------------------------------------------------------------- + + +def _plant(tmp_path: Path, files: dict[str, str]) -> list[Path]: + paths = [] + for name, text in files.items(): + path = tmp_path / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + paths.append(path) + return paths + + +@pytest.mark.parametrize( + ("body", "detail"), + [ + ("import subprocess\n", "mod.py:1 imports subprocess"), + ("import subprocess as sp\n", "mod.py:1 imports subprocess"), + ("from subprocess import run\n", "mod.py:1 imports from subprocess"), + ("import os\nos.system('x')\n", "mod.py:2 calls os.system"), + ("import os\nos.popen('x')\n", "mod.py:2 calls os.popen"), + ("import os\nos.startfile('x')\n", "mod.py:2 calls os.startfile"), + ("import os\nos.spawnl(0, 'x')\n", "mod.py:2 calls os.spawnl"), + ("import os\nos.execv('x', [])\n", "mod.py:2 calls os.execv"), + ("import os\nos.posix_spawn('x', [], {})\n", "mod.py:2 calls os.posix_spawn"), + ("import os as o\no.system('x')\n", "mod.py:2 calls os.system"), + ("from os import system as s\ns('x')\n", "mod.py:2 calls os.system"), + ], +) +def test_p1_stray_process_fires(tmp_path: Path, body: str, detail: str) -> None: + """Each way of starting a program outside the runner is flagged with its site.""" + found = stray_process_violations(_plant(tmp_path, {"mod.py": body}), tmp_path) + assert [v.detail for v in found] == [detail] + + +@pytest.mark.parametrize( + ("name", "body"), + [ + (RUNNER, "import subprocess\nsubprocess.run(['x'])\n"), + ("mod.py", "import os\npid = os.fork()\n"), + ("mod.py", "import os\nx = os.path.join('a')\n"), + ("mod.py", "def system():\n pass\nsystem()\n"), + ], +) +def test_p1_runner_fork_and_unrelated_names_are_quiet(tmp_path: Path, name: str, body: str) -> None: + """The runner's own subprocess import, os.fork and an unrelated `system` pass.""" + assert stray_process_violations(_plant(tmp_path, {name: body}), tmp_path) == [] diff --git a/tests/unit/test_install_command.py b/tests/unit/test_install_command.py index 7835f08..668cc3a 100644 --- a/tests/unit/test_install_command.py +++ b/tests/unit/test_install_command.py @@ -13,6 +13,7 @@ from typer.testing import CliRunner from reporails_cli.core.platform.utils.version import installed_version +from reporails_cli.interfaces.cli import helpers as helpers_module from reporails_cli.interfaces.cli import install as install_module from reporails_cli.interfaces.cli.main import app @@ -59,7 +60,7 @@ def fake_run(cmd: list[str], **kwargs: Any) -> MagicMock: result.stderr = "" return result - monkeypatch.setattr(install_module.subprocess, "run", fake_run) + monkeypatch.setattr(helpers_module.subprocess, "run", fake_run) assert install_module._install_to_path() is True assert len(calls) == 1 @@ -107,7 +108,7 @@ def _setup( ) -> None: monkeypatch.setattr(install_module, "_install_to_path", lambda: True) monkeypatch.setattr(install_module.shutil, "which", lambda cmd: f"/bin/{cmd}" if cmd in present else None) - monkeypatch.setattr(install_module.subprocess, "run", runner) + monkeypatch.setattr(helpers_module.subprocess, "run", runner) monkeypatch.setattr("reporails_cli.core.platform.adapters.api_key.has_api_key", lambda: key) if tmp_path is not None: monkeypatch.chdir(tmp_path) @@ -207,7 +208,7 @@ def boom(cmd: list[str], **kwargs: Any) -> MagicMock: raise next(outcomes, OSError("nope")) _setup(monkeypatch, _Runner(), {"claude", "uvx"}) - monkeypatch.setattr(install_module.subprocess, "run", boom) + monkeypatch.setattr(helpers_module.subprocess, "run", boom) result = _runner_invoke() @@ -270,7 +271,7 @@ def _update_setup(monkeypatch: pytest.MonkeyPatch, runner: _Runner, present: set monkeypatch.setattr(commands_module, "_update_engine", lambda: True) monkeypatch.setattr(install_module.shutil, "which", lambda cmd: f"/bin/{cmd}" if cmd in present else None) - monkeypatch.setattr(install_module.subprocess, "run", runner) + monkeypatch.setattr(helpers_module.subprocess, "run", runner) @pytest.mark.unit @@ -744,7 +745,7 @@ def fake_run(cmd: list[str], **kwargs: Any) -> MagicMock: result.stderr = "" return result - monkeypatch.setattr(install_module.subprocess, "run", fake_run) + monkeypatch.setattr(helpers_module.subprocess, "run", fake_run) return install_module._install_to_path(), calls diff --git a/tests/unit/test_npm_install_method.py b/tests/unit/test_npm_install_method.py index e5fac94..bf6888d 100644 --- a/tests/unit/test_npm_install_method.py +++ b/tests/unit/test_npm_install_method.py @@ -197,7 +197,36 @@ def test_update_prints_hint_and_runs_nothing(monkeypatch, capsys): ("/usr/local/bin/ails", False), ("/home/linuxbrew/.linuxbrew/bin/ails", False), ("C:\\Users\\u\\AppData\\Roaming\\npm\\ails.cmd", False), + ("C:\\Users\\u\\AppData\\Local\\uv\\cache\\archive-v0\\x\\Scripts\\ails.exe", True), + ("/home/u/.cache/uv/archive-v0/x/bin/ails", True), + ("C:\\tools\\ails.exe", False), ], ) def test_node_modules_copy_is_temporary(path, expected): - assert _is_temporary_ails(path, lambda: None) is expected + assert _is_temporary_ails(path, None) is expected + + +@pytest.mark.skipif(os.name == "nt", reason="stub executables are POSIX scripts") +@pytest.mark.parametrize("pathext", [None, ""]) +def test_unset_or_empty_pathext_means_the_default_windows_extensions(tmp_path, monkeypatch, pathext): + exe = tmp_path / "ails.COM" + exe.write_text("", encoding="utf-8") + exe.chmod(0o755) + if pathext is None: + monkeypatch.delenv("PATHEXT", raising=False) + else: + monkeypatch.setenv("PATHEXT", pathext) + with monkeypatch.context() as windows: + windows.setattr(install_module.os, "name", "nt") + found = install_module._ails_in_folder(str(tmp_path)) + assert found == str(exe) + + +def test_update_stops_when_uv_cannot_run(monkeypatch): + monkeypatch.delenv("AILS_LAUNCHER", raising=False) + monkeypatch.setattr(commands_module, "npm_update_hint", lambda method: None) + monkeypatch.setattr(commands_module.shutil, "which", lambda cmd: "/fake/uv") + calls: list[tuple[object, ...]] = [] + monkeypatch.setattr(commands_module, "run_uv_tool", lambda *a: calls.append(a)) + assert commands_module._update_engine() is False + assert len(calls) == 1 From 0013217a5d69df206d8f89d32a9f72c4a2eccec0 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 17:53:10 +0200 Subject: [PATCH 20/31] tests: the process floor also catches webbrowser, pty and asyncio subprocess starts, and the env floor counts an in-place os.environ edit or method write as a read --- UNRELEASED.md | 1 + tests/unit/architecture/_os_aliases.py | 19 ++++++ .../architecture/test_env_reads_in_config.py | 29 +++++----- .../architecture/test_one_process_runner.py | 58 +++++++++++++------ 4 files changed, 72 insertions(+), 35 deletions(-) create mode 100644 tests/unit/architecture/_os_aliases.py diff --git a/UNRELEASED.md b/UNRELEASED.md index eacf81b..346db89 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -28,3 +28,4 @@ ### Internal - A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. +- Build checks fail when the environment is read, or a program is started, outside the one place that owns it. diff --git a/tests/unit/architecture/_os_aliases.py b/tests/unit/architecture/_os_aliases.py new file mode 100644 index 0000000..3d5aaee --- /dev/null +++ b/tests/unit/architecture/_os_aliases.py @@ -0,0 +1,19 @@ +"""Shared by the env-read and process-runner floors: the local names the `os` module is bound to.""" + +from __future__ import annotations + +import ast + + +def os_names(tree: ast.Module) -> set[str]: + """The local names the `os` module is bound to: `import os`, `import os as x`, `import os.path`.""" + names: set[str] = set() + for node in ast.walk(tree): + if not isinstance(node, ast.Import): + continue + for alias in node.names: + if alias.name == "os": + names.add(alias.asname or "os") + elif alias.name.startswith("os.") and not alias.asname: + names.add("os") + return names diff --git a/tests/unit/architecture/test_env_reads_in_config.py b/tests/unit/architecture/test_env_reads_in_config.py index 09c20a7..278b507 100644 --- a/tests/unit/architecture/test_env_reads_in_config.py +++ b/tests/unit/architecture/test_env_reads_in_config.py @@ -7,6 +7,8 @@ `environb`, `getenv`, `getenvb`. `.get`, `.pop`, `.setdefault`, `.items`, `in os.environ`, `os.environ[...]` loads and passing `os.environ` along all count. The one exception is a write: `os.environ[...] = value` and `del os.environ[...]` set or remove a variable and read nothing. + That is a write through a subscript store or delete; a method write (`.update`, `.clear`, `|=`) and + `os.environ[...] += ...` count as a read. Why: a variable read in many places is trimmed, defaulted and validated many ways. One folder owns the reads, so each variable has one meaning. @@ -23,6 +25,8 @@ import pytest +from tests.unit.architecture._os_aliases import os_names + pytestmark = pytest.mark.architecture ROOT = Path(__file__).resolve().parent.parent.parent.parent @@ -41,24 +45,15 @@ class Violation: detail: str -def _os_names(tree: ast.Module) -> set[str]: - """The local names the `os` module is bound to: `import os`, `import os as x`, `import os.path`.""" - names: set[str] = set() - for node in ast.walk(tree): - if not isinstance(node, ast.Import): - continue - for alias in node.names: - if alias.name == "os": - names.add(alias.asname or "os") - elif alias.name.startswith("os.") and not alias.asname: - names.add("os") - return names - - def _is_write_target(node: ast.Attribute, parents: dict[ast.AST, ast.AST]) -> bool: """True when `node` is the object of a subscript store or delete (`os.environ["X"] = "1"`).""" parent = parents.get(node) - return isinstance(parent, ast.Subscript) and parent.value is node and isinstance(parent.ctx, (ast.Store, ast.Del)) + if not (isinstance(parent, ast.Subscript) and parent.value is node): + return False + # `os.environ["X"] += "..."` reads the value before it writes, so it counts as a read. + return isinstance(parent.ctx, ast.Del) or ( + isinstance(parent.ctx, ast.Store) and not isinstance(parents.get(parent), ast.AugAssign) + ) def _reads_env(node: ast.AST, modules: set[str], parents: dict[ast.AST, ast.AST]) -> bool: @@ -77,7 +72,7 @@ def env_read_violations(sources: list[Path], root: Path) -> list[Violation]: continue tree = ast.parse(py.read_text(encoding="utf-8")) parents = {child: node for node in ast.walk(tree) for child in ast.iter_child_nodes(node)} - modules = _os_names(tree) + modules = os_names(tree) for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module == "os": out.extend( @@ -130,6 +125,8 @@ def _plant(tmp_path: Path, files: dict[str, str]) -> list[Path]: ("from os import getenv as g\n", "mod.py:1 imports os.getenv"), ("import os as x\ny = x.environ\n", "mod.py:2 reads os.environ"), ("import os.path\ny = os.getenv('A')\n", "mod.py:2 reads os.getenv"), + ("import os\nos.environ['PATH'] += ':x'\n", "mod.py:2 reads os.environ"), + ("import os\nos.environ.update({'A': '1'})\n", "mod.py:2 reads os.environ"), ], ) def test_e1_env_read_fires(tmp_path: Path, body: str, detail: str) -> None: diff --git a/tests/unit/architecture/test_one_process_runner.py b/tests/unit/architecture/test_one_process_runner.py index 3af1b2e..e5ec847 100644 --- a/tests/unit/architecture/test_one_process_runner.py +++ b/tests/unit/architecture/test_one_process_runner.py @@ -2,8 +2,10 @@ One rule, pure `ast`, over every `src/reporails_cli/**/*.py`: -- P1 stray process: `import subprocess`, `import subprocess as x` or `from subprocess import ...` in - any module but `interfaces/cli/helpers.py`; and, anywhere in src, a call to `os.system`, +- P1 stray process: `import subprocess`, `import subprocess as x` or `from subprocess import ...`, and the same for + `webbrowser` and `pty`, in any module but `interfaces/cli/helpers.py`; an attribute reference to + `asyncio.create_subprocess_exec` or `asyncio.create_subprocess_shell` anywhere; + and, anywhere in src, a call to `os.system`, `os.popen`, `os.startfile`, `os.spawn*`, `os.exec*` or `os.posix_spawn*` (through `import os`, an alias of it, or a name imported from `os`). `os.fork` is allowed: the mapper daemon owns its own start. @@ -23,6 +25,8 @@ import pytest +from tests.unit.architecture._os_aliases import os_names + pytestmark = pytest.mark.architecture ROOT = Path(__file__).resolve().parent.parent.parent.parent @@ -31,6 +35,7 @@ _OS_EXACT = {"system", "popen", "startfile"} _OS_PREFIXES = ("spawn", "exec", "posix_spawn") +_ASYNCIO_STARTERS = {"create_subprocess_exec", "create_subprocess_shell"} @dataclass(frozen=True) @@ -45,22 +50,23 @@ def _starts_a_program(name: str) -> bool: return name in _OS_EXACT or name.startswith(_OS_PREFIXES) -def _os_names(tree: ast.Module) -> set[str]: - """The local names the `os` module is bound to: `import os`, `import os as x`, `import os.path`.""" - names: set[str] = set() - for node in ast.walk(tree): - if not isinstance(node, ast.Import): - continue - for alias in node.names: - if alias.name == "os": - names.add(alias.asname or "os") - elif alias.name.startswith("os.") and not alias.asname: - names.add("os") - return names +_RUNNER_MODULES = ("subprocess", "pty", "webbrowser") + + +def _is_runner_module(module: str | None) -> bool: + """True for `subprocess`, `pty` or `webbrowser`, or a submodule of one: each starts a program.""" + return any(module == name or (module or "").startswith(f"{name}.") for name in _RUNNER_MODULES) -def _is_subprocess(module: str | None) -> bool: - return module == "subprocess" or (module or "").startswith("subprocess.") +def _asyncio_names(tree: ast.Module) -> set[str]: + """The local names the `asyncio` module is bound to.""" + return { + alias.asname or "asyncio" + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + if alias.name == "asyncio" + } def stray_process_violations(sources: list[Path], root: Path) -> list[Violation]: @@ -69,7 +75,8 @@ def stray_process_violations(sources: list[Path], root: Path) -> list[Violation] for py in sources: rel = py.relative_to(root).as_posix() tree = ast.parse(py.read_text(encoding="utf-8")) - modules = _os_names(tree) + modules = os_names(tree) + asyncio_names = _asyncio_names(tree) from_os = { alias.asname or alias.name: alias.name for node in ast.walk(tree) @@ -82,10 +89,17 @@ def stray_process_violations(sources: list[Path], root: Path) -> list[Violation] out.extend( Violation("P1", f"{rel}:{node.lineno} imports {alias.name}") for alias in node.names - if _is_subprocess(alias.name) + if _is_runner_module(alias.name) ) - elif rel != RUNNER and isinstance(node, ast.ImportFrom) and _is_subprocess(node.module): + elif rel != RUNNER and isinstance(node, ast.ImportFrom) and _is_runner_module(node.module): out.append(Violation("P1", f"{rel}:{node.lineno} imports from {node.module}")) + elif ( + isinstance(node, ast.Attribute) + and isinstance(node.value, ast.Name) + and node.value.id in asyncio_names + and node.attr in _ASYNCIO_STARTERS + ): + out.append(Violation("P1", f"{rel}:{node.lineno} uses asyncio.{node.attr}")) elif isinstance(node, ast.Call): func = node.func if ( @@ -137,6 +151,12 @@ def _plant(tmp_path: Path, files: dict[str, str]) -> list[Path]: ("import os\nos.spawnl(0, 'x')\n", "mod.py:2 calls os.spawnl"), ("import os\nos.execv('x', [])\n", "mod.py:2 calls os.execv"), ("import os\nos.posix_spawn('x', [], {})\n", "mod.py:2 calls os.posix_spawn"), + ("import webbrowser\n", "mod.py:1 imports webbrowser"), + ("from webbrowser import open\n", "mod.py:1 imports from webbrowser"), + ("import pty\n", "mod.py:1 imports pty"), + ("from pty import spawn\n", "mod.py:1 imports from pty"), + ("import asyncio\nasyncio.create_subprocess_exec\n", "mod.py:2 uses asyncio.create_subprocess_exec"), + ("import asyncio\nx = asyncio.create_subprocess_shell\n", "mod.py:2 uses asyncio.create_subprocess_shell"), ("import os as o\no.system('x')\n", "mod.py:2 calls os.system"), ("from os import system as s\ns('x')\n", "mod.py:2 calls os.system"), ], From fd031e612570c2f48613fbcfc6ecf9fde96999cb Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 17:57:32 +0200 Subject: [PATCH 21/31] npm: a first run through npx without uv installs uv and finishes the command, running uvx from the installer's folder instead of exiting until the shell restarts --- UNRELEASED.md | 3 ++ packages/npm/bin/reporails.mjs | 43 +++++++++++++++---- tests/integration/test_npm_launcher.py | 59 +++++++++++++++++++++++++- 3 files changed, 95 insertions(+), 10 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 346db89..7fddc21 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -14,6 +14,9 @@ ### Fixed +- A first run through `npx` on a machine without `uv` installs `uv` and finishes your command, instead of stopping to ask you to restart your shell. +- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. An `ails` in the current folder that is not on PATH no longer counts as installed. +- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. - `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. - On Windows, `ails install` no longer counts an `ails` in the current folder or in uv's cache as installed, and an empty PATHEXT no longer hides an installed `ails`. - `ails update` reports a uv run that cannot start or times out, instead of crashing. diff --git a/packages/npm/bin/reporails.mjs b/packages/npm/bin/reporails.mjs index 34febf2..eeb909f 100755 --- a/packages/npm/bin/reporails.mjs +++ b/packages/npm/bin/reporails.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import { execSync, spawn } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { platform } from "node:os"; +import { existsSync, readFileSync } from "node:fs"; +import { homedir, platform } from "node:os"; +import { delimiter, join } from "node:path"; import { argv, env, exit } from "node:process"; const PYPI_PACKAGE = "reporails-cli"; @@ -52,8 +53,28 @@ function commandExists(cmd) { } } -function ensureUv() { - if (commandExists("uv")) return; +// The folders Astral's uv installer writes to, in the installer's own order. +function uvInstallDirs() { + const dirs = []; + if (env.UV_INSTALL_DIR) dirs.push(env.UV_INSTALL_DIR); + if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME); + if (env.XDG_DATA_HOME) dirs.push(join(env.XDG_DATA_HOME, "..", "bin")); + dirs.push(join(homedir(), ".local", "bin")); + return dirs; +} + +function findInstalledUvx() { + const file = platform() === "win32" ? "uvx.exe" : "uvx"; + const dir = uvInstallDirs().find((d) => existsSync(join(d, file))); + return dir ? { cmd: join(dir, file), dir } : null; +} + +// Returns the uvx to run: on PATH, else in the installer's folders, else +// installed first. `dir` is set when uvx is not on PATH. +function ensureUvx() { + if (commandExists("uvx")) return { cmd: "uvx", dir: null }; + const found = findInstalledUvx(); + if (found) return found; console.log("uv not found — installing..."); try { @@ -72,12 +93,14 @@ function ensureUv() { exit(1); } - if (!commandExists("uv")) { + const installed = commandExists("uvx") ? { cmd: "uvx", dir: null } : findInstalledUvx(); + if (!installed) { console.error( - "uv was installed but is not on PATH. Restart your shell or add it to PATH, then retry.", + `uv was installed but uvx was not found on PATH or in ${uvInstallDirs().join(", ")}. Add uv's folder to PATH, then retry.`, ); exit(1); } + return installed; } // --------------------------------------------------------------------------- @@ -85,11 +108,13 @@ function ensureUv() { // --------------------------------------------------------------------------- function proxy(args) { - ensureUv(); + const uvx = ensureUvx(); + const childEnv = { ...env, AILS_LAUNCHER: "npm" }; + if (uvx.dir) childEnv.PATH = env.PATH ? `${uvx.dir}${delimiter}${env.PATH}` : uvx.dir; - const child = spawn("uvx", ["--from", `${PYPI_PACKAGE}==${VERSION}`, CLI_COMMAND, ...args], { + const child = spawn(uvx.cmd, ["--from", `${PYPI_PACKAGE}==${VERSION}`, CLI_COMMAND, ...args], { stdio: "inherit", - env: { ...env, AILS_LAUNCHER: "npm" }, + env: childEnv, }); child.on("error", (err) => { diff --git a/tests/integration/test_npm_launcher.py b/tests/integration/test_npm_launcher.py index c7061a7..d054d4e 100644 --- a/tests/integration/test_npm_launcher.py +++ b/tests/integration/test_npm_launcher.py @@ -27,7 +27,8 @@ STUB = ( "import json, os, sys\n" "with open(os.environ['STUB_OUT'], 'w', encoding='utf-8') as fh:\n" - " json.dump({'argv': sys.argv[1:], 'launcher': os.environ.get('AILS_LAUNCHER')}, fh)\n" + " json.dump({'argv': sys.argv[1:], 'launcher': os.environ.get('AILS_LAUNCHER'),\n" + " 'path': os.environ.get('PATH')}, fh)\n" "sys.exit(int(os.environ.get('STUB_EXIT', '0')))\n" ) @@ -69,3 +70,59 @@ def test_launcher_pins_its_version_and_marks_npm(tmp_path: Path) -> None: def test_launcher_exits_with_the_engine_exit_code(tmp_path: Path) -> None: code, _ = _run_launcher(tmp_path, 3) assert code == 3 + + +def _run_without_uv_on_path( + tmp_path: Path, stub_dir: Path, **extra_env: str +) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]: + """Run the launcher with `which` on PATH but no uv/uvx, and a uvx stub in `stub_dir`.""" + node = shutil.which("node") + which = shutil.which("which") + if node is None or which is None: + pytest.skip("node or which is not installed; the npm launcher cannot run") + which_dir = Path(which).parent + if (which_dir / "uv").exists() or (which_dir / "uvx").exists(): + pytest.skip(f"uv or uvx sits beside `which` in {which_dir}; cannot build a PATH without uv") + stub_dir.mkdir(parents=True, exist_ok=True) + stub = stub_dir / "uvx" + stub.write_text(f"#!{sys.executable}\n{STUB}", encoding="utf-8") + stub.chmod(0o755) + out = tmp_path / "out.json" + env = { + **os.environ, + "PATH": str(which_dir), + "HOME": str(tmp_path), + "STUB_OUT": str(out), + "STUB_EXIT": "4", + **extra_env, + } + for name in ("UV_INSTALL_DIR", "XDG_BIN_HOME", "XDG_DATA_HOME", "AILS_LAUNCHER"): + if name not in extra_env: + env.pop(name, None) + result = subprocess.run([node, str(LAUNCHER), "version"], env=env, capture_output=True, text=True, timeout=60) + if not out.exists(): + pytest.fail(f"the uvx stub never ran\nstdout: {result.stdout}\nstderr: {result.stderr}") + return result, json.loads(out.read_text(encoding="utf-8")) + + +def test_launcher_runs_uvx_from_the_installer_folder_without_reinstalling(tmp_path: Path) -> None: + version = json.loads((NPM_DIR / "package.json").read_text(encoding="utf-8"))["version"] + result, seen = _run_without_uv_on_path(tmp_path, tmp_path / ".local" / "bin") + assert "installing" not in result.stdout + assert seen["argv"] == ["--from", f"reporails-cli=={version}", "ails", "version"] + assert seen["launcher"] == "npm" + assert result.returncode == 4 + + +def test_launcher_prefers_uv_install_dir(tmp_path: Path) -> None: + custom = tmp_path / "custom" + result, seen = _run_without_uv_on_path(tmp_path, custom, UV_INSTALL_DIR=str(custom)) + assert "installing" not in result.stdout + assert seen["launcher"] == "npm" + assert result.returncode == 4 + + +def test_launcher_puts_the_uvx_folder_first_on_the_child_path(tmp_path: Path) -> None: + folder = tmp_path / ".local" / "bin" + _, seen = _run_without_uv_on_path(tmp_path, folder) + assert str(seen["path"]).split(os.pathsep)[0] == str(folder) From 334996e2801953664f4228af78d9358570b86432 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 18:09:29 +0200 Subject: [PATCH 22/31] npm: the launcher leaves the engine's PATH as given, writes install output to stderr, accepts only an executable uvx and also looks in the legacy installer folders --- UNRELEASED.md | 2 +- packages/npm/bin/reporails.mjs | 32 ++++++-- tests/integration/test_npm_launcher.py | 105 ++++++++++++++++++++----- 3 files changed, 112 insertions(+), 27 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 7fddc21..c8f5aa5 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -14,7 +14,7 @@ ### Fixed -- A first run through `npx` on a machine without `uv` installs `uv` and finishes your command, instead of stopping to ask you to restart your shell. +- A first run through `npx` on a machine without `uv` installs `uv` and finishes your command, instead of stopping to ask you to restart your shell. The install messages go to stderr, so the command's own output, such as `-f json`, stays clean. - `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. An `ails` in the current folder that is not on PATH no longer counts as installed. - A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. - `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. diff --git a/packages/npm/bin/reporails.mjs b/packages/npm/bin/reporails.mjs index eeb909f..df72de6 100755 --- a/packages/npm/bin/reporails.mjs +++ b/packages/npm/bin/reporails.mjs @@ -1,9 +1,9 @@ #!/usr/bin/env node import { execSync, spawn } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; +import { accessSync, constants, readFileSync, statSync } from "node:fs"; import { homedir, platform } from "node:os"; -import { delimiter, join } from "node:path"; +import { join } from "node:path"; import { argv, env, exit } from "node:process"; const PYPI_PACKAGE = "reporails-cli"; @@ -57,15 +57,27 @@ function commandExists(cmd) { function uvInstallDirs() { const dirs = []; if (env.UV_INSTALL_DIR) dirs.push(env.UV_INSTALL_DIR); + if (env.CARGO_DIST_FORCE_INSTALL_DIR) dirs.push(env.CARGO_DIST_FORCE_INSTALL_DIR); if (env.XDG_BIN_HOME) dirs.push(env.XDG_BIN_HOME); if (env.XDG_DATA_HOME) dirs.push(join(env.XDG_DATA_HOME, "..", "bin")); dirs.push(join(homedir(), ".local", "bin")); + // Legacy installer folder. + dirs.push(env.CARGO_HOME ? join(env.CARGO_HOME, "bin") : join(homedir(), ".cargo", "bin")); return dirs; } function findInstalledUvx() { const file = platform() === "win32" ? "uvx.exe" : "uvx"; - const dir = uvInstallDirs().find((d) => existsSync(join(d, file))); + const runnable = (p) => { + try { + if (!statSync(p).isFile()) return false; + if (platform() !== "win32") accessSync(p, constants.X_OK); + return true; + } catch { + return false; + } + }; + const dir = uvInstallDirs().find((d) => runnable(join(d, file))); return dir ? { cmd: join(dir, file), dir } : null; } @@ -76,16 +88,16 @@ function ensureUvx() { const found = findInstalledUvx(); if (found) return found; - console.log("uv not found — installing..."); + console.error("uv not found — installing..."); try { if (platform() === "win32") { execSync( 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"', - { stdio: "inherit" }, + { stdio: ["ignore", 2, 2] }, ); } else { execSync("curl -LsSf https://astral.sh/uv/install.sh | sh", { - stdio: "inherit", + stdio: ["ignore", 2, 2], }); } } catch { @@ -96,10 +108,15 @@ function ensureUvx() { const installed = commandExists("uvx") ? { cmd: "uvx", dir: null } : findInstalledUvx(); if (!installed) { console.error( - `uv was installed but uvx was not found on PATH or in ${uvInstallDirs().join(", ")}. Add uv's folder to PATH, then retry.`, + `uv was installed but uvx was not found in ${uvInstallDirs().join(", ")}. Open a new terminal (the installer adds uv to PATH there) and run the command again.`, ); exit(1); } + if (installed.dir) { + console.error( + `uv was installed to ${installed.dir}. Open a new terminal so uv (and an ails installed by \`ails install\`) is on PATH there.`, + ); + } return installed; } @@ -110,7 +127,6 @@ function ensureUvx() { function proxy(args) { const uvx = ensureUvx(); const childEnv = { ...env, AILS_LAUNCHER: "npm" }; - if (uvx.dir) childEnv.PATH = env.PATH ? `${uvx.dir}${delimiter}${env.PATH}` : uvx.dir; const child = spawn(uvx.cmd, ["--from", `${PYPI_PACKAGE}==${VERSION}`, CLI_COMMAND, ...args], { stdio: "inherit", diff --git a/tests/integration/test_npm_launcher.py b/tests/integration/test_npm_launcher.py index d054d4e..13df6a2 100644 --- a/tests/integration/test_npm_launcher.py +++ b/tests/integration/test_npm_launcher.py @@ -72,10 +72,23 @@ def test_launcher_exits_with_the_engine_exit_code(tmp_path: Path) -> None: assert code == 3 +FAKE_CURL_DEFAULT = 'echo "stub curl called" >&2\nexit 1\n' + + def _run_without_uv_on_path( - tmp_path: Path, stub_dir: Path, **extra_env: str -) -> tuple[subprocess.CompletedProcess[str], dict[str, object]]: - """Run the launcher with `which` on PATH but no uv/uvx, and a uvx stub in `stub_dir`.""" + tmp_path: Path, + stub_dir: Path | None, + *, + curl_body: str = FAKE_CURL_DEFAULT, + expect_run: bool = True, + **extra_env: str, +) -> tuple[subprocess.CompletedProcess[str], dict[str, object] | None, str]: + """Run the launcher with `which` on PATH but no uv/uvx, and a uvx stub in `stub_dir`. + + A fake `curl` sits first on PATH so the installer can never reach the network; it runs + `curl_body` (a /bin/sh fragment). Returns the process, the stub's record (None when it + never ran and `expect_run` is false), and the PATH the launcher was given. + """ node = shutil.which("node") which = shutil.which("which") if node is None or which is None: @@ -83,32 +96,50 @@ def _run_without_uv_on_path( which_dir = Path(which).parent if (which_dir / "uv").exists() or (which_dir / "uvx").exists(): pytest.skip(f"uv or uvx sits beside `which` in {which_dir}; cannot build a PATH without uv") - stub_dir.mkdir(parents=True, exist_ok=True) - stub = stub_dir / "uvx" - stub.write_text(f"#!{sys.executable}\n{STUB}", encoding="utf-8") - stub.chmod(0o755) + if stub_dir is not None: + stub_dir.mkdir(parents=True, exist_ok=True) + stub = stub_dir / "uvx" + stub.write_text(f"#!{sys.executable}\n{STUB}", encoding="utf-8") + stub.chmod(0o755) + curl_dir = tmp_path / "fakecurl" + curl_dir.mkdir() + curl = curl_dir / "curl" + curl.write_text(f"#!/bin/sh\n{curl_body}", encoding="utf-8") + curl.chmod(0o755) + path = os.pathsep.join([str(curl_dir), str(which_dir)]) out = tmp_path / "out.json" env = { **os.environ, - "PATH": str(which_dir), + "PATH": path, "HOME": str(tmp_path), "STUB_OUT": str(out), "STUB_EXIT": "4", **extra_env, } - for name in ("UV_INSTALL_DIR", "XDG_BIN_HOME", "XDG_DATA_HOME", "AILS_LAUNCHER"): + for name in ( + "UV_INSTALL_DIR", + "CARGO_DIST_FORCE_INSTALL_DIR", + "XDG_BIN_HOME", + "XDG_DATA_HOME", + "CARGO_HOME", + "AILS_LAUNCHER", + ): if name not in extra_env: env.pop(name, None) result = subprocess.run([node, str(LAUNCHER), "version"], env=env, capture_output=True, text=True, timeout=60) if not out.exists(): - pytest.fail(f"the uvx stub never ran\nstdout: {result.stdout}\nstderr: {result.stderr}") - return result, json.loads(out.read_text(encoding="utf-8")) + if expect_run: + pytest.fail(f"the uvx stub never ran\nstdout: {result.stdout}\nstderr: {result.stderr}") + return result, None, path + return result, json.loads(out.read_text(encoding="utf-8")), path def test_launcher_runs_uvx_from_the_installer_folder_without_reinstalling(tmp_path: Path) -> None: version = json.loads((NPM_DIR / "package.json").read_text(encoding="utf-8"))["version"] - result, seen = _run_without_uv_on_path(tmp_path, tmp_path / ".local" / "bin") - assert "installing" not in result.stdout + result, seen, _ = _run_without_uv_on_path(tmp_path, tmp_path / ".local" / "bin") + assert seen is not None + assert "installing" not in result.stdout + result.stderr + assert "stub curl called" not in result.stderr assert seen["argv"] == ["--from", f"reporails-cli=={version}", "ails", "version"] assert seen["launcher"] == "npm" assert result.returncode == 4 @@ -116,13 +147,51 @@ def test_launcher_runs_uvx_from_the_installer_folder_without_reinstalling(tmp_pa def test_launcher_prefers_uv_install_dir(tmp_path: Path) -> None: custom = tmp_path / "custom" - result, seen = _run_without_uv_on_path(tmp_path, custom, UV_INSTALL_DIR=str(custom)) - assert "installing" not in result.stdout + result, seen, _ = _run_without_uv_on_path(tmp_path, custom, UV_INSTALL_DIR=str(custom)) + assert seen is not None + assert "installing" not in result.stdout + result.stderr assert seen["launcher"] == "npm" assert result.returncode == 4 -def test_launcher_puts_the_uvx_folder_first_on_the_child_path(tmp_path: Path) -> None: +def test_launcher_leaves_the_child_path_as_given(tmp_path: Path) -> None: + _, seen, path = _run_without_uv_on_path(tmp_path, tmp_path / ".local" / "bin") + assert seen is not None + assert seen["path"] == path + + +def test_launcher_skips_a_uvx_that_is_not_executable(tmp_path: Path) -> None: folder = tmp_path / ".local" / "bin" - _, seen = _run_without_uv_on_path(tmp_path, folder) - assert str(seen["path"]).split(os.pathsep)[0] == str(folder) + folder.mkdir(parents=True) + (folder / "uvx").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + (folder / "uvx").chmod(0o644) + result, seen, _ = _run_without_uv_on_path(tmp_path, None, expect_run=False) + assert seen is None + assert result.returncode != 0 + assert "stub curl called" in result.stderr + + +def test_launcher_installs_uv_and_keeps_stdout_clean(tmp_path: Path) -> None: + version = json.loads((NPM_DIR / "package.json").read_text(encoding="utf-8"))["version"] + payload = tmp_path / "payload" + payload.mkdir() + (payload / "uvx").write_text(f"#!{sys.executable}\n{STUB}", encoding="utf-8") + curl_body = ( + "cat <<'EOF'\n" + 'echo "installer says hello"\n' + 'mkdir -p "$HOME/.local/bin"\n' + f'cp "{payload}/uvx" "$HOME/.local/bin/uvx"\n' + 'chmod +x "$HOME/.local/bin/uvx"\n' + "printf '#!/bin/sh\\nexit 0\\n' > \"$HOME/.local/bin/uv\"\n" + 'chmod +x "$HOME/.local/bin/uv"\n' + "EOF\n" + ) + result, seen, _ = _run_without_uv_on_path(tmp_path, None, curl_body=curl_body) + assert seen is not None + assert seen["argv"] == ["--from", f"reporails-cli=={version}", "ails", "version"] + assert result.returncode == 4 + assert result.stdout == "" + assert "installing" in result.stderr + assert "installer says hello" in result.stderr + assert "Open a new terminal" in result.stderr + assert str(tmp_path / ".local" / "bin") in result.stderr From b572a29580374d399938daa838cf721d696cbecf Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Tue, 20 Oct 2026 18:11:39 +0200 Subject: [PATCH 23/31] unreleased: one copy each of the npx install line and the linked CLAUDE.md line after the merge --- UNRELEASED.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index c8f5aa5..7a96f02 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -15,8 +15,6 @@ ### Fixed - A first run through `npx` on a machine without `uv` installs `uv` and finishes your command, instead of stopping to ask you to restart your shell. The install messages go to stderr, so the command's own output, such as `-f json`, stays clean. -- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. An `ails` in the current folder that is not on PATH no longer counts as installed. -- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. - `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. - On Windows, `ails install` no longer counts an `ails` in the current folder or in uv's cache as installed, and an empty PATHEXT no longer hides an installed `ails`. - `ails update` reports a uv run that cannot start or times out, instead of crashing. From 3bd0236f56e3df51500e1e16e53b690c5608fca3 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 21 Oct 2026 16:59:36 +0200 Subject: [PATCH 24/31] check: a left-out folder is a folder name or a path from the project root, matched like file patterns and skipped in every walk and through symlinks; a value holding *, ?, a backslash or .. stops the run naming where it came from; the skill entry check, the Excluded line on mixed runs and an --agent run follow the run's exclusions; qa and CI check test markers --- .github/workflows/ci.yml | 3 + .github/workflows/release-qa.yml | 3 + UNRELEASED.md | 10 + docs/configuration.md | 14 +- pyproject.toml | 2 +- scripts/release-gates.sh | 1 + .../core/classify/capability_paths.py | 24 +- src/reporails_cli/core/classify/file_tags.py | 2 +- .../core/discovery/agent_discovery.py | 79 +++---- .../core/discovery/agent_markers.py | 5 +- src/reporails_cli/core/discovery/agents.py | 18 +- .../core/discovery/exclusions.py | 149 ++++++++----- src/reporails_cli/core/discovery/features.py | 15 +- .../core/discovery/memory_locator.py | 18 +- .../core/discovery/plugin_roots.py | 34 +-- src/reporails_cli/core/discovery/walk.py | 100 +++++---- .../core/lint/mechanical/checks.py | 2 +- .../core/lint/mechanical/checks_absent.py | 7 +- .../core/lint/mechanical/checks_advanced.py | 10 +- src/reporails_cli/core/lint/regex/expect.py | 2 +- src/reporails_cli/core/lint/regex/runner.py | 4 +- .../core/pipeline/expected_loads.py | 4 +- src/reporails_cli/core/pipeline/mapping.py | 65 +++++- .../core/platform/config/config.py | 42 +++- .../core/platform/contract/glob_pattern.py | 202 +++++++++++++++++ .../core/platform/policy/exclusion.py | 89 +++++++- .../core/platform/policy/preflight.py | 33 ++- .../core/platform/utils/utils.py | 53 +---- src/reporails_cli/formatters/text/display.py | 2 +- .../formatters/text/funnel_cta.py | 4 +- .../interfaces/cli/check_command.py | 14 +- .../interfaces/cli/check_flow.py | 7 +- .../interfaces/cli/check_orchestration.py | 15 +- .../interfaces/cli/completion.py | 18 ++ .../interfaces/cli/config_command.py | 11 +- tests/unit/test_agent_discovery_mut.py | 41 ++-- tests/unit/test_capability_paths.py | 3 +- tests/unit/test_capability_paths_mut.py | 10 +- tests/unit/test_claude_plugin_layout.py | 17 +- tests/unit/test_exclude_dirs_refusal.py | 161 ++++++++++++++ tests/unit/test_features_mut.py | 9 +- tests/unit/test_folder_values.py | 148 +++++++++++++ tests/unit/test_funnel.py | 29 ++- tests/unit/test_glob_matches.py | 106 ++++++++- tests/unit/test_glob_walk_owner.py | 26 ++- tests/unit/test_left_out_folders.py | 165 ++++++++++++++ tests/unit/test_mechanical.py | 18 ++ tests/unit/test_mechanical_run_exclusions.py | 2 +- tests/unit/test_memory_classification.py | 3 +- tests/unit/test_memory_locator_patterns.py | 3 +- tests/unit/test_pipeline_mapping.py | 104 +++++++++ tests/unit/test_run_exclusions.py | 115 ++++++++-- tests/unit/test_run_exclusions_paths.py | 45 +++- tests/unit/test_scorecard_readability.py | 14 +- tests/unit/test_symlink_detection.py | 14 +- tests/unit/test_walk_excludes.py | 205 ++++++++++++++++-- tests/unit/test_walk_glob_runs.py | 9 +- tests/unit/test_walk_glob_shared.py | 54 +++-- tests/unit/test_walk_markdown.py | 14 +- tests/unit/test_windows_path_forms.py | 3 +- 60 files changed, 1924 insertions(+), 455 deletions(-) create mode 100644 src/reporails_cli/core/platform/contract/glob_pattern.py create mode 100644 tests/unit/test_exclude_dirs_refusal.py create mode 100644 tests/unit/test_folder_values.py create mode 100644 tests/unit/test_left_out_folders.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fe4c0c..2f7fd15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,9 @@ jobs: - name: Type check run: uv run poe type + - name: Test markers + run: uv run poe test_markers + - name: Rule lint run: uv run poe test_lint diff --git a/.github/workflows/release-qa.yml b/.github/workflows/release-qa.yml index fa4b86a..f5258c2 100644 --- a/.github/workflows/release-qa.yml +++ b/.github/workflows/release-qa.yml @@ -53,6 +53,9 @@ jobs: if: matrix.os == 'ubuntu-latest' run: uv run mypy --platform=win32 src/ + - name: Test markers + run: uv run poe test_markers + - name: Rule lint run: uv run poe test_lint diff --git a/UNRELEASED.md b/UNRELEASED.md index 7a96f02..53445d8 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -11,6 +11,7 @@ - The context x-ray's event logs in `~/.reporails/xray/` are private to you, removed after 30 days (sooner if Claude Code is set to keep sessions for less), and the folder stays under 100 MB. - `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. - The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. +- A folder value in `--exclude-dirs` or `exclude_dirs` that holds `*`, `?`, a backslash or a `..` step stops the run with an error that names the flag or the config file it came from and points to `--exclude-files` / `exclude_files` for file patterns; such a value used to be listed as left out while leaving nothing out. `ails config set exclude_dirs` refuses it before writing, with an error that names the setting, and the MCP tools answer `config_unreadable`. ### Fixed @@ -23,6 +24,14 @@ - `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. - A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. - A space before the address in `AILS_SERVER_URL` no longer stops `ails check` from reaching that server. +- `--exclude-dirs` and `exclude_dirs` read a value with a `/` as a folder path from the project root: `/docs`, `./docs`, `docs/.` and an absolute path inside the project leave out the root's `docs` folder, where `/docs` and an absolute path used to leave out nothing and `./docs` every `docs` folder. The `Excluded` line lists such a folder as `./docs`. Folder names holding `[` and `]`, such as `[slug]`, can be left out. +- A folder you leave out by path is skipped during the scan, as a folder name is, so a large left-out folder costs no scan time, and an agent whose files sit only inside a folder left out in `exclude_dirs` is not detected. The scan does not enter a left-out folder through a symlink, or on Windows a junction, that points inside it either. +- The skill entry-point check no longer reports a skill folder you left out. +- On a run that names a file and also checks a folder or a capability listing, the `Excluded` line keeps a folder or pattern the named file sits in. +- `ails check --agent ` no longer checks another agent's file that you named inside a left-out folder. +- A run with no findings no longer says the score covers the rest of the project, since it shows no score. +- With `generic_scanning: true`, a failure while reading the files your instruction files import or link is logged as a warning instead of silently checking none of them. +- The over-cap message never suggests leaving out a folder whose name `--exclude-dirs` would refuse, and on Pro its lines end with a full stop. ### Removed @@ -30,3 +39,4 @@ - A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. - Build checks fail when the environment is read, or a program is started, outside the one place that owns it. +- Every build check, not only the quick local one, fails on a test that is marked as more than one kind of test or as none. diff --git a/docs/configuration.md b/docs/configuration.md index 5fb228b..7ff943c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -17,7 +17,7 @@ Lives at the root of your repo. ```yaml default_agent: claude # Which agent's rules to run by default -exclude_dirs: [examples] # Extra directory names to skip during discovery (added to the built-in defaults below) +exclude_dirs: [examples] # Extra folders to skip during discovery: a name, or a path from the project root (added to the built-in defaults below) disabled_rules: [CORE:C:0010] # Rule IDs to disable entirely heal_exclude: ["archive/**"] # Files that are still checked and scored, but that heal never rewrites ``` @@ -174,7 +174,15 @@ Use the rule ID shown next to the finding in `ails check` output (e.g. `CORE:C:0 ## Excluding directories -`exclude_dirs` is a list of directory *names* (not paths). Any directory matching one of these names is skipped no matter where it appears. The setting exists because the discovery walk scans every directory looking for instruction files — without an exclude list it would descend into vendored trees (`node_modules`, `vendor/`), build output (`dist/`, `target/`), and data dumps (`data/`), surfacing third-party `CLAUDE.md` / `AGENTS.md` files you didn't author and slowing the scan. +`exclude_dirs` is a list of folders, each written as a folder name or a folder path: + +- A value without a `/` (`examples`) is a folder *name*: every folder with that name is skipped, no matter where it appears. +- A value with a `/` (`.claude/rules`, `./docs`, `/docs`, `docs/.`) is a folder *path* from the project root: only that folder is skipped. `./docs` and `/docs` both mean the `docs` folder at the root, not a `docs` folder deeper down. +- A trailing `/` is ignored, so `docs/` is the name `docs`. An absolute path inside the project is read from the project root. +- `[` and `]` are ordinary characters, so `[slug]` and `app/[id]` name those folders. +- A value holding `*`, `?`, a backslash or a `..` step is not a folder and stops the run with an error that names where the value came from. For file patterns use `exclude_files` / `--exclude-files` (below). + +The scan does not go into a folder you leave out, whether you gave it as a name or as a path, nor into a symlink that points inside one. The one exception is a left-out folder that holds a file you name on the command line: the scan enters it so that file can be checked, and everything else in it stays left out. The setting exists because the discovery walk scans every directory looking for instruction files — without an exclude list it would descend into vendored trees (`node_modules`, `vendor/`), build output (`dist/`, `target/`), and data dumps (`data/`), surfacing third-party `CLAUDE.md` / `AGENTS.md` files you didn't author and slowing the scan. The [built-in list above](#built-in-directory-excludes) already covers the common cases. Add to `exclude_dirs` only when your project has a non-standard tree: @@ -467,7 +475,7 @@ Always present, regardless of tier: `offline`, `server_error`, `tier`, `quality` `not_evaluated` lists the checks that did not finish on a file, as `{rule, file}`; it is `[]` when every check finished. A check that runs out of time on a very large file reports no finding for that file rather than a guess, so a rule named here was neither passed nor failed there. Text output names them in one line per file under the results. -`excluded` lists what you left out of the run, as `{dirs, files, imported_text_counted}`. `dirs` and `files` are the patterns you pass on the command line (`--exclude-dirs`, `--exclude-files`) joined with `exclude_dirs` and `exclude_files` from your config, sorted and without repeats; neither replaces the other. Both are `[]` when you excluded nothing, and the folders skipped by default (such as `node_modules`) are not listed. A folder name matches that folder at any depth; a value with a `/` (`.claude/rules`) is a folder path from the project root; a trailing `/` is ignored. A file or capability you name on the command line is checked even inside a left-out folder. On a run narrowed to named targets, the patterns a named target matches are not listed, and a run of a single file lists none. `imported_text_counted` is `true` when text a checked file imports with `@path` comes from a left-out file (that text still counts inside the importing file), and `false` otherwise. When `dirs` or `files` is non-empty, the score covers only the rest of the project. +`excluded` lists what you left out of the run, as `{dirs, files, imported_text_counted}`. `dirs` and `files` are the patterns you pass on the command line (`--exclude-dirs`, `--exclude-files`) joined with `exclude_dirs` and `exclude_files` from your config, sorted and without repeats; neither replaces the other. Both are `[]` when you excluded nothing, and the folders skipped by default (such as `node_modules`) are not listed. A folder name matches that folder at any depth; a value with a `/` (`.claude/rules`) is a folder path from the project root and is listed that way, with a single folder at the root listed as `./docs`; a trailing `/` is ignored. A file or capability you name on the command line is checked even inside a left-out folder. On a run whose targets are all named files, the patterns a named file matches are not listed; a run that also covers a folder or a capability listing lists them. A run of a single file lists none. `imported_text_counted` is `true` when text a checked file imports with `@path` comes from a left-out file (that text still counts inside the importing file), and `false` otherwise. When `dirs` or `files` is non-empty, the score covers only the rest of the project. Two additive fields enrich the output when the analysis service has data for the run. Both are **additive and backward-compatible** — existing JSON consumers and CI baselines that ignore them keep working unchanged: diff --git a/pyproject.toml b/pyproject.toml index ef9ecfc..9a70d4f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -199,7 +199,7 @@ test_behave = "behave" # test can be added. Usage: uv run poe test_mutation src/.py tests/unit/.py [max] test_mutation = "python scripts/mutation_probe.py" qa_fast = ["fmt", "lint_fix", "lint", "pylint_struct", "arch", "type", "test_markers", "test_lint", "test_unit"] -qa = ["fmt", "lint_fix", "lint", "pylint_struct", "arch", "type", "test_lint", "test_unit", "test_integration", "test_smoke", "test_behave"] +qa = ["fmt", "lint_fix", "lint", "pylint_struct", "arch", "type", "test_markers", "test_lint", "test_unit", "test_integration", "test_smoke", "test_behave"] mcp_dev = "python -m reporails_cli.interfaces.mcp.server" fetch_bundled_model = "python scripts/fetch_bundled_model.py" test_markers = "python scripts/check_test_markers.py" diff --git a/scripts/release-gates.sh b/scripts/release-gates.sh index 3051856..cd587f7 100755 --- a/scripts/release-gates.sh +++ b/scripts/release-gates.sh @@ -18,6 +18,7 @@ sha=$(git rev-parse HEAD) uv run poe pylint_struct uv run poe arch +uv run poe test_markers bash scripts/pre-release-check.sh if [ "$(git rev-parse HEAD)" != "$sha" ] || [ -n "$(git status --porcelain)" ]; then diff --git a/src/reporails_cli/core/classify/capability_paths.py b/src/reporails_cli/core/classify/capability_paths.py index 2f3fe04..1901021 100644 --- a/src/reporails_cli/core/classify/capability_paths.py +++ b/src/reporails_cli/core/classify/capability_paths.py @@ -22,7 +22,7 @@ from reporails_cli.core.discovery.memory_locator import memory_entries_for_agent from reporails_cli.core.discovery.plugin_roots import expand_plugin_patterns from reporails_cli.core.discovery.read_gates import apply_read_gate -from reporails_cli.core.discovery.walk import safe_resolve, walk_glob_matches +from reporails_cli.core.discovery.walk import Pruner, safe_resolve, walk_glob_matches from reporails_cli.core.platform.config.config import get_project_config from reporails_cli.core.platform.config.vocabulary import load_capability_vocabulary from reporails_cli.core.platform.dto.models import FileTypeDeclaration @@ -215,10 +215,10 @@ def list_capability_targets( if decl is None: continue if _is_user_scope_memory(ft_name, decl.patterns): - paths = _user_scope_memory_paths(agent, project_root, exclusions.walk_dirs) + paths = _user_scope_memory_paths(agent, project_root, exclusions) paths = [p for p in paths if not exclusions.leaves_out(p)] else: - paths = _glob_patterns(decl.patterns, project_root, exclusions.walk_dirs, decl=decl) + paths = _glob_patterns(decl.patterns, project_root, exclusions, decl=decl) paths = [p for p in paths if not exclusions.leaves_out(p)] # A file the agent reads only under a condition is its target only then. paths = apply_read_gate(agent, ft_name, paths, project_root) @@ -240,7 +240,7 @@ def _list_referenced_targets(agent: str, project_root: Path, exclusions: RunExcl in the project — if disabled, returns an empty list (classifier won't walk). """ - discovered = discover_from_config(project_root, agent, extra_exclude_dirs=exclusions.walk_dirs) + discovered = discover_from_config(project_root, agent, exclusions=exclusions) if discovered is None: return [] instruction_files = [f for f in discovered[0] if not exclusions.leaves_out(f)] @@ -278,9 +278,9 @@ def _is_user_scope_memory(ft_name: str, patterns: tuple[str, ...]) -> bool: return any(p.startswith("~/") for p in patterns) -def _user_scope_memory_paths(agent: str, project_root: Path, exclude_dirs: frozenset[str]) -> list[Path]: - """Resolve user-scope memory entries via memory_locator, not entering `exclude_dirs`.""" - return [entry.path for entry in memory_entries_for_agent(agent, project_root, exclude_dirs)] +def _user_scope_memory_paths(agent: str, project_root: Path, exclusions: RunExclusions) -> list[Path]: + """Resolve user-scope memory entries via memory_locator, not entering the folders `exclusions` skips.""" + return [entry.path for entry in memory_entries_for_agent(agent, project_root, exclusions)] def resolve_capability( @@ -327,7 +327,7 @@ def _find_declaration( def _glob_patterns( patterns: tuple[str, ...], project_root: Path, - skip_dirs: frozenset[str], + skip: Pruner, decl: FileTypeDeclaration | None = None, ) -> list[Path]: """Expand glob patterns under project_root. Skips user/managed-scope patterns. @@ -338,8 +338,8 @@ def _glob_patterns( the project tree — drop patterns that start with `~/`, an absolute path outside `project_root`, or `/etc/`-style managed locations. - `skip_dirs` holds folder names to skip — any matched path with a - directory of that name between `project_root` and the file is dropped. + `skip` prunes folders — a walk does not enter a folder it prunes, so no file + below one is matched. `decl` carries the file_type semantics — when provided, files matched via a loose-leaf pattern (`**/X.md` or bare `X.md`) are filtered by the @@ -357,7 +357,7 @@ def _glob_patterns( # A plugin component pattern resolves against each plugin root under the project. expanded = [ pattern - for pattern in expand_plugin_patterns(list(patterns), project_root, skip_dirs) + for pattern in expand_plugin_patterns(list(patterns), project_root, skip) if not is_rooted_pattern(pattern) ] if not expanded: @@ -365,7 +365,7 @@ def _glob_patterns( seen_resolved: set[Path] = set() out: list[Path] = [] for pattern in expanded: - for path in sorted(walk_glob_matches(project_root, pattern, skip_dirs)): + for path in sorted(walk_glob_matches(project_root, pattern, skip)): if decl is not None and not location_matches_mode(path, decl, {project_root}, pattern): continue resolved = safe_resolve(path) diff --git a/src/reporails_cli/core/classify/file_tags.py b/src/reporails_cli/core/classify/file_tags.py index 07fd08a..d08388e 100644 --- a/src/reporails_cli/core/classify/file_tags.py +++ b/src/reporails_cli/core/classify/file_tags.py @@ -17,8 +17,8 @@ from reporails_cli.core.discovery.agent_surfaces import agent_main_names from reporails_cli.core.discovery.walk import safe_resolve from reporails_cli.core.platform.contract.errors import BundledConfigMissingError +from reporails_cli.core.platform.contract.glob_pattern import glob_matches from reporails_cli.core.platform.contract.json_value import as_object, pattern_list -from reporails_cli.core.platform.utils.utils import glob_matches _CONFIG_NAMES = frozenset(("settings.json", ".mcp.json", "config.yml", "settings.local.json")) diff --git a/src/reporails_cli/core/discovery/agent_discovery.py b/src/reporails_cli/core/discovery/agent_discovery.py index a657706..ac16431 100644 --- a/src/reporails_cli/core/discovery/agent_discovery.py +++ b/src/reporails_cli/core/discovery/agent_discovery.py @@ -21,7 +21,7 @@ extract_properties, load_config_file_types, ) -from reporails_cli.core.discovery.exclusions import load_project_exclude_dirs +from reporails_cli.core.discovery.exclusions import RunExclusions, load_run_exclusions from reporails_cli.core.discovery.plugin_roots import expand_plugin_patterns from reporails_cli.core.discovery.project_root import resolve_project_root, user_level_agent_dir from reporails_cli.core.discovery.read_gates import apply_read_gate @@ -31,6 +31,7 @@ surface_include_patterns, ) from reporails_cli.core.discovery.walk import ( + Pruner, documented_path, list_dir, walk_glob, @@ -38,7 +39,6 @@ walk_markdown, ) from reporails_cli.core.platform.contract.json_value import JsonObject -from reporails_cli.core.platform.policy.exclusion import excluded_by_name from reporails_cli.core.platform.utils.utils import config_pattern_matches, is_rooted_pattern, matches_any_glob if TYPE_CHECKING: @@ -58,26 +58,15 @@ _PROJECT_SCOPED_HOME_CAPABILITIES = frozenset({"memory"}) -def ci_glob(target: Path, pattern: str, exclude_dirs: frozenset[str] = frozenset()) -> list[Path]: +def ci_glob(target: Path, pattern: str, skip: Pruner) -> list[Path]: """Case-insensitive glob (`claude.md` == `CLAUDE.md`). Repos in the wild use mixed filename casing and the agent specs do not mandate exact case (the AGENTS.md spec is silent on casing), so a lowercase copy is a real instruction file and must be collected. The walk - does not enter a folder named in `exclude_dirs`. + does not enter a folder `skip` prunes. """ - return list(walk_glob_matches(target, pattern, exclude_dirs, ignore_case=True)) - - -def is_excluded(path: Path, target: Path, exclude_dirs: frozenset[str]) -> bool: - """Check if any path component is in the exclusion set.""" - if not exclude_dirs: - return False - try: - rel = path.relative_to(target) - except ValueError: - return False - return excluded_by_name(rel.parts, exclude_dirs) + return list(walk_glob_matches(target, pattern, skip, ignore_case=True)) def walk_ancestors(start: Path, filename: str, stop: Path) -> list[Path]: @@ -208,7 +197,7 @@ def _run_descendant_recursive( target: Path, pattern: str, nested: bool, - exclude_dirs: frozenset[str], + exclusions: RunExclusions, repo_cache: dict[Path, bool] | None = None, ) -> list[Path]: """Descendant walk for **/ patterns. @@ -227,13 +216,13 @@ def _run_descendant_recursive( walk_root = target / Path(*prefix_parts) if prefix_parts else target if not walk_root.is_dir(): return [] - results = walk_glob(walk_root, filename, exclude_dirs) + results = walk_glob(walk_root, filename, exclusions) if nested: results = [m for m in results if m.parent != target] return [ m for m in results - if not is_excluded(m, target, exclude_dirs) + if not exclusions.covers(m) and config_pattern_matches(m.relative_to(target).as_posix(), pattern, ignore_case=True) and not (repo_cache is not None and in_nested_repository(m, target, pattern, repo_cache)) ] @@ -243,7 +232,7 @@ def glob_file_type_patterns( target: Path, patterns: list[str], properties: Mapping[str, str | list[str]] | None, - exclude_dirs: frozenset[str], + exclusions: RunExclusions, ) -> list[Path]: """Glob file_type patterns against target directory. @@ -271,7 +260,7 @@ def glob_file_type_patterns( if pattern.endswith("/"): # Directory glob (`.claude/agent-memory/*/`, `~/.claude/projects/*/memory/`) # -> enumerate `*.md` files inside the matched directories. - _glob_directory_entries(pattern, target, found, exclude_dirs) + _glob_directory_entries(pattern, target, found, exclusions) continue if is_rooted_pattern(pattern): _glob_external(pattern, target, found) @@ -284,22 +273,18 @@ def glob_file_type_patterns( if eager_global and (is_recursive_leaf or is_bare_leaf): if project_root is None: project_root = resolve_project_root(target) - found.extend( - m for m in walk_ancestors(target, filename, project_root) if not is_excluded(m, target, exclude_dirs) - ) + found.extend(m for m in walk_ancestors(target, filename, project_root) if not exclusions.covers(m)) elif eager_global and "**" not in pattern: if project_root is None: project_root = resolve_project_root(target) - found.extend( - m for m in ci_glob(project_root, pattern, exclude_dirs) if not is_excluded(m, target, exclude_dirs) - ) + found.extend(m for m in ci_glob(project_root, pattern, exclusions) if not exclusions.covers(m)) elif is_recursive_leaf: - found.extend(_run_descendant_recursive(target, pattern, nested, exclude_dirs, cache)) + found.extend(_run_descendant_recursive(target, pattern, nested, exclusions, cache)) else: found.extend( m - for m in ci_glob(target, pattern, exclude_dirs) - if not is_excluded(m, target, exclude_dirs) + for m in ci_glob(target, pattern, exclusions) + if not exclusions.covers(m) and not (cache is not None and in_nested_repository(m, target, pattern, cache)) ) return found @@ -321,7 +306,7 @@ def _glob_directory_entries( pattern: str, target: Path, found: list[Path], - exclude_dirs: frozenset[str], + exclusions: RunExclusions, ) -> None: """Enumerate `*.md` files inside directories matching a trailing-slash pattern. @@ -343,24 +328,22 @@ def _glob_directory_entries( for d in glob.glob(expanded_str): base = Path(d) if base.is_dir(): - found.extend(walk_markdown(base, exclude_dirs)) + found.extend(walk_markdown(base, exclusions)) return # In-tree pattern: resolve glob relative to target, enumerate .md inside each dir - for base in _resolve_in_tree_dirs(dir_pattern, target, exclude_dirs): - found.extend( - entry for entry in walk_markdown(base, exclude_dirs) if not is_excluded(entry, target, exclude_dirs) - ) + for base in _resolve_in_tree_dirs(dir_pattern, target, exclusions): + found.extend(entry for entry in walk_markdown(base, exclusions) if not exclusions.covers(entry)) def _resolve_in_tree_dirs( dir_pattern: str, target: Path, - exclude_dirs: frozenset[str], + exclusions: RunExclusions, ) -> list[Path]: """Resolve an in-tree directory glob (no trailing slash) to existing directories.""" candidates = [Path(p) for p in glob.glob(str(target / dir_pattern))] - return [p for p in candidates if p.is_dir() and not is_excluded(p, target, exclude_dirs)] + return [p for p in candidates if p.is_dir() and not exclusions.covers(p)] def _repo_scoped_patterns( @@ -401,7 +384,7 @@ class _AgentScope: target: Path agent_id: str project_config: ProjectConfig | None - excluded: frozenset[str] + exclusions: RunExclusions repo_scoped: bool user_dir: Path | None exclude_globs: list[str] @@ -416,7 +399,7 @@ def _discover_surface( target = scope.target patterns = _repo_scoped_patterns(extract_patterns(spec), ft_name, scope.repo_scoped, scope.user_dir) # A plugin component pattern resolves against each plugin root found here. - patterns = expand_plugin_patterns(patterns, target, scope.excluded, scope.plugin_roots) + patterns = expand_plugin_patterns(patterns, target, scope.exclusions, scope.plugin_roots) if not patterns: return None properties = extract_properties(spec) @@ -428,7 +411,7 @@ def _discover_surface( # Inject per-surface include patterns from .ails/config.yml patterns = patterns + surface_include_patterns(scope.agent_id, ft_name, scope.project_config) - found = glob_file_type_patterns(target, patterns, properties, scope.excluded) + found = glob_file_type_patterns(target, patterns, properties, scope.exclusions) if scope.exclude_globs: found = [p for p in found if not matches_any_glob(p, scope.exclude_globs, target)] @@ -442,7 +425,7 @@ def _agent_scope( target: Path, agent_id: str, file_types: JsonObject, - extra_exclude_dirs: frozenset[str] | None, + exclusions: RunExclusions | None, *, project_config: ProjectConfig | None, repo_scoped: bool, @@ -458,7 +441,7 @@ def _agent_scope( target=target, agent_id=agent_id, project_config=project_config, - excluded=extra_exclude_dirs or load_project_exclude_dirs(target), + exclusions=exclusions if exclusions is not None else load_run_exclusions(target), repo_scoped=repo_scoped, user_dir=target if repo_scoped and user_level_agent_dir(target) == target else None, exclude_globs=[ @@ -471,7 +454,7 @@ def discover_ungated( target: Path, agent_id: str, file_type_names: tuple[str, ...], - extra_exclude_dirs: frozenset[str], + exclusions: RunExclusions, *, project_config: ProjectConfig | None, ) -> list[Path]: @@ -479,9 +462,7 @@ def discover_ungated( found as discovery finds them but before the agent's read gate narrows them. Every path is kept under its own name, whatever it links to.""" file_types = load_config_file_types(agent_id) or {} - scope = _agent_scope( - target, agent_id, file_types, extra_exclude_dirs, project_config=project_config, repo_scoped=True - ) + scope = _agent_scope(target, agent_id, file_types, exclusions, project_config=project_config, repo_scoped=True) found: set[Path] = set() for name in file_type_names: spec = file_types.get(name) @@ -494,7 +475,7 @@ def discover_ungated( def discover_from_config( target: Path, agent_id: str, - extra_exclude_dirs: frozenset[str] | None = None, + exclusions: RunExclusions | None = None, *, project_config: ProjectConfig | None = None, repo_scoped: bool = False, @@ -513,7 +494,7 @@ def discover_from_config( return None scope = _agent_scope( - target, agent_id, file_types, extra_exclude_dirs, project_config=project_config, repo_scoped=repo_scoped + target, agent_id, file_types, exclusions, project_config=project_config, repo_scoped=repo_scoped ) buckets: dict[SurfaceBucket, list[Path]] = {bucket: [] for bucket in SURFACE_BUCKETS} for ft_name, spec in file_types.items(): diff --git a/src/reporails_cli/core/discovery/agent_markers.py b/src/reporails_cli/core/discovery/agent_markers.py index 2275c60..69bacca 100644 --- a/src/reporails_cli/core/discovery/agent_markers.py +++ b/src/reporails_cli/core/discovery/agent_markers.py @@ -14,6 +14,7 @@ from typing import TYPE_CHECKING from reporails_cli.core.discovery import plugin_roots as _plugin_roots +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.walk import list_dir if TYPE_CHECKING: @@ -98,7 +99,7 @@ def agent_has_marker( target: Path, agent_type: AgentType, registry: dict[str, AgentType], - exclude_dirs: frozenset[str], + exclusions: RunExclusions, ) -> bool: """Fast existence check — does this agent likely apply at target? @@ -107,5 +108,5 @@ def agent_has_marker( """ markers = _plugin_roots.plugin_markers(agent_type.instruction_patterns) return scan_marker_at(target, agent_type, registry) or any( - _plugin_roots.find_plugin_roots(target, marker, exclude_dirs) for marker in markers + _plugin_roots.find_plugin_roots(target, marker, exclusions) for marker in markers ) diff --git a/src/reporails_cli/core/discovery/agents.py b/src/reporails_cli/core/discovery/agents.py index e27f142..c6b0db2 100644 --- a/src/reporails_cli/core/discovery/agents.py +++ b/src/reporails_cli/core/discovery/agents.py @@ -21,7 +21,7 @@ ) from reporails_cli.core.discovery.agent_discovery import discover_from_config from reporails_cli.core.discovery.agent_markers import agent_has_marker, dir_prefix_from_glob -from reporails_cli.core.discovery.exclusions import load_project_exclude_dirs +from reporails_cli.core.discovery.exclusions import RunExclusions, load_run_exclusions from reporails_cli.core.discovery.file_aliases import clear_alias_cache, dedupe_with_aliases, store_alias_cache from reporails_cli.core.discovery.read_gates import reads_as_fallback from reporails_cli.core.discovery.surface_config import config_names_agent @@ -30,7 +30,6 @@ from reporails_cli.core.platform.utils.utils import is_rooted_pattern if TYPE_CHECKING: - from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.platform.dto.results import ProjectConfig @@ -152,21 +151,17 @@ def _detect_agent( agent_type: AgentType, *, known: dict[str, AgentType], - project_excludes: frozenset[str], + exclusions: RunExclusions, project_config: ProjectConfig | None, ) -> DetectedAgent | None: """The agent's detected files and directories under `target`, None when it has no scannable footprint.""" agent_id = agent_type.id # Fast marker check — skip agents with no footprint (avoids tree walks) - if not ( - agent_has_marker(target, agent_type, known, project_excludes) or config_names_agent(agent_id, project_config) - ): + if not (agent_has_marker(target, agent_type, known, exclusions) or config_names_agent(agent_id, project_config)): return None # Config-driven discovery from bundled config.yml - config_result = discover_from_config( - target, agent_id, project_excludes, project_config=project_config, repo_scoped=True - ) + config_result = discover_from_config(target, agent_id, exclusions, project_config=project_config, repo_scoped=True) if config_result is None: return None instruction_files, rule_files, config_files = config_result @@ -202,8 +197,7 @@ def detect_agents(target: Path) -> list[DetectedAgent]: if cached is not None: return cached - # Load project exclude_dirs early so discovery skips noise directories - project_excludes = load_project_exclude_dirs(target) + exclusions = load_run_exclusions(target) # Load project config for per-surface include/exclude + fallback filenames project_config = get_project_config(target) @@ -217,7 +211,7 @@ def detect_agents(target: Path) -> list[DetectedAgent]: target, agent_type, known=known, - project_excludes=project_excludes, + exclusions=exclusions, project_config=project_config, ) ) diff --git a/src/reporails_cli/core/discovery/exclusions.py b/src/reporails_cli/core/discovery/exclusions.py index 455f4d5..c5a0e80 100644 --- a/src/reporails_cli/core/discovery/exclusions.py +++ b/src/reporails_cli/core/discovery/exclusions.py @@ -2,14 +2,15 @@ from __future__ import annotations +import os from collections.abc import Iterable from dataclasses import dataclass, replace from functools import cached_property from pathlib import Path -from reporails_cli.core.discovery.walk import safe_resolve +from reporails_cli.core.discovery.walk import child_real, safe_resolve from reporails_cli.core.platform.config.config import get_project_config -from reporails_cli.core.platform.policy.exclusion import excluded_by_name +from reporails_cli.core.platform.policy.exclusion import FolderValue, parse_folder_value from reporails_cli.core.platform.utils.utils import matches_any_glob DEFAULT_EXCLUDE_DIRS: frozenset[str] = frozenset( @@ -51,14 +52,13 @@ class RunExclusions: """What a run leaves out. `dirs` and `files` are the user's folders and file globs (command line plus config). - `walk_dirs` adds the built-in defaults and is what directory walks prune. + `prunes` is what directory walks ask: it adds the built-in defaults to the user's folders. `named` holds the resolved files the user named; they are never left out. """ root: Path - dirs: frozenset[str] = frozenset() + dirs: tuple[FolderValue, ...] = () files: tuple[str, ...] = () - walk_dirs: frozenset[str] = DEFAULT_EXCLUDE_DIRS named: frozenset[Path] = frozenset() @classmethod @@ -66,6 +66,13 @@ def none(cls, root: Path) -> RunExclusions: """No user exclusions; walks still prune the built-in defaults.""" return cls(root=root) + @classmethod + def parse(cls, root: Path, dirs: Iterable[str], files: Iterable[str]) -> RunExclusions: + """The exclusions of the folder values `dirs` and file globs `files`, without repeats, in the order given.""" + roots = (root, safe_resolve(root)) + folders = dict.fromkeys(v for v in (parse_folder_value(d, roots) for d in dirs) if v is not None) + return cls(root=root, dirs=tuple(folders), files=tuple(dict.fromkeys(files))) + def with_named(self, paths: Iterable[Path]) -> RunExclusions: """A copy that also treats `paths` as named by the user.""" return replace(self, named=self.named | {safe_resolve(p) for p in paths}) @@ -77,25 +84,88 @@ def _roots(self) -> tuple[Path, ...]: return (self.root,) if resolved == self.root else (self.root, resolved) @cached_property - def feature_walk_dirs(self) -> frozenset[str]: - """The built-in defaults plus the user's folder names that hold no named file.""" - return DEFAULT_EXCLUDE_DIRS | { - d for d in self.dirs if "/" not in d and not any(self._dir_matches(p, d) for p in self.named) - } + def _prefixes(self) -> tuple[str, ...]: + """The root as given and resolved, each as a string ending in a separator.""" + return tuple(dict.fromkeys(os.fspath(root).rstrip(os.sep) + os.sep for root in self._roots)) + + @cached_property + def _named_homes(self) -> frozenset[str]: + """Every named file and every folder above one.""" + return frozenset(str(item) for path in self.named for item in (path, *path.parents)) + + def _views(self, path: str, real: str) -> tuple[str, ...]: + """The root-relative posix forms of `path` (as given) and `real` (canonical) that a folder rule is tested on. + + Each is tried against the root as given and resolved. A form outside both roots, and the + root itself, give nothing. + """ + views: dict[str, None] = {} + for form in (path,) if real == path else (path, real): + for prefix in self._prefixes: + if form.startswith(prefix) and len(form) > len(prefix): + rel = form[len(prefix) :] + views[rel if os.sep == "/" else rel.replace(os.sep, "/")] = None + return tuple(views) + + @staticmethod + def _folder_hit(views: tuple[str, ...], dirs: tuple[FolderValue, ...]) -> bool: + """True when a folder rule in `dirs` leaves out any of the root-relative `views`.""" + return any(d.holds(view) for d in dirs for view in views) + + @staticmethod + def _default_pruned(name: str) -> bool: + """True when the listed folder `name` is a built-in default.""" + return name in DEFAULT_EXCLUDE_DIRS + + def _user_pruned(self, path: str, real: str) -> bool: + """True when a user folder rule holds the folder `path` (canonical `real`) and it holds no named file.""" + if not self.dirs or not self._folder_hit(self._views(path, real), self.dirs): + return False + return not (self.named and (path in self._named_homes or real in self._named_homes)) + + def prunes(self, path: str, name: str, real: str) -> bool: + """True when a walk skips the listed folder `path` named `name` (`real` is its canonical path). + + The built-in defaults are skipped by name. A folder the user left out is skipped too, unless + it holds a file the user named. + """ + return self._default_pruned(name) or self._user_pruned(path, real) + + def covers(self, path: Path) -> bool: + """True when a walk never reaches `path`: it, or a folder above it below the root, is skipped. + + Tests each segment from the root down for a built-in default, then for a user folder. A + file at the end of `path` is listed by a walk, not skipped like a folder. + """ + for root in self._roots: + if path.is_relative_to(root): + parts = path.relative_to(root).parts + folder = os.fspath(root) + real = os.fspath(self._roots[-1]) + for depth, part in enumerate(parts, 1): + folder = os.path.join(folder, part) + pruned = self._default_pruned(part) + if not pruned and self.dirs: + real = child_real(folder, part, real, os.path.islink(folder)) + pruned = self._user_pruned(folder, real) + if pruned: + return depth < len(parts) or not os.path.isfile(folder) + return False + return False def leaves_out(self, path: Path) -> bool: """True when `path` sits in a folder the user left out or matches a file glob the user left out. - A folder value without `/` is a folder name matched at any depth; one with `/` is a folder - relative to the root. A named path is never left out. Built-in defaults never count. The path - is tried as given and resolved, against the root as given and resolved. + A folder name is matched at any depth; a folder path is relative to the root. A named path is + never left out. Built-in defaults never count. The path is tried as given and resolved, + against the root as given and resolved. """ if not self.dirs and not self.files: return False resolved = safe_resolve(path) if self.named and resolved in self.named: return False - return self._hit(path, resolved, tuple(self.dirs), self.files) + return self._hit(path, resolved, self.dirs, self.files) def record(self, *, narrowed: bool = False) -> tuple[tuple[str, ...], tuple[str, ...]]: """The `(folders, file globs)` to report: every user pattern, sorted, without repeats. @@ -104,41 +174,27 @@ def record(self, *, narrowed: bool = False) -> tuple[tuple[str, ...], tuple[str, dropped; a pattern that matches nothing stays. """ named = self.named if narrowed else frozenset() - dirs = sorted(d for d in self.dirs if not any(self._dir_matches(p, d) for p in named)) + dirs = sorted({d.text for d in self.dirs if not any(self._dir_matches(p, d) for p in named)}) files = sorted({g for g in self.files if not any(self._glob_matches(p, g) for p in named)}) return tuple(dirs), tuple(files) - def _dir_matches(self, path: Path, name: str) -> bool: - return self._hit(path, safe_resolve(path), (name,), ()) + def _dir_matches(self, path: Path, folder: FolderValue) -> bool: + return self._hit(path, safe_resolve(path), (folder,), ()) def _glob_matches(self, path: Path, glob: str) -> bool: return self._hit(path, safe_resolve(path), (), (glob,)) - def _hit(self, path: Path, resolved: Path, dirs: tuple[str, ...], files: tuple[str, ...]) -> bool: + def _hit(self, path: Path, resolved: Path, dirs: tuple[FolderValue, ...], files: tuple[str, ...]) -> bool: """True when `path` (or its `resolved` form) falls under one of `dirs` or matches one of `files`.""" - names = frozenset(d for d in dirs if "/" not in d) - prefixes = [tuple(d.split("/")) for d in dirs if "/" in d] - for candidate in (path,) if resolved == path else (path, resolved): - for root in self._roots: - try: - parts = candidate.relative_to(root).parts - except ValueError: - parts = () - if names and excluded_by_name(parts, names): - return True - if any(parts[: len(prefix)] == prefix for prefix in prefixes): - return True - if files and matches_any_glob(candidate, list(files), root): - return True - return False - - -def _normalized_dir(value: str) -> str: - """A folder value without a leading `./` or trailing `/`; empty when nothing is left.""" - value = value.strip() - while value.startswith("./"): - value = value[2:] - return value.rstrip("/") + if dirs and self._folder_hit(self._views(os.fspath(path), os.fspath(resolved)), dirs): + return True + if not files: + return False + return any( + matches_any_glob(candidate, list(files), root) + for candidate in dict.fromkeys((path, resolved)) + for root in self._roots + ) def load_run_exclusions( @@ -146,13 +202,6 @@ def load_run_exclusions( ) -> RunExclusions: """The run's exclusions at `target`: command-line patterns joined with the project's config.""" config = get_project_config(target) - dirs = frozenset(d for d in map(_normalized_dir, (*(flag_dirs or ()), *config.exclude_dirs)) if d) - files = tuple(dict.fromkeys((*(flag_files or ()), *config.exclude_files))) - return RunExclusions( - root=target, dirs=dirs, files=files, walk_dirs=DEFAULT_EXCLUDE_DIRS | {d for d in dirs if "/" not in d} + return RunExclusions.parse( + target, (*(flag_dirs or ()), *config.exclude_dirs), (*(flag_files or ()), *config.exclude_files) ) - - -def load_project_exclude_dirs(target: Path) -> frozenset[str]: - """The built-in default excludes plus the project's `exclude_dirs`.""" - return load_run_exclusions(target).walk_dirs diff --git a/src/reporails_cli/core/discovery/features.py b/src/reporails_cli/core/discovery/features.py index accf14a..e07a882 100644 --- a/src/reporails_cli/core/discovery/features.py +++ b/src/reporails_cli/core/discovery/features.py @@ -130,8 +130,7 @@ class _SurfaceScan: """What one feature detection shares: the project's excluded folders and the plugin roots already found, so each plugin marker is searched for once.""" - exclude_dirs: frozenset[str] - exclusions: RunExclusions | None = None + exclusions: RunExclusions roots_by_marker: dict[str, list[Path]] = field(default_factory=dict) hook_configs: dict[Path, HookConfig | None] = field(default_factory=dict) @@ -143,15 +142,13 @@ def hook_config(self, path: Path) -> HookConfig | None: def keep(self, paths: list[Path]) -> list[Path]: """`paths` without the files the run leaves out (a file the user named is never left out).""" - if self.exclusions is None: - return paths return [p for p in paths if not self.exclusions.leaves_out(p)] @classmethod def for_target(cls, target: Path, exclusions: RunExclusions | None = None) -> _SurfaceScan: """The scan for `target` under `exclusions` (the project's own when none is given).""" exclusions = exclusions or load_run_exclusions(target) - return cls(exclude_dirs=exclusions.feature_walk_dirs, exclusions=exclusions) + return cls(exclusions=exclusions) def _scan_for(target: Path, scan: _SurfaceScan | None) -> _SurfaceScan: @@ -170,7 +167,7 @@ def _surface_patterns( return expand_plugin_patterns( [p for p in patterns if not is_rooted_pattern(p)] if repo_only else patterns, target, - scan.exclude_dirs, + scan.exclusions, scan.roots_by_marker, ) @@ -194,7 +191,7 @@ def _agent_surface_files( patterns = _surface_patterns(target, spec, scan) if not patterns: return [] - return scan.keep(glob_file_type_patterns(target, patterns, extract_properties(spec), scan.exclude_dirs)) + return scan.keep(glob_file_type_patterns(target, patterns, extract_properties(spec), scan.exclusions)) def _agent_has_surface(target: Path, agent_id: str, surface_name: str, scan: _SurfaceScan | None = None) -> bool: @@ -230,7 +227,7 @@ def _hook_scope_matches(target: Path, agent_id: str, scan: _SurfaceScan, *, repo patterns = _surface_patterns( target, {"scopes": {scope: scope_spec}} if scopes else spec, scan, repo_only=repo_only ) - for match in scan.keep(glob_file_type_patterns(target, patterns, props, scan.exclude_dirs)): + for match in scan.keep(glob_file_type_patterns(target, patterns, props, scan.exclusions)): if match not in seen: seen.add(match) found.append((scope, match)) @@ -318,7 +315,7 @@ def _agent_nested_instruction_files(target: Path, agent_id: str, scan: _SurfaceS patterns = _surface_patterns(target, ft, scan) if not patterns: continue - found.extend(scan.keep(glob_file_type_patterns(target, patterns, extract_properties(ft), scan.exclude_dirs))) + found.extend(scan.keep(glob_file_type_patterns(target, patterns, extract_properties(ft), scan.exclusions))) return found diff --git a/src/reporails_cli/core/discovery/memory_locator.py b/src/reporails_cli/core/discovery/memory_locator.py index 21a5075..dbf522f 100644 --- a/src/reporails_cli/core/discovery/memory_locator.py +++ b/src/reporails_cli/core/discovery/memory_locator.py @@ -8,7 +8,7 @@ from reporails_cli.core.discovery.agent_config import load_config_file_types from reporails_cli.core.discovery.agent_discovery import MEMORY_SURFACES, glob_file_type_patterns -from reporails_cli.core.discovery.exclusions import load_project_exclude_dirs +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.platform.contract.json_value import JsonObject, as_object, pattern_list logger = logging.getLogger(__name__) @@ -27,16 +27,14 @@ class MemoryEntry: body: str -def memory_entries_for_agent( - agent: str, project_root: Path, exclude_dirs: frozenset[str] | None = None -) -> list[MemoryEntry]: +def memory_entries_for_agent(agent: str, project_root: Path, exclusions: RunExclusions) -> list[MemoryEntry]: """Enumerate memory entries declared by an agent's config. Returns `[]` when the agent has no memory surface OR the surface exists but holds no entries. Callers should treat empty lists as "nothing to validate", not "agent unknown" — `discover_from_config` - handles agent presence detection separately. `exclude_dirs` is the folders the walk - does not enter; `None` reads the project's configured `exclude_dirs`. + handles agent presence detection separately. `exclusions` holds the folders the walk + does not enter. """ file_types = load_config_file_types(agent) if not file_types: @@ -46,7 +44,7 @@ def memory_entries_for_agent( spec = file_types.get(capability) if not isinstance(spec, dict): continue - entries.extend(_entries_from_directory_globs(agent, capability, spec, project_root, exclude_dirs)) + entries.extend(_entries_from_directory_globs(agent, capability, spec, project_root, exclusions)) return entries @@ -55,7 +53,7 @@ def _entries_from_directory_globs( capability: str, spec: JsonObject, project_root: Path, - exclude_dirs: frozenset[str] | None = None, + exclusions: RunExclusions, ) -> list[MemoryEntry]: """Enumerate `*.md` files inside directory-glob patterns (claude shape). @@ -73,9 +71,7 @@ def _entries_from_directory_globs( return [] # Pass empty properties — directory-glob dispatch in glob_file_type_patterns # only needs the patterns themselves for trailing-slash enumeration. - if exclude_dirs is None: - exclude_dirs = load_project_exclude_dirs(project_root) - paths = glob_file_type_patterns(project_root, patterns, {}, exclude_dirs) + paths = glob_file_type_patterns(project_root, patterns, {}, exclusions) entries: list[MemoryEntry] = [] for path in paths: try: diff --git a/src/reporails_cli/core/discovery/plugin_roots.py b/src/reporails_cli/core/discovery/plugin_roots.py index 94760dd..f649d46 100644 --- a/src/reporails_cli/core/discovery/plugin_roots.py +++ b/src/reporails_cli/core/discovery/plugin_roots.py @@ -12,10 +12,11 @@ from __future__ import annotations +import os from collections.abc import Callable from pathlib import Path -from reporails_cli.core.discovery.walk import ListedEntry, list_dir +from reporails_cli.core.discovery.walk import ListedEntry, Pruner, child_real, list_dir from reporails_cli.core.platform.contract.json_value import JsonObject, pattern_list from reporails_cli.core.platform.utils.utils import config_pattern_matches @@ -57,31 +58,34 @@ def _holds_marker(directory: Path, marker: str) -> bool: return False -def _is_searchable_dir(entry: ListedEntry, exclude_dirs: frozenset[str]) -> bool: - if entry.name.startswith(".") or entry.name in exclude_dirs: - return False - return entry.is_dir and not entry.is_symlink +def _searchable_real(entry: ListedEntry, skip: Pruner, parent_real: str) -> str | None: + """The canonical path of a listed folder worth searching (not hidden, not a symlink, not pruned), else `None`.""" + if entry.name.startswith(".") or not entry.is_dir or entry.is_symlink: + return None + real = child_real(entry.path, entry.name, parent_real, False) + return None if skip.prunes(entry.path, entry.name, real) else real -def find_plugin_roots(target: Path, marker: str, exclude_dirs: frozenset[str]) -> list[Path]: +def find_plugin_roots(target: Path, marker: str, skip: Pruner) -> list[Path]: """Directories at or below `target` (up to a few levels) that hold `marker`. - Hidden directories and excluded ones are not searched; `target` itself is always + Hidden directories and ones `skip` prunes are not searched; `target` itself is always checked. Sorted, so the expansion order is stable. """ root = target if target.is_dir() else target.parent found: list[Path] = [] - frontier = [root] + frontier = [(root, os.path.realpath(root))] for depth in range(_MAX_ROOT_DEPTH + 1): - next_frontier: list[Path] = [] - for directory in frontier: + next_frontier: list[tuple[Path, str]] = [] + for directory, directory_real in frontier: if _holds_marker(directory, marker): found.append(directory) if depth == _MAX_ROOT_DEPTH: continue - next_frontier.extend( - Path(entry.path) for entry in list_dir(str(directory)) or () if _is_searchable_dir(entry, exclude_dirs) - ) + for entry in list_dir(str(directory)) or (): + real = _searchable_real(entry, skip, directory_real) + if real is not None: + next_frontier.append((Path(entry.path), real)) frontier = next_frontier return sorted(found) @@ -89,7 +93,7 @@ def find_plugin_roots(target: Path, marker: str, exclude_dirs: frozenset[str]) - def expand_plugin_patterns( patterns: list[str], target: Path, - exclude_dirs: frozenset[str], + skip: Pruner, roots_by_marker: dict[str, list[Path]] | None = None, ) -> list[str]: """Replace each `/pattern` with one root-relative pattern per plugin root found @@ -106,7 +110,7 @@ def expand_plugin_patterns( continue marker, rest = split if marker not in roots_by_marker: - roots_by_marker[marker] = find_plugin_roots(root, marker, exclude_dirs) + roots_by_marker[marker] = find_plugin_roots(root, marker, skip) for plugin_root in roots_by_marker[marker]: prefix = plugin_root.relative_to(root).as_posix() out.append(rest if prefix == "." else f"{prefix}/{rest}") diff --git a/src/reporails_cli/core/discovery/walk.py b/src/reporails_cli/core/discovery/walk.py index f42f560..6c9adf2 100644 --- a/src/reporails_cli/core/discovery/walk.py +++ b/src/reporails_cli/core/discovery/walk.py @@ -20,9 +20,9 @@ import os from collections.abc import Callable, Iterator from pathlib import Path, PurePosixPath -from typing import NamedTuple +from typing import NamedTuple, Protocol -from reporails_cli.core.platform.utils.utils import brace_alternatives, glob_matches +from reporails_cli.core.platform.contract.glob_pattern import GLOB_CHARS, brace_alternatives, glob_matches logger = logging.getLogger(__name__) @@ -75,6 +75,13 @@ def is_under(path: Path, root: Path) -> bool: return False +class Pruner(Protocol): # pylint: disable=too-few-public-methods # a protocol of one question + """Which folders a walk skips.""" + + def prunes(self, path: str, name: str, real: str) -> bool: + """True when the listed folder `path` named `name` (canonical path `real`) is skipped.""" + + class ListedEntry(NamedTuple): """One directory entry, resolved once: name, path, whether it resolves to a regular file or to a directory (symlinks followed), and whether it is itself a symlink.""" @@ -145,26 +152,29 @@ def list_dir(path: str) -> list[ListedEntry] | None: return listed -def _descend_real( - entry: ListedEntry, exclude_dirs: frozenset[str], visited_real: set[str], parent_real: str -) -> str | None: +def child_real(path: str, name: str, parent_real: str, is_symlink: bool) -> str: + """The canonical path of the listed child `name` at `path` of a folder whose canonical path is `parent_real`. + + A plain folder's canonical path is its parent's plus its name; a symlink or a Windows + junction is resolved. + """ + return os.path.realpath(path) if is_symlink or os.path.isjunction(path) else os.path.join(parent_real, name) + + +def _descend_real(entry: ListedEntry, skip: Pruner, visited_real: set[str], parent_real: str) -> str | None: """The canonical path of a listed entry worth descending into, else `None`. Follows directory symlinks (so symlinked skills/rules surface) and tracks canonical paths in `visited_real` to break cycles — each physical directory is entered at most once across the walk. - - A plain directory's canonical path is its parent's canonical path plus its name, so - only a symlink pays for a path resolution; resolving every directory on a large tree - cost seconds per walk. """ - if entry.name in exclude_dirs or not entry.is_dir: + if not entry.is_dir: return None try: - real = os.path.realpath(entry.path) if entry.is_symlink else os.path.join(parent_real, entry.name) + real = child_real(entry.path, entry.name, parent_real, entry.is_symlink) except OSError: return None - if real in visited_real: + if skip.prunes(entry.path, entry.name, real) or real in visited_real: return None visited_real.add(real) return real @@ -194,11 +204,11 @@ def _same_file(first: Path, second: Path) -> bool: return False -def walk_glob(root: Path, filename: str, exclude_dirs: frozenset[str]) -> list[Path]: +def walk_glob(root: Path, filename: str, skip: Pruner) -> list[Path]: """Every regular file under root that answers to the documented name `filename`, skipping - excluded dirs. + the folders `skip` prunes. - Much faster than Path.glob("**/name") because it prunes excluded + Much faster than Path.glob("**/name") because it prunes skipped subtrees during traversal instead of filtering afterwards. A file is found by its exact name, or by a differently-cased name only when the filesystem @@ -206,29 +216,24 @@ def walk_glob(root: Path, filename: str, exclude_dirs: frozenset[str]) -> list[P the documented spelling; on a case-sensitive filesystem a lowercase `agents.md` is not `AGENTS.md`. """ - found = _walk(root, exclude_dirs, lambda path: documented_path(path, filename) is not None) + found = _walk(root, skip, lambda path: documented_path(path, filename) is not None) return [named for path in found if (named := documented_path(path, filename)) is not None] -def walk_markdown(root: Path, exclude_dirs: frozenset[str]) -> Iterator[Path]: +def walk_markdown(root: Path, skip: Pruner) -> Iterator[Path]: """Yield every regular `.md` file under root, following symlinks safely and not - entering a directory named in `exclude_dirs`.""" - yield from _walk(root, exclude_dirs, lambda p: p.suffix == ".md") + entering a folder `skip` prunes.""" + yield from _walk(root, skip, lambda p: p.suffix == ".md") -def walk_files( - root: Path, exclude_dirs: frozenset[str], predicate: Callable[[Path], bool] | None = None -) -> Iterator[Path]: +def walk_files(root: Path, skip: Pruner, predicate: Callable[[Path], bool] | None = None) -> Iterator[Path]: """Yield every regular file under root, following symlinks safely and not entering a - directory named in `exclude_dirs`. + folder `skip` prunes. Optional `predicate` further filters the yielded files (e.g. text-file detection for the regex runner's catch-all fallback). """ - yield from _walk(root, exclude_dirs, predicate) - - -_GLOB_CHARS = frozenset("*?[{") + yield from _walk(root, skip, predicate) def glob_prefix_dir(pattern: str) -> str: @@ -237,24 +242,33 @@ def glob_prefix_dir(pattern: str) -> str: gives `.claude/skills`; `CLAUDE.md` and `**/x.md` give `""`.""" prefix: list[str] = [] for segment in pattern.lstrip("/").split("/")[:-1]: - if segment in ("", ".", "..") or _GLOB_CHARS & set(segment): + if segment in ("", ".", "..") or GLOB_CHARS & set(segment): break prefix.append(segment) return "/".join(prefix) -def _blocked_prefix(glob: str, exclude_dirs: frozenset[str]) -> bool: - """Whether a pattern's leading folders include an excluded folder or a `..` step, so it +def _blocked_prefix(root: str, glob: str, skip: Pruner) -> bool: + """Whether a pattern's leading folders include a pruned folder or a `..` step, so it matches nothing.""" - folders = PurePosixPath(glob).parts[:-1] - return ".." in PurePosixPath(glob).parts or bool(exclude_dirs.intersection(_literal_folders(folders))) + parts = PurePosixPath(glob).parts + if ".." in parts: + return True + literal = _literal_folders(parts[:-1]) + folder, real = root, os.path.realpath(root) + for name in literal: + folder = os.path.join(folder, name) + real = child_real(folder, name, real, os.path.islink(folder)) + if skip.prunes(folder, name, real): + return True + return False def _literal_folders(folders: tuple[str, ...]) -> tuple[str, ...]: """The leading folders of a pattern up to its first glob segment.""" out: list[str] = [] for segment in folders: - if _GLOB_CHARS & set(segment): + if GLOB_CHARS & set(segment): break out.append(segment) return tuple(out) @@ -290,18 +304,16 @@ def _literal_matches(root: Path, glob: str, ignore_case: bool) -> Iterator[Path] yield Path(entry.path) -def walk_glob_matches( - root: Path, pattern: str, exclude_dirs: frozenset[str], *, ignore_case: bool = False -) -> Iterator[Path]: +def walk_glob_matches(root: Path, pattern: str, skip: Pruner, *, ignore_case: bool = False) -> Iterator[Path]: """Yield every regular file under `root` matching `pattern` anchored at `root` (a leading `/` is dropped), walking only the pattern's literal prefix folder. A symlinked prefix folder is - entered; excluded folders are skipped below it. A pattern with no glob character is checked as + entered; folders `skip` prunes are skipped below it. A pattern with no glob character is checked as one path, without a walk. With `ignore_case` the pattern matches names and prefix folders without regard to case.""" glob = pattern.lstrip("/") - if _blocked_prefix(glob, exclude_dirs): + if _blocked_prefix(str(root), glob, skip): return - if not _GLOB_CHARS & set(glob): + if not GLOB_CHARS & set(glob): yield from _literal_matches(root, glob, ignore_case) return prefix = glob_prefix_dir(glob) @@ -317,7 +329,7 @@ def walk_glob_matches( start_prefix = str(start) start_rel = os.path.relpath(start, root).replace(os.sep, "/") base = "" if start_rel == "." else start_rel + "/" - for entry in _walk_files(start, exclude_dirs, report_loops=False): + for entry in _walk_files(start, skip, report_loops=False): name = entry.name.lower() if ignore_case else entry.name if leaves is not None and not any(glob_matches(name, leaf, anchored=True) for leaf in leaves): continue @@ -333,15 +345,15 @@ def _leaf_globs(glob: str) -> tuple[str, ...] | None: return None if "**" in leaves or "" in leaves else leaves -def _walk(root: Path, exclude_dirs: frozenset[str], predicate: Callable[[Path], bool] | None) -> Iterator[Path]: +def _walk(root: Path, skip: Pruner, predicate: Callable[[Path], bool] | None) -> Iterator[Path]: """Every regular file under `root` (see `_walk_files`), as a `Path` that passes `predicate`.""" - for entry in _walk_files(root, exclude_dirs): + for entry in _walk_files(root, skip): full_path = Path(entry.path) if predicate is None or predicate(full_path): yield full_path -def _walk_files(root: Path, exclude_dirs: frozenset[str], *, report_loops: bool = True) -> Iterator[ListedEntry]: +def _walk_files(root: Path, skip: Pruner, *, report_loops: bool = True) -> Iterator[ListedEntry]: """Shared walker — top-down over listed directories with canonical-path cycle tracking. Yields each listed regular file. A plain file is taken from the listing as it stands; a @@ -359,7 +371,7 @@ def _walk_files(root: Path, exclude_dirs: frozenset[str], *, report_loops: bool kept: list[tuple[str, str]] = [] for entry in list_dir(current) or (): if entry.is_dir: - real = _descend_real(entry, exclude_dirs, visited_real, current_real) + real = _descend_real(entry, skip, visited_real, current_real) if real is not None: kept.append((entry.path, real)) elif not entry.is_symlink: diff --git a/src/reporails_cli/core/lint/mechanical/checks.py b/src/reporails_cli/core/lint/mechanical/checks.py index 8c9c940..5c48293 100644 --- a/src/reporails_cli/core/lint/mechanical/checks.py +++ b/src/reporails_cli/core/lint/mechanical/checks.py @@ -76,7 +76,7 @@ def _resolve_glob_targets(pattern: str, root: Path, exclusions: RunExclusions | `get_target_files` bounds the result back to in-scope instruction files. """ exclusions = exclusions or load_run_exclusions(root) - return [m for m in walk_glob_matches(root, pattern, exclusions.feature_walk_dirs) if not exclusions.leaves_out(m)] + return [m for m in walk_glob_matches(root, pattern, exclusions) if not exclusions.leaves_out(m)] def get_target_files( diff --git a/src/reporails_cli/core/lint/mechanical/checks_absent.py b/src/reporails_cli/core/lint/mechanical/checks_absent.py index 513fa1e..3b92579 100644 --- a/src/reporails_cli/core/lint/mechanical/checks_absent.py +++ b/src/reporails_cli/core/lint/mechanical/checks_absent.py @@ -14,6 +14,7 @@ run_exclusions, target_texts, ) +from reporails_cli.core.platform.contract.glob_pattern import GLOB_CHARS from reporails_cli.core.platform.dto.checks import CheckResult from reporails_cli.core.platform.dto.models import ClassifiedFile from reporails_cli.core.platform.policy.matching import _prop_matches @@ -30,7 +31,7 @@ def _scope_dir_from_glob(glob_pattern: str) -> str: parts = glob_pattern.replace("\\", "/").split("/") dirs: list[str] = [] for part in parts: - if any(c in part for c in "*?[{"): + if GLOB_CHARS & set(part): break dirs.append(part) return "/".join(dirs) @@ -90,9 +91,7 @@ def file_absent( scope_root = root / scope_dir direct_path = scope_root / pattern matches = [ - m - for m in walk_glob_matches(scope_root, f"**/{pattern}", exclusions.feature_walk_dirs) - if not exclusions.leaves_out(m) + m for m in walk_glob_matches(scope_root, f"**/{pattern}", exclusions) if not exclusions.leaves_out(m) ] else: direct_path = root / pattern diff --git a/src/reporails_cli/core/lint/mechanical/checks_advanced.py b/src/reporails_cli/core/lint/mechanical/checks_advanced.py index 20226ab..b1cc1b9 100644 --- a/src/reporails_cli/core/lint/mechanical/checks_advanced.py +++ b/src/reporails_cli/core/lint/mechanical/checks_advanced.py @@ -10,7 +10,7 @@ from collections.abc import Sequence from pathlib import Path -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.walk import safe_resolve, walk_files, walk_glob_matches from reporails_cli.core.lint.mechanical.checks import ( CheckArgs, @@ -347,9 +347,10 @@ def skill_entrypoint_present( entry = str(arg_value(args, "entry", "SKILL.md")) roots = one_level_skills_roots(classified_files, root) missing: list[str] = [] + exclusions = run_exclusions(args, root) for skills_root in sorted(roots): for sub in slot_subfolders(skills_root): - if not (sub / entry).is_file(): + if not (sub / entry).is_file() and not exclusions.leaves_out(sub): rel = sub.relative_to(root).as_posix() if sub.is_relative_to(root) else sub.name missing.append(rel) if not missing: @@ -385,7 +386,7 @@ def frontmatter_valid_glob( exclusions = run_exclusions(args, root) subjects = [ f - for f in walk_files(target, exclusions.feature_walk_dirs, lambda f: f.suffix in MARKDOWN_RULE_SUFFIXES) + for f in walk_files(target, exclusions, lambda f: f.suffix in MARKDOWN_RULE_SUFFIXES) if not exclusions.leaves_out(f) ] if classified_files: @@ -428,12 +429,13 @@ def _validate_file_globs( key = path_filter_key(f, root) raw = read.data.get(key) if key else next((read.data[k] for k in _PATH_KEYS if read.data.get(k)), None) paths: Sequence[JsonValue] = split_top_level_commas(raw) if isinstance(raw, str) else as_list(raw) + defaults = RunExclusions.none(root) for p in paths: if not isinstance(p, str): return CheckResult(passed=False, message=f"{f.name}: non-string path: {p}") if p.count("[") != p.count("]"): return CheckResult(passed=False, message=f"{f.name}: unbalanced brackets: {p}") - if require_matches and next(walk_glob_matches(root, p, DEFAULT_EXCLUDE_DIRS), None) is None: + if require_matches and next(walk_glob_matches(root, p, defaults), None) is None: unresolved.append((f"{f.relative_to(root).as_posix()}:{_frontmatter_line(read.block, p)}", p)) return None diff --git a/src/reporails_cli/core/lint/regex/expect.py b/src/reporails_cli/core/lint/regex/expect.py index 55fc948..b40b4a2 100644 --- a/src/reporails_cli/core/lint/regex/expect.py +++ b/src/reporails_cli/core/lint/regex/expect.py @@ -8,8 +8,8 @@ from reporails_cli.core.discovery.walk import safe_resolve from reporails_cli.core.lint.regex.compiler import CompiledCheck from reporails_cli.core.lint.regex.not_evaluated import record_not_evaluated, rule_id_of +from reporails_cli.core.platform.contract.glob_pattern import glob_matches from reporails_cli.core.platform.dto.models import LocalFinding -from reporails_cli.core.platform.utils.utils import glob_matches @dataclass(frozen=True) diff --git a/src/reporails_cli/core/lint/regex/runner.py b/src/reporails_cli/core/lint/regex/runner.py index 30addd0..d233648 100644 --- a/src/reporails_cli/core/lint/regex/runner.py +++ b/src/reporails_cli/core/lint/regex/runner.py @@ -7,7 +7,7 @@ import regex as re -from reporails_cli.core.discovery.exclusions import load_project_exclude_dirs +from reporails_cli.core.discovery.exclusions import load_run_exclusions from reporails_cli.core.discovery.walk import safe_resolve, walk_files, walk_markdown from reporails_cli.core.lint.regex import matching from reporails_cli.core.lint.regex.compiler import ( @@ -52,7 +52,7 @@ def _resolve_scan_targets( return targets scan_dir = target if target.is_dir() else target.parent - excluded = load_project_exclude_dirs(scan_dir) + excluded = load_run_exclusions(scan_dir) targets = list(walk_markdown(scan_dir, excluded)) if not targets: targets = list(walk_files(scan_dir, excluded, _is_text_file)) diff --git a/src/reporails_cli/core/pipeline/expected_loads.py b/src/reporails_cli/core/pipeline/expected_loads.py index 3810eab..baab113 100644 --- a/src/reporails_cli/core/pipeline/expected_loads.py +++ b/src/reporails_cli/core/pipeline/expected_loads.py @@ -73,7 +73,7 @@ def expected_loads(target: Path) -> JsonObject: root, AGENT, gated_file_types(AGENT), - exclusions.walk_dirs, + exclusions, project_config=project_config, ) skipped = [SkippedFile(path, "setting_off", None) for path in switched_off] @@ -110,7 +110,7 @@ def _loaded_files( """The non-empty instruction and rule files Claude Code loads (the user's own included), the instruction files among them, and the launch files the `instructionFiles` setting turns off (each with its file type).""" - found = discover_from_config(root, AGENT, exclusions.walk_dirs, project_config=project_config, repo_scoped=False) + found = discover_from_config(root, AGENT, exclusions, project_config=project_config, repo_scoped=False) instruction, rules = (set(bucket) for bucket in found[:2]) if found is not None else (set(), set()) files: list[JsonValue] = [] read_instructions: dict[Path, str] = {} diff --git a/src/reporails_cli/core/pipeline/mapping.py b/src/reporails_cli/core/pipeline/mapping.py index adf40b1..f85e3ee 100644 --- a/src/reporails_cli/core/pipeline/mapping.py +++ b/src/reporails_cli/core/pipeline/mapping.py @@ -50,6 +50,8 @@ from reporails_cli.core.platform.observability.log_levels import quiet_loggers from reporails_cli.core.platform.runtime.merger import normalize_finding_path +logger = logging.getLogger(__name__) + class ScopeDiscovery(NamedTuple): """What one discovery pass found at a scan root.""" @@ -134,7 +136,12 @@ def resolve_agent_filters( filtered = list(all_detected) exclusions = exclusions or RunExclusions.none(target) filtered = filter_agents_by_exclusions(filtered, exclusions) - return effective, assumed, mixed, _place_named(filtered, all_detected, exclusions, effective, target) + return ( + effective, + assumed, + mixed, + _place_named(filtered, all_detected, exclusions, effective, target, explicit=explicit), + ) def _place_named( @@ -143,12 +150,16 @@ def _place_named( exclusions: RunExclusions, effective_agent: str, target: Path, + *, + explicit: bool, ) -> list[DetectedAgent]: """`filtered` plus the named files that a user pattern matches yet no directory walk listed. Such a file (its folder pruned from the detection walk by a configured folder) goes to the agent that listed it in ``detected``, else to the effective agent under the list its file type - maps to. A named file no user pattern matches is not added. Lists are copied before they grow. + maps to. With an explicit agent, a file another agent lists is not placed, nor is an unlisted + file only another agent's file types classify: those files are not part of the run. A named + file no user pattern matches is not added. Lists are copied before they grow. """ wanted = _unlisted_named(filtered, exclusions) @@ -162,7 +173,15 @@ def _place_named( for a in filtered ] for path in wanted: - owner, kind, listed_path = listed.get(path) or (effective_agent, _kind_of(path, effective_agent, target), path) + found = listed.get(path) + if found is None: + kind = _kind_of(path, effective_agent, target) + if kind is None and explicit and _claimed_by_other(path, effective_agent, target): + continue + found = (effective_agent, kind or "instruction_files", path) + owner, kind, listed_path = found + if explicit and owner != effective_agent: + continue _append_file(out, owner, kind, listed_path) return out @@ -197,19 +216,44 @@ def _by_resolved_path(agents: list[DetectedAgent]) -> dict[Path, tuple[str, str, _KINDS = ("instruction_files", "rule_files", "config_files") -def _kind_of(path: Path, agent: str, target: Path) -> str: - """The file list (``instruction_files``, ``rule_files`` or ``config_files``) `path`'s type maps to.""" +def _claimed_by_other(path: Path, agent: str, target: Path) -> bool: + """True when the file types of a known agent other than `agent` classify `path`.""" + + return any(_claims(path, other, target) for other in get_known_agents() if other != agent) + + +def _claims(path: Path, agent: str, target: Path) -> bool: + """True when the file types of `agent` classify `path`; False when they cannot be read.""" + + try: + return _classified_kind(path, agent, target) is not None + except (OSError, ValueError): + return False + + +def _kind_of(path: Path, agent: str, target: Path) -> str | None: + """The file list (``instruction_files``, ``rule_files`` or ``config_files``) `path`'s type maps to. + + ``None`` when the agent's file types do not classify `path`; ``instruction_files`` when they + cannot be read. + """ try: - declarations = load_file_types(agent, project_root=target) - classified = classify_files(target, [path], declarations) + return _classified_kind(path, agent, target) except (OSError, ValueError): return "instruction_files" + + +def _classified_kind(path: Path, agent: str, target: Path) -> str | None: + """Like `_kind_of`, but raises OSError or ValueError when the agent's file types cannot be read.""" + + declarations = load_file_types(agent, project_root=target) + classified = classify_files(target, [path], declarations) if not classified: - return "instruction_files" + return None declaration = next((d for d in declarations if d.name == classified[0].file_type), None) if declaration is None: - return "instruction_files" + return None bucket = categorize_file_type(list(declaration.patterns), dict(classified[0].properties)) if bucket == "rule": return "rule_files" @@ -249,7 +293,8 @@ def apply_generic_scan( classified = classify_files( target, list(instruction_files), file_types, generic_scanning=True, exclusions=exclusions ) - except (OSError, ValueError): + except (OSError, ValueError) as err: + logger.warning("Generic scanning skipped: %s", err) return [], {} ft_by_path = {normalize_finding_path(str(cf.path), target): cf.file_type for cf in classified} diff --git a/src/reporails_cli/core/platform/config/config.py b/src/reporails_cli/core/platform/config/config.py index d4fdf7c..789a773 100644 --- a/src/reporails_cli/core/platform/config/config.py +++ b/src/reporails_cli/core/platform/config/config.py @@ -36,6 +36,7 @@ ProjectConfig, SubagentHooks, ) +from reporails_cli.core.platform.policy.exclusion import first_folder_value_problem from reporails_cli.core.platform.utils.utils import ( load_yaml_file, non_text_items, @@ -219,7 +220,8 @@ def read_config_file(config_path: Path, check_items: bool = True) -> JsonObject: when the file cannot be read, is not UTF-8, is not valid YAML, or is not a mapping. With `check_items` (the default) it also raises for a setting of the wrong shape: a list holding a non-text item, an `agents` / `surfaces` entry that is not a mapping, or a - `rule_thresholds` value that is not a whole number. A caller + `rule_thresholds` value that is not a whole number, or an `exclude_dirs` item that is not a + folder name or a folder path. A caller that is about to replace such a setting passes False and gets the mapping as written. """ if not config_path.exists(): @@ -233,14 +235,42 @@ def read_config_file(config_path: Path, check_items: bool = True) -> JsonObject: if not isinstance(data, dict): raise ConfigUnreadableError(f"{config_path}: the file is not a mapping of settings") if check_items: - for key in TEXT_LIST_KEYS: - _check_text_list(config_path, key, data.get(key)) - for key in _MAPPING_KEYS: - _check_mapping(config_path, key, data.get(key)) - _check_thresholds(config_path, data.get("rule_thresholds")) + for key in (*TEXT_LIST_KEYS, *_MAPPING_KEYS, "rule_thresholds"): + _check_shape(config_path, key, data.get(key)) + _check_folder_values(config_path, data.get("exclude_dirs")) return data +def _check_shape(config_path: Path, key: str, value: JsonValue) -> None: + """Raise ConfigUnreadableError when `value` is not the shape the setting `key` reads.""" + if key in TEXT_LIST_KEYS: + _check_text_list(config_path, key, value) + elif key in _MAPPING_KEYS: + _check_mapping(config_path, key, value) + elif key == "rule_thresholds": + _check_thresholds(config_path, value) + + +def setting_value_problem(key: str, value: JsonValue) -> str | None: + """The problem with a setting's value, worded without any file path; None when it has none. + + Only `exclude_dirs` has one: an item that is not a folder name or a folder path. + """ + if key != "exclude_dirs" or not isinstance(value, list): + return None + problem = first_folder_value_problem(value) + if problem is None: + return None + return f"exclude_dirs: {problem}; list file patterns under exclude_files" + + +def _check_folder_values(config_path: Path, value: JsonValue) -> None: + """Raise ConfigUnreadableError, naming the file, for the first `exclude_dirs` item that is not a folder.""" + problem = setting_value_problem("exclude_dirs", value) + if problem is not None: + raise ConfigUnreadableError(f"{config_path}: {problem}") + + def _list_keys(config_cls: type) -> tuple[str, ...]: """The fields of a config dataclass that hold a list of names, read from the field types.""" return tuple(name for name, kind in get_type_hints(config_cls).items() if kind == list[str]) diff --git a/src/reporails_cli/core/platform/contract/glob_pattern.py b/src/reporails_cli/core/platform/contract/glob_pattern.py new file mode 100644 index 0000000..f1b843c --- /dev/null +++ b/src/reporails_cli/core/platform/contract/glob_pattern.py @@ -0,0 +1,202 @@ +"""The one path-glob matcher: `**` spans directories, `{a,b}` expands, `*` and `?` stay in one segment.""" + +from __future__ import annotations + +import fnmatch +import re +from collections.abc import Callable +from functools import lru_cache +from pathlib import PurePosixPath +from typing import NamedTuple + +# A segment holding one of these is a pattern, not a plain name. +GLOB_CHARS = frozenset("*?[{") + +_ESCAPES = {"*": "[*]", "?": "[?]", "[": "[[]", "{": "[{]"} + + +def escape_glob(text: str) -> str: + """`text` as a glob that matches only `text` itself. + + Pure function. `*`, `?` and `[` are written as one-character classes (`[*]`, `[?]`, `[[]`), + and `{` as `[{]`, which does not open a brace group. + """ + return "".join(_ESCAPES.get(char, char) for char in text) + + +class _Segment(NamedTuple): + """One glob segment: its text, and a matcher for a path segment.""" + + text: str + matches: Callable[[str], object] + literal: bool + + +class _Shape(NamedTuple): + """A glob split at its `**` segments: the runs of segments between them, and whether it opens or ends with one.""" + + chunks: tuple[tuple[_Segment, ...], ...] + opens: bool + ends: bool + has_star: bool + + +def _segment(text: str) -> _Segment: + """A glob segment; one with no glob character is compared as text.""" + if GLOB_CHARS.isdisjoint(text): + return _Segment(text, text.__eq__, True) + return _Segment(text, re.compile(fnmatch.translate(text)).match, False) + + +def _shape(segments: tuple[str, ...]) -> _Shape: + """`segments` split at their `**` segments.""" + chunks: list[tuple[_Segment, ...]] = [] + run: list[_Segment] = [] + for text in segments: + if text == "**": + if run: + chunks.append(tuple(run)) + run = [] + else: + run.append(_segment(text)) + if run: + chunks.append(tuple(run)) + return _Shape(tuple(chunks), segments[0] == "**", segments[-1] == "**", "**" in segments) + + +def _chunk_at(parts: tuple[str, ...], at: int, chunk: tuple[_Segment, ...]) -> bool: + """Whether the segments of `chunk` match the path segments starting at `at`.""" + if len(chunk) == 1: + return at < len(parts) and bool(chunk[0].matches(parts[at])) + if at + len(chunk) > len(parts): + return False + return all(seg.matches(parts[at + offset]) for offset, seg in enumerate(chunk)) + + +def _find_chunk(parts: tuple[str, ...], chunk: tuple[_Segment, ...], start: int, end: int) -> int: + """The first position in `start..end` where `chunk` matches `parts` and ends by `end`; -1 when none.""" + size = len(chunk) + if size == 1: + only = chunk[0] + if only.literal: + text = only.text + return next((at for at in range(start, end) if parts[at] == text), -1) + matches = only.matches + for at in range(start, end): + if matches(parts[at]): + return at + return -1 + for at in range(start, end - size + 1): + if _chunk_at(parts, at, chunk): + return at + return -1 + + +def _inner_span( + parts: tuple[str, ...], shape: _Shape, prefix: bool +) -> tuple[int, int, tuple[tuple[_Segment, ...], ...]] | None: + """The part of `parts` left between the glob's fixed first and last runs, and the runs still to place. + + `None` when the first run (unless the glob opens with `**`) or the last run (unless it ends + with `**`, or `prefix` lets the match stop early) does not match where it must. + """ + chunks, opens, ends, _ = shape + size = len(parts) + start, end = 0, size + if not opens: + first = chunks[0] + if len(first) > size or not _chunk_at(parts, 0, first): + return None + start, chunks = len(first), chunks[1:] + if not ends and not prefix and chunks: + last = chunks[-1] + end -= len(last) + if end < start or not _chunk_at(parts, end, last): + return None + chunks = chunks[:-1] + return start, end, chunks + + +def _shape_matches(parts: tuple[str, ...], shape: _Shape, prefix: bool = False) -> bool: + """Whether path segments match a glob; a `**` segment spans zero or more directories. + + A trailing `**` needs at least one more path segment. Each run of segments between `**` + segments is placed at its earliest position, which leaves the most room for the rest. + With `prefix`, the glob may match the segments or any leading part of them: the runs are + placed as for a trailing `**`, and the match may end before the last segment. + """ + if prefix and not parts: + return False + if not shape.has_star: + size = len(shape.chunks[0]) + if prefix: + return size <= len(parts) and _chunk_at(parts, 0, shape.chunks[0]) + return size == len(parts) and _chunk_at(parts, 0, shape.chunks[0]) + if shape.opens and (shape.ends or prefix): + start, end, chunks = 0, len(parts), shape.chunks + else: + span = _inner_span(parts, shape, prefix) + if span is None: + return False + start, end, chunks = span + for chunk in chunks: + at = _find_chunk(parts, chunk, start, end) + if at < 0: + return False + start = at + len(chunk) + return start < len(parts) if shape.ends else True + + +# A comma group opens at a `{` that is not the `[{]` escape. +_BRACE_GROUP = re.compile(r"(? tuple[str, ...]: + """The globs a `{a,b}` group stands for (`*.{ts,tsx}` -> `*.ts`, `*.tsx`); the glob itself without one. + + Pure function, cached. Only a group holding a comma expands: `{x}` and `{{x}}` stay literal, + and so does a `{` written as `[{]`. + """ + group = _BRACE_GROUP.search(glob) + if group is None: + return (glob,) + head, tail = glob[: group.start()], glob[group.end() :] + return tuple(alt for option in group.group(1).split(",") for alt in brace_alternatives(head + option + tail)) + + +@lru_cache(maxsize=1024) +def _alternative_shapes(pattern: str, anchored: bool) -> tuple[_Shape, ...]: + """The shape of each brace alternative of `pattern`; an unanchored relative one starts with `**`.""" + out: list[_Shape] = [] + for alternative in brace_alternatives(pattern): + glob = PurePosixPath(alternative) + if glob.parts: + out.append(_shape(glob.parts if anchored or glob.is_absolute() else ("**", *glob.parts))) + return tuple(out) + + +# A path with a leading, trailing or doubled slash, or a `.` segment, is normalized before it is split. +_NEEDS_NORMALIZING = re.compile(r"^/|^\./|/$|/\.$|//|/\./|^\.$") + + +@lru_cache(maxsize=8192) +def _path_parts(path: str) -> tuple[str, ...]: + """The segments of a posix path.""" + if _NEEDS_NORMALIZING.search(path): + return PurePosixPath(path).parts + return tuple(path.split("/")) if path else () + + +def glob_matches(path: str, pattern: str, *, anchored: bool = False, prefix: bool = False) -> bool: + """Whether a posix path matches a glob pattern, one path segment at a time. + + Pure function. `*` and `?` stay inside one segment; a `**` segment spans zero or more + directories (`a/**/b.md` matches `a/b.md` and `a/x/y/b.md`), and a trailing `**` needs + at least one more segment. A `{a,b}` group matches any of its alternatives. An anchored or + absolute pattern must match the whole path; + any other matches the path's trailing segments (`AGENTS.md` matches `pkg/AGENTS.md`). + With `prefix`, a path also matches when a folder above it does (`docs` matches `docs/a/b.md`). + """ + parts = _path_parts(path) + return any(_shape_matches(parts, shape, prefix) for shape in _alternative_shapes(pattern, anchored)) diff --git a/src/reporails_cli/core/platform/policy/exclusion.py b/src/reporails_cli/core/platform/policy/exclusion.py index 558131d..046af46 100644 --- a/src/reporails_cli/core/platform/policy/exclusion.py +++ b/src/reporails_cli/core/platform/policy/exclusion.py @@ -1,10 +1,89 @@ -"""Which paths a folder exclusion leaves out.""" +"""Which folders a folder exclusion leaves out: a folder name, or a folder path from the project root.""" from __future__ import annotations -from collections.abc import Iterable +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from functools import cached_property +from pathlib import PurePath +from reporails_cli.core.platform.contract.glob_pattern import escape_glob, glob_matches -def excluded_by_name(parts: Iterable[str], exclude_dirs: frozenset[str]) -> bool: - """True when any part of a path is named in `exclude_dirs`, so a name matches a folder at any depth.""" - return any(part in exclude_dirs for part in parts) + +@dataclass(frozen=True) +class FolderValue: + """One excluded folder: a name matched at any depth, or a path anchored at the project root.""" + + parts: tuple[str, ...] + anchored: bool = False + + def __post_init__(self) -> None: + if not self.parts or not all(self.parts): + raise ValueError("a folder value holds at least one part, and no part is empty") + if not self.anchored and len(self.parts) > 1: + raise ValueError("a folder name holds one part; a folder path is anchored") + + @cached_property + def glob(self) -> str: + """The folder as a glob that matches only that folder: any depth for a name, from the root for a path.""" + if not self.anchored: + return "**/" + escape_glob(self.parts[0]) + return "/".join(escape_glob(part) for part in self.parts) + + def holds(self, rel: str) -> bool: + """True when the root-relative posix path `rel` is this folder or lies under it.""" + if not rel: + return False + return glob_matches(rel, self.glob, anchored=True, prefix=True) + + @property + def text(self) -> str: + """The folder as reported: `docs`, `pkg/docs`, or `./docs` for a single anchored folder.""" + if not self.anchored: + return self.parts[0] + joined = "/".join(self.parts) + return joined if len(self.parts) > 1 else "./" + joined + + +_REFUSED_CHARS = (("*", "it holds '*'"), ("?", "it holds '?'"), ("\\", "it holds a backslash")) + + +def folder_value_problem(value: str) -> str | None: + """Why `value` is not a folder name or a folder path, or `None` when it is one.""" + value = value.strip() + reason = next((why for char, why in _REFUSED_CHARS if char in value), None) + if reason is None and ".." in value.split("/"): + reason = "it steps up with '..'" + if reason is None: + return None + return f"'{value}' is not a folder name or a folder path: {reason}" + + +def parse_folder_value(value: str, roots: Sequence[PurePath]) -> FolderValue | None: + """The folder `value` names, or `None` when it names no folder (empty, `/`, `.`, the root itself). + + A value that is, or starts with, one of `roots` is read as the path below that root. A leading + `./` or `/`, or a `/` before the last character, makes the value a path from the root; a + trailing `/` is ignored. Raises `ValueError` for a value `folder_value_problem` refuses. + """ + value = value.strip() + problem = folder_value_problem(value) + if problem is not None: + raise ValueError(problem) + anchored = value.startswith(("./", "/")) or "/" in value.rstrip("/") + for root in roots: + prefix = root.as_posix().rstrip("/") + if value == prefix or value.startswith(prefix + "/"): + value, anchored = value[len(prefix) :], True + break + parts = tuple(p for p in value.split("/") if p and p != ".") + return FolderValue(parts, anchored) if parts else None + + +def first_folder_value_problem(values: Iterable[object]) -> str | None: + """The `folder_value_problem` text of the first text item in `values` that is refused, or `None`.""" + for item in values: + problem = folder_value_problem(item) if isinstance(item, str) else None + if problem is not None: + return problem + return None diff --git a/src/reporails_cli/core/platform/policy/preflight.py b/src/reporails_cli/core/platform/policy/preflight.py index 261e940..de92547 100644 --- a/src/reporails_cli/core/platform/policy/preflight.py +++ b/src/reporails_cli/core/platform/policy/preflight.py @@ -19,7 +19,7 @@ RETRYABLE_ERRORS, FunnelError, ) -from reporails_cli.core.platform.policy.exclusion import excluded_by_name +from reporails_cli.core.platform.policy.exclusion import FolderValue, parse_folder_value logger = logging.getLogger(__name__) @@ -174,14 +174,33 @@ def _dense_dirs_and_route(payload: WireObject, cap: int) -> tuple[tuple[tuple[st return _shown_dirs(per_file, total), _route(_files_by_name(per_file), per_file, total - cap) +def _name_candidate(part: str) -> FolderValue | None: + """The folder name `--exclude-dirs` would read `part` as; `None` when it names no folder, a path, or is refused.""" + try: + value = parse_folder_value(part, ()) + except ValueError: + return None + return value if value is not None and not value.anchored else None + + def _files_by_name(per_file: dict[str, int]) -> dict[str, set[str]]: - """Each folder name -> the files `--exclude-dirs ` leaves out (the name anywhere in the path).""" - folders = frozenset(part for path in per_file for part in path.split("/")[:-1]) - by_name: dict[str, set[str]] = {} + """Each folder name -> the files `--exclude-dirs ` leaves out (the name anywhere in the path). + + The names are the folder segments of the paths that `--exclude-dirs` accepts as a single name; + each is tried once against each distinct folder it appears in. + """ + in_folder: dict[str, list[str]] = {} for path in per_file: - for part in set(path.split("/")): - if excluded_by_name((part,), folders): - by_name.setdefault(part, set()).add(path) + in_folder.setdefault(path.rpartition("/")[0], []).append(path) + parsed: dict[str, FolderValue | None] = {} + by_name: dict[str, set[str]] = {} + for folder, paths in in_folder.items(): + for part in dict.fromkeys(folder.split("/")): + if part not in parsed: + parsed[part] = _name_candidate(part) + candidate = parsed[part] + if candidate is not None and candidate.holds(folder): + by_name.setdefault(candidate.text, set()).update(paths) return by_name diff --git a/src/reporails_cli/core/platform/utils/utils.py b/src/reporails_cli/core/platform/utils/utils.py index 8902f46..72bae9c 100644 --- a/src/reporails_cli/core/platform/utils/utils.py +++ b/src/reporails_cli/core/platform/utils/utils.py @@ -11,15 +11,14 @@ import os import re from collections.abc import Hashable -from fnmatch import fnmatchcase -from functools import lru_cache -from pathlib import Path, PurePosixPath +from pathlib import Path from typing import ClassVar, NamedTuple import yaml from yaml.constructor import SafeConstructor from yaml.resolver import Resolver +from reporails_cli.core.platform.contract.glob_pattern import glob_matches from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue logger = logging.getLogger(__name__) @@ -279,54 +278,6 @@ def read_frontmatter_file(path: Path, *, lenient: bool = False) -> Frontmatter | return read_frontmatter(text, lenient=lenient) -def _segments_match(parts: tuple[str, ...], pattern: tuple[str, ...]) -> bool: - """Whether path segments match glob segments; a `**` segment spans zero or more directories.""" - if not pattern: - return not parts - head, rest = pattern[0], pattern[1:] - if head == "**": - if not rest: - return bool(parts) - return any(_segments_match(parts[skip:], rest) for skip in range(len(parts) + 1)) - return bool(parts) and fnmatchcase(parts[0], head) and _segments_match(parts[1:], rest) - - -_BRACE_GROUP = re.compile(r"\{([^{}]*,[^{}]*)\}") - - -@lru_cache(maxsize=1024) -def brace_alternatives(glob: str) -> tuple[str, ...]: - """The globs a `{a,b}` group stands for (`*.{ts,tsx}` -> `*.ts`, `*.tsx`); the glob itself without one. - - Pure function, cached. Only a group holding a comma expands: `{x}` and `{{x}}` stay literal. - """ - group = _BRACE_GROUP.search(glob) - if group is None: - return (glob,) - head, tail = glob[: group.start()], glob[group.end() :] - return tuple(alt for option in group.group(1).split(",") for alt in brace_alternatives(head + option + tail)) - - -def glob_matches(path: str, pattern: str, *, anchored: bool = False) -> bool: - """Whether a posix path matches a glob pattern, one path segment at a time. - - Pure function. `*` and `?` stay inside one segment; a `**` segment spans zero or more - directories (`a/**/b.md` matches `a/b.md` and `a/x/y/b.md`), and a trailing `**` needs - at least one more segment. A `{a,b}` group matches any of its alternatives. An anchored or - absolute pattern must match the whole path; - any other matches the path's trailing segments (`AGENTS.md` matches `pkg/AGENTS.md`). - """ - parts = PurePosixPath(path).parts - for alternative in brace_alternatives(pattern): - glob = PurePosixPath(alternative) - if not glob.parts: - continue - segments = glob.parts if anchored or glob.is_absolute() else ("**", *glob.parts) - if _segments_match(parts, segments): - return True - return False - - def expand_home_pattern(pattern: str) -> str: """A `~`-rooted glob pattern with `~` resolved to the user's home directory. diff --git a/src/reporails_cli/formatters/text/display.py b/src/reporails_cli/formatters/text/display.py index b4c15a6..602d1b8 100644 --- a/src/reporails_cli/formatters/text/display.py +++ b/src/reporails_cli/formatters/text/display.py @@ -334,7 +334,7 @@ def print_text_result( _print_header(tier) if not result.findings: console.print(f" {'ok' if ascii_mode else chr(0x2713)} No findings.") - render_excluded_line(result, scored=has_quality) + render_excluded_line(result, scored=False) print_not_evaluated(console, result.omissions.not_evaluated) _print_notices(result.reply.notices, ascii_mode, blank_before=not _render_funnel_cta(funnel_error)) return diff --git a/src/reporails_cli/formatters/text/funnel_cta.py b/src/reporails_cli/formatters/text/funnel_cta.py index bafb685..3f772d9 100644 --- a/src/reporails_cli/formatters/text/funnel_cta.py +++ b/src/reporails_cli/formatters/text/funnel_cta.py @@ -180,7 +180,7 @@ def format_cta(err: FunnelError, *, command_line: bool = True) -> str: if err.status is None: text += _atom_cap_route(err, tier, command_line) elif tier == "pro": - text += " \u2014 let us know your use case" + text += " Let us know your use case." return _with_url(text, url) @@ -207,7 +207,7 @@ def _atom_cap_route(err: FunnelError, tier: str, command_line: bool = True) -> s else: lines.append("Leaving folders out does not bring the project under the cap.") if tier == "pro": - lines.append("If you need the whole project checked, let us know your use case") + lines.append("If you need the whole project checked, let us know your use case.") return "".join(f"\n{line}" for line in lines) diff --git a/src/reporails_cli/interfaces/cli/check_command.py b/src/reporails_cli/interfaces/cli/check_command.py index b700e6d..638171e 100644 --- a/src/reporails_cli/interfaces/cli/check_command.py +++ b/src/reporails_cli/interfaces/cli/check_command.py @@ -7,12 +7,21 @@ import typer +from reporails_cli.core.platform.policy.exclusion import first_folder_value_problem from reporails_cli.interfaces.cli.completion import complete_agent, complete_rule_token, complete_target_token from reporails_cli.interfaces.cli.helpers import _print_unknown_rule, console from reporails_cli.interfaces.cli.option_groups import command_options from reporails_cli.interfaces.cli.typer_app import app +def _checked_exclude_dirs(values: list[str] | None) -> list[str] | None: + """Refuse a `--exclude-dirs` value that is not a folder name or a folder path.""" + problem = first_folder_value_problem(values or []) + if problem is not None: + raise typer.BadParameter(f"{problem}; use --exclude-files for file patterns") + return values + + @dataclass class CheckOptions: """The options of `ails check`; each default is the `typer.Option` / `typer.Argument` that declares it.""" @@ -35,7 +44,10 @@ class CheckOptions: autocompletion=complete_agent, ) exclude_dirs: list[str] | None = typer.Option( # noqa: RUF009 - None, "--exclude-dirs", help="Directories to exclude" + None, + "--exclude-dirs", + help="Folders to leave out: a name, or a path from the project root", + callback=_checked_exclude_dirs, ) exclude_files: list[str] | None = typer.Option( # noqa: RUF009 None, "--exclude-files", help="File globs to exclude" diff --git a/src/reporails_cli/interfaces/cli/check_flow.py b/src/reporails_cli/interfaces/cli/check_flow.py index c333e57..23c7f05 100644 --- a/src/reporails_cli/interfaces/cli/check_flow.py +++ b/src/reporails_cli/interfaces/cli/check_flow.py @@ -237,9 +237,12 @@ def _resolve_scope_at_target(state: CheckState, capability_paths: frozenset[Path if agent_arg: _validate_agent(agent_arg, console) # A single file is checked whatever is excluded, so its pass lists nothing and records nothing. - flags, named = (state.inputs.exclude_dirs, state.inputs.exclude_files), _named_paths(state, capability_paths) + flags, (named, narrowed) = ( + (state.inputs.exclude_dirs, state.inputs.exclude_files), + _named_paths(state, capability_paths), + ) scan = not state.targets.single_file - found = discover_scope(state.targets.target, agent_arg, *flags, scan_files=scan, named=named, narrowed=bool(named)) + found = discover_scope(state.targets.target, agent_arg, *flags, scan_files=scan, named=named, narrowed=narrowed) state.render.excluded, state.scope.exclusions = found.excluded, found.exclusions state.scope.detected, state.scope.filtered = found.detected, found.filtered state.scope.effective_agent, state.scope.assumed, state.scope.mixed = ( diff --git a/src/reporails_cli/interfaces/cli/check_orchestration.py b/src/reporails_cli/interfaces/cli/check_orchestration.py index 60bffc1..40a84b8 100644 --- a/src/reporails_cli/interfaces/cli/check_orchestration.py +++ b/src/reporails_cli/interfaces/cli/check_orchestration.py @@ -106,12 +106,21 @@ def _resolve_capability_paths( return paths, unresolved -def _named_paths(state: CheckState, capability_paths: frozenset[Path]) -> frozenset[Path]: - """The files the run names: capability paths, path targets that are files, and the single file.""" +def _named_paths(state: CheckState, capability_paths: frozenset[Path]) -> tuple[frozenset[Path], bool]: + """The files the run names, and whether the run covers nothing but those files. + + Named: capability paths, path targets that are files, and the single file. The run is narrowed to them + only when every target is a named file: a folder target or a capability listing (a bare capability + such as `skills`) keeps the run wide. + """ single = state.targets.single_file - return frozenset( + named = frozenset( capability_paths | {t for t in state.targets.path_targets if t.is_file()} | ({single} if single else set()) ) + only_files = all(name for _, name in state.targets.capability_specs) and all( + t.is_file() for t in state.targets.path_targets + ) + return named, bool(named) and only_files def _resolved_within_target(f: Path, target: Path) -> bool: diff --git a/src/reporails_cli/interfaces/cli/completion.py b/src/reporails_cli/interfaces/cli/completion.py index fb3adcc..fb0914c 100644 --- a/src/reporails_cli/interfaces/cli/completion.py +++ b/src/reporails_cli/interfaces/cli/completion.py @@ -7,10 +7,26 @@ from __future__ import annotations +import functools +from collections.abc import Callable from pathlib import Path from reporails_cli.core.discovery.agents import detect_agents from reporails_cli.core.platform.adapters.rules_query import list_known_agents, load_all_rules +from reporails_cli.core.platform.contract.errors import ConfigUnreadableError + + +def _no_candidates_when_config_unreadable(complete: Callable[[str], list[str]]) -> Callable[[str], list[str]]: + """Wrap a completion callback so an unreadable config offers no candidates instead of an error.""" + + @functools.wraps(complete) + def guarded(incomplete: str) -> list[str]: + try: + return complete(incomplete) + except ConfigUnreadableError: + return [] + + return guarded def complete_rule_token(incomplete: str) -> list[str]: @@ -26,6 +42,7 @@ def complete_rule_token(incomplete: str) -> list[str]: return sorted(out) +@_no_candidates_when_config_unreadable def complete_capability(incomplete: str) -> list[str]: """Complete a capability keyword for the currently-detected agent.""" from reporails_cli.core.classify.capability_paths import ( # pylint: disable=import-outside-toplevel # startup: loads when completing @@ -47,6 +64,7 @@ def complete_agent(incomplete: str) -> list[str]: return sorted(a for a in list_known_agents() if a.startswith(incomplete)) +@_no_candidates_when_config_unreadable def complete_target_token(incomplete: str) -> list[str]: """Complete a target token for `ails check`: bare capability, `capability:name`, or path prefix. diff --git a/src/reporails_cli/interfaces/cli/config_command.py b/src/reporails_cli/interfaces/cli/config_command.py index 8c757b7..3f873aa 100644 --- a/src/reporails_cli/interfaces/cli/config_command.py +++ b/src/reporails_cli/interfaces/cli/config_command.py @@ -8,9 +8,14 @@ import typer import yaml +from rich.markup import escape from reporails_cli.core.platform.config.bootstrap import get_global_config_path, get_project_config_path -from reporails_cli.core.platform.config.config import TEXT_LIST_KEYS, read_config_file +from reporails_cli.core.platform.config.config import ( + TEXT_LIST_KEYS, + read_config_file, + setting_value_problem, +) from reporails_cli.core.platform.contract.json_value import JsonObject, JsonValue from reporails_cli.core.platform.dto.results import GlobalConfig, ProjectConfig from reporails_cli.core.platform.utils.utils import forget_yaml_file @@ -136,6 +141,10 @@ def config_set( console.print(f"Settable keys: {', '.join(sorted(settable))}") raise typer.Exit(2) parsed = _parse_value(settable[key], value) + problem = setting_value_problem(key, parsed) + if problem is not None: + console.print(f"[red]Error:[/red] {escape(problem)}", soft_wrap=True) + raise typer.Exit(2) if global_: data = _load_global_config(check_items=False) diff --git a/tests/unit/test_agent_discovery_mut.py b/tests/unit/test_agent_discovery_mut.py index fb16e7d..bb0dd7a 100644 --- a/tests/unit/test_agent_discovery_mut.py +++ b/tests/unit/test_agent_discovery_mut.py @@ -16,21 +16,18 @@ from reporails_cli.core.discovery import agent_discovery as ad from reporails_cli.core.discovery import walk from reporails_cli.core.discovery.agent_config import load_config_file_types -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.surface_config import surface_include_patterns from tests.conftest import skip_if_case_insensitive_fs -# --- is_excluded (L90) ------------------------------------------------------ +# --- RunExclusions.covers (path outside the root) --- @pytest.mark.unit @pytest.mark.subsys_lint -def test_is_excluded_path_outside_target_is_not_excluded() -> None: - """A path not under `target` is not excluded (relative_to raises -> False). - - Kills: L90 `return False -> return True` in the ValueError branch. - """ - assert ad.is_excluded(Path("/other/x.md"), Path("/target"), frozenset({"x.md"})) is False +def test_covers_path_outside_root_is_not_covered() -> None: + """A path not under the root is not covered, whatever name it carries.""" + assert RunExclusions.parse(Path("/target"), ["x.md"], ()).covers(Path("/other/x.md")) is False # --- walk_glob always-skip pruning (L160) ----------------------------------- @@ -48,7 +45,7 @@ def test_walk_glob_skips_always_skip_dirs(tmp_path: Path) -> None: (tmp_path / ".git" / "CLAUDE.md").write_text("x") (tmp_path / "CLAUDE.md").write_text("y") - results = walk.walk_glob(tmp_path, "CLAUDE.md", DEFAULT_EXCLUDE_DIRS) + results = walk.walk_glob(tmp_path, "CLAUDE.md", RunExclusions.none(tmp_path)) assert set(results) == {tmp_path / "CLAUDE.md"} @@ -61,7 +58,7 @@ def test_walk_glob_skips_a_wrong_case_name_on_a_case_sensitive_filesystem(tmp_pa """A lowercase `claude.md` is not the documented `CLAUDE.md` where the filesystem tells them apart.""" skip_if_case_insensitive_fs(tmp_path) (tmp_path / "claude.md").write_text("y") - assert walk.walk_glob(tmp_path, "CLAUDE.md", DEFAULT_EXCLUDE_DIRS) == [] + assert walk.walk_glob(tmp_path, "CLAUDE.md", RunExclusions.none(tmp_path)) == [] @pytest.mark.unit @@ -69,8 +66,8 @@ def test_walk_glob_skips_a_wrong_case_name_on_a_case_sensitive_filesystem(tmp_pa def test_ci_glob_matches_case_insensitively(tmp_path: Path) -> None: """ci_glob resolves a bare filename and a glob case-insensitively.""" (tmp_path / "agents.md").write_text("a") - assert ad.ci_glob(tmp_path, "AGENTS.md") == [tmp_path / "agents.md"] - assert set(ad.ci_glob(tmp_path, "*.MD")) == {tmp_path / "agents.md"} + assert ad.ci_glob(tmp_path, "AGENTS.md", RunExclusions.none(tmp_path)) == [tmp_path / "agents.md"] + assert set(ad.ci_glob(tmp_path, "*.MD", RunExclusions.none(tmp_path))) == {tmp_path / "agents.md"} @pytest.mark.unit @@ -131,7 +128,7 @@ def test_eager_global_recursive_leaf_uses_ancestor_walk(tmp_path: Path) -> None: (tmp_path / "sub" / "CLAUDE.md").write_text("s") res = ad.glob_file_type_patterns( - tmp_path, ["**/CLAUDE.md"], {"scope": "global", "loading": "session_start"}, DEFAULT_EXCLUDE_DIRS + tmp_path, ["**/CLAUDE.md"], {"scope": "global", "loading": "session_start"}, RunExclusions.none(tmp_path) ) assert res == [tmp_path / "CLAUDE.md"] @@ -179,7 +176,7 @@ class _Type: root = Path("/proj") agent = DetectedAgent(_Type(), [root / "CLAUDE.md", root / "scratch" / "CLAUDE.md"], [], [], []) # type: ignore[arg-type] - kept = filter_agents_by_exclusions([agent], RunExclusions(root, dirs=frozenset({"scratch"}))) + kept = filter_agents_by_exclusions([agent], RunExclusions.parse(root, ["scratch"], ())) assert [a.instruction_files for a in kept] == [[root / "CLAUDE.md"]] assert filter_agents_by_exclusions([agent], RunExclusions(root)) == [agent] @@ -204,7 +201,7 @@ def test_pinned_recursive_patterns_match_the_whole_pattern(tmp_path: Path) -> No nested = {"scope": "nested"} def found(pattern: str) -> set[str]: - res = ad.glob_file_type_patterns(tmp_path, [pattern], nested, DEFAULT_EXCLUDE_DIRS) + res = ad.glob_file_type_patterns(tmp_path, [pattern], nested, RunExclusions.none(tmp_path)) return {p.relative_to(tmp_path).as_posix() for p in res} assert found("**/.agents/skills/*/agents/openai.yaml") == {".agents/skills/foo/agents/openai.yaml"} @@ -219,7 +216,7 @@ def test_recursive_pattern_filter_keeps_only_the_documented_filename_case(tmp_pa for rel in ("AGENTS.md", "svc/AGENTS.md", "pkg/agents.md"): (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) (tmp_path / rel).write_text("x") - res = ad.glob_file_type_patterns(tmp_path, ["**/AGENTS.md"], {"scope": "nested"}, DEFAULT_EXCLUDE_DIRS) + res = ad.glob_file_type_patterns(tmp_path, ["**/AGENTS.md"], {"scope": "nested"}, RunExclusions.none(tmp_path)) assert "pkg/agents.md" not in {p.relative_to(tmp_path).as_posix() for p in res} @@ -245,7 +242,7 @@ def test_rule_only_agent_survives_an_exclusion_that_matches_nothing() -> None: root = Path("/proj") rule = root / ".claude" / "rules" / "python.md" agent = _agent([], [rule]) - by_dirs = filter_agents_by_exclusions([agent], RunExclusions(root, dirs=frozenset({"vendor"}))) + by_dirs = filter_agents_by_exclusions([agent], RunExclusions.parse(root, ["vendor"], ())) by_files = filter_agents_by_exclusions([agent], RunExclusions(root, files=("vendor/**",))) assert [a.rule_files for a in by_dirs] == [[rule]] assert [a.rule_files for a in by_files] == [[rule]] @@ -261,7 +258,7 @@ def test_rule_only_agent_loses_a_rule_file_inside_the_excluded_folder() -> None: kept_rule = root / ".claude" / "rules" / "python.md" gone_rule = root / "vendor" / ".claude" / "rules" / "x.md" agent = _agent([], [kept_rule, gone_rule]) - by_dirs = filter_agents_by_exclusions([agent], RunExclusions(root, dirs=frozenset({"vendor"}))) + by_dirs = filter_agents_by_exclusions([agent], RunExclusions.parse(root, ["vendor"], ())) by_files = filter_agents_by_exclusions([agent], RunExclusions(root, files=("vendor/**",))) assert [a.rule_files for a in by_dirs] == [[kept_rule]] assert [a.rule_files for a in by_files] == [[kept_rule]] @@ -276,7 +273,7 @@ def test_agent_whose_only_instruction_file_is_excluded_keeps_its_surviving_rule_ root = Path("/proj") rule = root / ".claude" / "rules" / "python.md" agent = _agent([root / "vendor" / "CLAUDE.md"], [rule]) - by_dirs = filter_agents_by_exclusions([agent], RunExclusions(root, dirs=frozenset({"vendor"}))) + by_dirs = filter_agents_by_exclusions([agent], RunExclusions.parse(root, ["vendor"], ())) by_files = filter_agents_by_exclusions([agent], RunExclusions(root, files=("vendor/**",))) assert [(a.instruction_files, a.rule_files) for a in by_dirs] == [([], [rule])] assert [(a.instruction_files, a.rule_files) for a in by_files] == [([], [rule])] @@ -290,7 +287,7 @@ def test_agent_whose_only_instruction_file_is_excluded_and_has_no_rule_files_is_ root = Path("/proj") agent = _agent([root / "vendor" / "CLAUDE.md"], []) - assert filter_agents_by_exclusions([agent], RunExclusions(root, dirs=frozenset({"vendor"}))) == [] + assert filter_agents_by_exclusions([agent], RunExclusions.parse(root, ["vendor"], ())) == [] assert filter_agents_by_exclusions([agent], RunExclusions(root, files=("vendor/**",))) == [] @@ -302,7 +299,7 @@ def test_rule_only_agent_whose_every_rule_file_is_excluded_is_dropped() -> None: root = Path("/proj") agent = _agent([], [root / "vendor" / ".claude" / "rules" / "x.md"]) - assert filter_agents_by_exclusions([agent], RunExclusions(root, dirs=frozenset({"vendor"}))) == [] + assert filter_agents_by_exclusions([agent], RunExclusions.parse(root, ["vendor"], ())) == [] assert filter_agents_by_exclusions([agent], RunExclusions(root, files=("vendor/**",))) == [] @@ -339,7 +336,7 @@ def test_rule_only_claude_project_keeps_its_rule_file_under_a_no_op_exclusion(tm rule.write_text("# Python\n\nUse type hints on every function.\n", encoding="utf-8") def checked(exclude_dirs: list[str] | None) -> list[Path]: - exclusions = RunExclusions(tmp_path, dirs=frozenset(exclude_dirs or ())) + exclusions = RunExclusions.parse(tmp_path, exclude_dirs or (), ()) _, _, _, filtered = resolve_agent_filters("claude", detect_agents(tmp_path), tmp_path, exclusions) return get_all_instruction_files(tmp_path, filtered) diff --git a/tests/unit/test_capability_paths.py b/tests/unit/test_capability_paths.py index c0ec8cd..a363d19 100644 --- a/tests/unit/test_capability_paths.py +++ b/tests/unit/test_capability_paths.py @@ -263,6 +263,7 @@ def test_symlinked_declared_folder_is_listed(tmp_path: Path) -> None: @pytest.mark.subsys_classify def test_literal_pattern_is_found_without_a_walk(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: from reporails_cli.core.discovery import walk + from reporails_cli.core.discovery.exclusions import RunExclusions (tmp_path / "CLAUDE.md").write_text("# x\n") (tmp_path / "big" / "deep").mkdir(parents=True) @@ -270,7 +271,7 @@ def test_literal_pattern_is_found_without_a_walk(tmp_path: Path, monkeypatch: py (tmp_path / "node_modules").mkdir() (tmp_path / "node_modules" / "AGENTS.md").write_text("x\n") monkeypatch.setattr(walk, "_walk", lambda *a, **k: pytest.fail("walked")) - skip = frozenset({"node_modules"}) + skip = RunExclusions.parse(tmp_path, ["node_modules"], ()) assert list(walk.walk_glob_matches(tmp_path, "/CLAUDE.md", skip)) == [tmp_path / "CLAUDE.md"] assert list(walk.walk_glob_matches(tmp_path, "node_modules/AGENTS.md", skip)) == [] assert list(walk.walk_glob_matches(tmp_path, "missing.md", skip)) == [] diff --git a/tests/unit/test_capability_paths_mut.py b/tests/unit/test_capability_paths_mut.py index 58267d4..e0143d2 100644 --- a/tests/unit/test_capability_paths_mut.py +++ b/tests/unit/test_capability_paths_mut.py @@ -12,7 +12,7 @@ import pytest from reporails_cli.core.classify import location_matches_mode -from reporails_cli.core.discovery.agent_discovery import is_excluded +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.platform.dto.models import FileTypeDeclaration from reporails_cli.core.platform.utils.utils import is_loose_leaf_pattern @@ -75,15 +75,13 @@ def test_pathed_pattern_is_not_loose_leaf(self) -> None: assert is_loose_leaf_pattern("CLAUDE.md") is True -# ── is_excluded out-of-tree path (L315) ─────────────────── +# ── covers out-of-tree path ─────────────────── class TestUnderExcludedDir: @pytest.mark.unit @pytest.mark.subsys_classify def test_path_outside_project_root_is_not_excluded(self, tmp_path: Path) -> None: - # A path outside project_root can't be relativized; the ValueError path - # must report "not excluded". - # Kills L315 `return False -> True`. + # A path outside the root can't be relativized and must report "not excluded". outside = Path("/totally/unrelated/elsewhere/file.md") - assert is_excluded(outside, tmp_path, frozenset({"node_modules"})) is False + assert RunExclusions.parse(tmp_path, ["node_modules"], ()).covers(outside) is False diff --git a/tests/unit/test_claude_plugin_layout.py b/tests/unit/test_claude_plugin_layout.py index 69d0870..f7daa85 100644 --- a/tests/unit/test_claude_plugin_layout.py +++ b/tests/unit/test_claude_plugin_layout.py @@ -22,7 +22,7 @@ detect_agents, get_all_instruction_files, ) -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.plugin_roots import ( expand_plugin_patterns, find_plugin_roots, @@ -77,9 +77,11 @@ def test_plugin_roots_at_the_root_and_below_it(tmp_path: Path) -> None: _plugin(tmp_path) _plugin(tmp_path / "plugins" / "demo") _plugin(tmp_path / "node_modules" / "vendored") - roots = find_plugin_roots(tmp_path, MANIFEST, DEFAULT_EXCLUDE_DIRS) + roots = find_plugin_roots(tmp_path, MANIFEST, RunExclusions.none(tmp_path)) assert roots == [tmp_path, tmp_path / "plugins" / "demo"] - assert expand_plugin_patterns([f"<{MANIFEST}>/skills/**/SKILL.md", "x.md"], tmp_path, DEFAULT_EXCLUDE_DIRS) == [ + assert expand_plugin_patterns( + [f"<{MANIFEST}>/skills/**/SKILL.md", "x.md"], tmp_path, RunExclusions.none(tmp_path) + ) == [ "skills/**/SKILL.md", "plugins/demo/skills/**/SKILL.md", "x.md", @@ -90,7 +92,7 @@ def test_plugin_roots_at_the_root_and_below_it(tmp_path: Path) -> None: @pytest.mark.subsys_classify def test_no_manifest_no_plugin_pattern(tmp_path: Path) -> None: _write(tmp_path / "skills" / "fmt" / "SKILL.md", SKILL) - assert expand_plugin_patterns([f"<{MANIFEST}>/skills/**/SKILL.md"], tmp_path, DEFAULT_EXCLUDE_DIRS) == [] + assert expand_plugin_patterns([f"<{MANIFEST}>/skills/**/SKILL.md"], tmp_path, RunExclusions.none(tmp_path)) == [] assert plugin_relative_path(tmp_path / "skills" / "fmt" / "SKILL.md", tmp_path, MANIFEST) is None @@ -178,7 +180,7 @@ def test_an_unreadable_folder_is_skipped_and_the_plugin_beside_it_is_found(tmp_p (locked / "inner").mkdir(parents=True) locked.chmod(0) try: - assert find_plugin_roots(project, MANIFEST, DEFAULT_EXCLUDE_DIRS) == [project / "plugins" / "demo"] + assert find_plugin_roots(project, MANIFEST, RunExclusions.none(project)) == [project / "plugins" / "demo"] clear_agent_cache() assert "claude" in {a.agent_type.id for a in detect_agents(project)} finally: @@ -250,6 +252,7 @@ def test_one_feature_detection_searches_each_plugin_marker_once( ) -> None: from reporails_cli.core.discovery import plugin_roots from reporails_cli.core.discovery.features import detect_features_filesystem + from reporails_cli.core.discovery.walk import Pruner _write(tmp_path / "CLAUDE.md", "# Project\n\nRun `make test` before committing.\n") _plugin(tmp_path / "plugins" / "own") @@ -258,9 +261,9 @@ def test_one_feature_detection_searches_each_plugin_marker_once( searched: list[str] = [] real = plugin_roots.find_plugin_roots - def spy(target: Path, marker: str, exclude_dirs: frozenset[str] = frozenset()) -> list[Path]: + def spy(target: Path, marker: str, skip: Pruner) -> list[Path]: searched.append(marker) - return real(target, marker, exclude_dirs) + return real(target, marker, skip) monkeypatch.setattr(plugin_roots, "find_plugin_roots", spy) detect_features_filesystem(tmp_path, agents=agents) diff --git a/tests/unit/test_exclude_dirs_refusal.py b/tests/unit/test_exclude_dirs_refusal.py new file mode 100644 index 0000000..26eccdf --- /dev/null +++ b/tests/unit/test_exclude_dirs_refusal.py @@ -0,0 +1,161 @@ +"""A folder value the run cannot read is refused where it comes from, naming the flag or the setting.""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from reporails_cli.core.platform.config import config as config_mod +from reporails_cli.core.platform.config.config import get_project_config +from reporails_cli.core.platform.contract.errors import ConfigUnreadableError +from reporails_cli.interfaces.cli.completion import complete_capability, complete_target_token +from reporails_cli.interfaces.cli.config_command import config_app +from reporails_cli.interfaces.cli.main import app + +runner = CliRunner() +_REFUSED = ["docs/*", "**/docs", "docs\\x", "../x", "a?b"] +_ACCEPTED = ["[slug]", "app/[id]", "./docs", "/docs", "docs/."] + + +@pytest.fixture +def global_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """A global config location under tmp, used by the reader and by `ails config`.""" + path = tmp_path / "home" / "config.yml" + path.parent.mkdir() + monkeypatch.setattr(config_mod, "get_global_config_path", lambda: path) + monkeypatch.setattr("reporails_cli.interfaces.cli.config_command.get_global_config_path", lambda: path) + return path + + +def _project(tmp_path: Path, name: str | None = None, value: str = "docs/*") -> Path: + root = tmp_path / "proj" + (root / ".ails").mkdir(parents=True) + (root / "CLAUDE.md").write_text("# Project\n\nUse uv.\n", encoding="utf-8") + if name: + (root / ".ails" / name).write_text(f"exclude_dirs: ['{value}']\n", encoding="utf-8") + return root + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +@pytest.mark.parametrize("name", ["config.yml", "config.local.yml"]) +@pytest.mark.parametrize("value", _REFUSED) +def test_project_file_refuses_a_bad_folder_value(tmp_path: Path, global_path: Path, name: str, value: str) -> None: + root = _project(tmp_path, name, value) + with pytest.raises(ConfigUnreadableError) as raised: + get_project_config(root) + message = str(raised.value) + assert str(root / ".ails" / name) in message + assert "exclude_dirs" in message and value in message and "exclude_files" in message + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_global_file_refuses_a_bad_folder_value(tmp_path: Path, global_path: Path) -> None: + global_path.write_text("exclude_dirs: ['docs/*']\n", encoding="utf-8") + with pytest.raises(ConfigUnreadableError) as raised: + get_project_config(_project(tmp_path)) + message = str(raised.value) + assert str(global_path) in message and "exclude_dirs" in message and "docs/*" in message + assert "exclude_files" in message + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +@pytest.mark.parametrize("value", _ACCEPTED) +def test_project_file_accepts_a_folder_value(tmp_path: Path, global_path: Path, value: str) -> None: + root = _project(tmp_path, "config.yml", value) + assert value in get_project_config(root).exclude_dirs + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +@pytest.mark.parametrize("use_global", [False, True]) +def test_config_set_refuses_a_bad_folder_value_and_leaves_the_file( + tmp_path: Path, global_path: Path, use_global: bool +) -> None: + root = _project(tmp_path, "config.yml", "docs") + target = global_path if use_global else root / ".ails" / "config.yml" + if use_global: + global_path.write_text("default_agent: claude\n", encoding="utf-8") + before = target.read_bytes() + args = ["config", "set", "exclude_dirs", "ok,[x]*", "--path", str(root)] + (["--global"] if use_global else []) + result = runner.invoke(app, args) + assert result.exit_code == 2 + assert "Error: exclude_dirs: '[x]*' is not a folder name or a folder path" in result.output + assert str(target) not in result.output + assert "exclude_files" in result.output + assert target.read_bytes() == before + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_config_set_writes_bracketed_folder_values(tmp_path: Path, global_path: Path) -> None: + root = _project(tmp_path) + result = runner.invoke(config_app, ["set", "exclude_dirs", "docs,[slug]", "--path", str(root)]) + assert result.exit_code == 0 + assert get_project_config(root).exclude_dirs == ["docs", "[slug]"] + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_check_refuses_a_bad_exclude_dirs_flag(tmp_path: Path, global_path: Path) -> None: + root = _project(tmp_path) + result = runner.invoke(app, ["check", str(root), "--exclude-dirs", "docs/*"]) + assert result.exit_code == 2 + assert "--exclude-dirs" in result.output and "docs/*" in result.output and "--exclude-files" in result.output + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_check_shows_brackets_in_the_refusal_literally(tmp_path: Path, global_path: Path) -> None: + result = runner.invoke(app, ["check", str(_project(tmp_path)), "--exclude-dirs", "[x]*"]) + assert result.exit_code == 2 + assert "'[x]*'" in result.output + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_check_does_not_refuse_a_bracketed_exclude_dirs_flag(tmp_path: Path, global_path: Path) -> None: + result = runner.invoke(app, ["check", str(_project(tmp_path)), "--exclude-dirs", "[slug]"]) + assert "Invalid value" not in result.output + + +@pytest.mark.unit +@pytest.mark.subsys_server +def test_mcp_validate_and_heal_apply_name_the_refused_setting(tmp_path: Path, global_path: Path) -> None: + from reporails_cli.interfaces.mcp.server import _run_heal_apply, _run_validate + + root = _project(tmp_path, "config.yml") + payload = asyncio.run(_run_validate(str(root), True, None)) + assert payload["error"] == "config_unreadable" + message = str(payload["message"]) + assert "exclude_dirs" in message and "docs/*" in message + healed = asyncio.run(_run_heal_apply(str(root))) + assert healed.startswith("heal_apply: ") and "exclude_dirs" in healed + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_completion_offers_nothing_for_a_refused_config( + tmp_path: Path, global_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.chdir(_project(tmp_path, "config.yml")) + assert complete_capability("") == [] + assert complete_target_token("") == [] + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +def test_a_shape_error_in_a_later_setting_is_reported_before_a_refused_folder_value( + tmp_path: Path, global_path: Path +) -> None: + root = _project(tmp_path) + (root / ".ails" / "config.yml").write_text("exclude_dirs: ['docs/*']\nexclude_files: [1]\n", encoding="utf-8") + with pytest.raises(ConfigUnreadableError) as raised: + get_project_config(root) + assert "exclude_files" in str(raised.value) + assert "exclude_dirs" not in str(raised.value) diff --git a/tests/unit/test_features_mut.py b/tests/unit/test_features_mut.py index 79b2377..9352e8d 100644 --- a/tests/unit/test_features_mut.py +++ b/tests/unit/test_features_mut.py @@ -10,6 +10,7 @@ import pytest +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.features import ( _detect_auto_memory, _hooks_key_is_set, @@ -64,7 +65,7 @@ def test_bare_mcp_json_with_no_detected_agent_grants_no_hooks(tmp_path: Path) -> @pytest.mark.unit @pytest.mark.subsys_lint def test_hooks_key_is_set_missing_file_is_false(tmp_path: Path) -> None: - assert _hooks_key_is_set(tmp_path / "nope.json", _SurfaceScan(exclude_dirs=frozenset())) is False # kills L175 + assert _hooks_key_is_set(tmp_path / "nope.json", _SurfaceScan(RunExclusions.none(tmp_path))) is False # kills L175 @pytest.mark.unit @@ -72,7 +73,7 @@ def test_hooks_key_is_set_missing_file_is_false(tmp_path: Path) -> None: def test_hooks_key_is_set_malformed_json_is_false(tmp_path: Path) -> None: p = tmp_path / "settings.json" p.write_text("{ not valid json") - assert _hooks_key_is_set(p, _SurfaceScan(exclude_dirs=frozenset())) is False # kills L181 + assert _hooks_key_is_set(p, _SurfaceScan(RunExclusions.none(tmp_path))) is False # kills L181 @pytest.mark.unit @@ -80,7 +81,7 @@ def test_hooks_key_is_set_malformed_json_is_false(tmp_path: Path) -> None: def test_hooks_key_is_set_dict_without_hooks_is_false(tmp_path: Path) -> None: p = tmp_path / "settings.json" p.write_text('{"other": 1}') - assert _hooks_key_is_set(p, _SurfaceScan(exclude_dirs=frozenset())) is False # kills L182 and→or + assert _hooks_key_is_set(p, _SurfaceScan(RunExclusions.none(tmp_path))) is False # kills L182 and→or @pytest.mark.unit @@ -88,7 +89,7 @@ def test_hooks_key_is_set_dict_without_hooks_is_false(tmp_path: Path) -> None: def test_hooks_key_is_set_dict_with_hooks_is_true(tmp_path: Path) -> None: p = tmp_path / "settings.json" p.write_text('{"hooks": {"PreToolUse": []}}') - assert _hooks_key_is_set(p, _SurfaceScan(exclude_dirs=frozenset())) is True + assert _hooks_key_is_set(p, _SurfaceScan(RunExclusions.none(tmp_path))) is True # ────────────────────────────────────────────────────────────────── diff --git a/tests/unit/test_folder_values.py b/tests/unit/test_folder_values.py new file mode 100644 index 0000000..138de8d --- /dev/null +++ b/tests/unit/test_folder_values.py @@ -0,0 +1,148 @@ +"""The folder-value grammar: a folder name, or a folder path from the project root.""" + +from __future__ import annotations + +from pathlib import Path, PurePath + +import pytest + +from reporails_cli.core.platform.policy.exclusion import ( + FolderValue, + folder_value_problem, + parse_folder_value, +) + +ROOT = Path("/proj/root") +ROOTS = [ROOT] + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize( + ("value", "parts", "anchored", "text"), + [ + ("/docs", ("docs",), True, "./docs"), + ("./docs", ("docs",), True, "./docs"), + ("/proj/root/docs", ("docs",), True, "./docs"), + ("docs/.", ("docs",), True, "./docs"), + ("app/[id]", ("app", "[id]"), True, "app/[id]"), + ("[slug]", ("[slug]",), False, "[slug]"), + ("docs/", ("docs",), False, "docs"), + ("docs", ("docs",), False, "docs"), + ("pkg/docs", ("pkg", "docs"), True, "pkg/docs"), + (" pkg/docs ", ("pkg", "docs"), True, "pkg/docs"), + ("a..b", ("a..b",), False, "a..b"), + ("...", ("...",), False, "..."), + ], +) +def test_parse_accepted_values(value: str, parts: tuple[str, ...], anchored: bool, text: str) -> None: + parsed = parse_folder_value(value, ROOTS) + assert parsed == FolderValue(parts, anchored) + assert parsed is not None + assert parsed.text == text + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize("value", ["", "/", ".", "./", "/proj/root", "/proj/root/"]) +def test_parse_empty_values_are_nothing(value: str) -> None: + assert parse_folder_value(value, ROOTS) is None + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize( + ("value", "holds"), + [ + ("docs/*", "it holds '*'"), + ("**/docs", "it holds '*'"), + ("do?s", "it holds '?'"), + ("docs\\x", "it holds a backslash"), + ("../x", "it steps up with '..'"), + ("a/../x", "it steps up with '..'"), + ], +) +def test_refused_values(value: str, holds: str) -> None: + problem = folder_value_problem(value) + assert problem == f"'{value}' is not a folder name or a folder path: {holds}" + with pytest.raises(ValueError, match="not a folder name"): + parse_folder_value(value, ROOTS) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize("value", ["docs", "app/[id]", "[slug]", "...", "a..b", "./docs", "/docs", " docs "]) +def test_acceptable_values_have_no_problem(value: str) -> None: + assert folder_value_problem(value) is None + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_resolved_root_prefix_is_dropped() -> None: + roots: list[PurePath] = [Path("/link/root"), Path("/real/root")] + assert parse_folder_value("/real/root/pkg/docs", roots) == FolderValue(("pkg", "docs"), True) + assert parse_folder_value("/link/root/docs", roots) == FolderValue(("docs",), True) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_holds_name_matches_any_depth_and_anchored_only_the_prefix() -> None: + name = FolderValue(("docs",)) + assert name.holds("a/docs/x.md") + assert name.holds("docs") + assert not name.holds("a/other/x.md") + assert not name.holds("a/mydocs/x.md") + anchored = FolderValue(("pkg", "docs"), True) + assert anchored.holds("pkg/docs/x.md") + assert anchored.holds("pkg/docs") + assert not anchored.holds("a/pkg/docs/x.md") + assert not anchored.holds("pkg") + assert not name.holds("") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize( + ("value", "inside", "outside"), + [ + ("[slug]", ["[slug]", "app/[slug]/x.md"], ["s", "slug", "a/l/x.md", "app/slug/x.md"]), + ("app/[id]", ["app/[id]", "app/[id]/x.md"], ["app/i", "app/d", "app/id/x.md", "x/app/[id]"]), + ("{a,b}", ["{a,b}", "p/{a,b}/x.md"], ["a", "b", "p/a/x.md", "p/b/x.md", "a,b"]), + ("a,b", ["a,b", "p/a,b/x.md"], ["a", "b", "p/a/x.md", "{a,b}"]), + ("a-b.c", ["a-b.c/x.md"], ["axb.c/x.md"]), + ], +) +def test_a_folder_value_holds_only_the_folder_it_spells(value: str, inside: list[str], outside: list[str]) -> None: + folder = parse_folder_value(value, ROOTS) + assert folder is not None + assert all(folder.holds(rel) for rel in inside) + assert not any(folder.holds(rel) for rel in outside) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize("value", ["pkg/[id]", "pkg/{a,b}", "pkg/a,b"]) +def test_an_anchored_folder_value_holds_only_the_path_it_spells(value: str) -> None: + folder = parse_folder_value(value, ROOTS) + assert folder is not None + literal = value + assert folder.holds(literal) and folder.holds(literal + "/x.md") + assert not folder.holds("pkg/other") and not folder.holds("x/" + literal) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize( + ("parts", "anchored"), + [((), False), ((), True), (("",), False), (("a", ""), True), (("a", "b"), False)], +) +def test_a_folder_value_it_cannot_honour_is_refused(parts: tuple[str, ...], anchored: bool) -> None: + with pytest.raises(ValueError): + FolderValue(parts, anchored) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_folder_name_or_anchored_path_is_admitted() -> None: + assert FolderValue(("docs",)).holds("docs") + assert FolderValue(("a", "b"), True).holds("a/b") diff --git a/tests/unit/test_funnel.py b/tests/unit/test_funnel.py index 915974e..ac4f898 100644 --- a/tests/unit/test_funnel.py +++ b/tests/unit/test_funnel.py @@ -9,13 +9,13 @@ import pytest -from reporails_cli.core.discovery.agent_discovery import is_excluded from reporails_cli.core.platform.dto.diagnostics import ( ENTITLED_TIERS, UNENTITLED_TIERS, FunnelError, LintResponse, ) +from reporails_cli.core.platform.policy.exclusion import FolderValue from reporails_cli.core.platform.policy.preflight import ( UNIVERSAL_ATOM_CAP, WIRE_MAX_FILES, @@ -763,12 +763,12 @@ def _dense_payload(spec: list[tuple[str, int]]) -> dict[str, object]: def _atoms_left(payload: dict[str, object], route: tuple[str, ...]) -> int: files = payload["files"] - excluded = frozenset(route) + folders = [FolderValue((name,)) for name in route] left = 0 for atom in payload["atoms"]: # type: ignore[attr-defined] path = files[atom["fi"]]["path"] # type: ignore[index] outside = path.startswith(("~/", "/")) - if outside or not is_excluded(Path(path), Path(), excluded): + if outside or not any(folder.holds(Path(path).as_posix()) for folder in folders): left += 1 return left @@ -795,6 +795,23 @@ def test_route_brings_the_project_under_the_cap(self) -> None: assert _atoms_left(payload, err.exclude_route) <= UNIVERSAL_ATOM_CAP assert _atoms_left(payload, err.exclude_route[:1]) > UNIVERSAL_ATOM_CAP + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_a_folder_name_exclude_dirs_would_refuse_is_never_routed(self) -> None: + payload = _dense_payload([("a*b/x.md", 6000), ("x?y/y.md", 3000), ("ROOT.md", 2000)]) + err = preflight_oversized(payload, has_api_key=False) + assert err is not None + assert err.dense_dirs[0] == ("a*b", 6000) + assert err.exclude_route == () + + @pytest.mark.unit + @pytest.mark.subsys_funnel + def test_a_bracketed_folder_name_can_be_routed(self) -> None: + payload = _dense_payload([("[slug]/x.md", 6000), ("a*b/y.md", 3000), ("ROOT.md", 2000)]) + err = preflight_oversized(payload, has_api_key=False) + assert err is not None + assert err.exclude_route == ("[slug]",) + @pytest.mark.unit @pytest.mark.subsys_funnel def test_nested_folder_sharing_a_name_counts_as_removed(self) -> None: @@ -849,7 +866,7 @@ def test_anonymous_and_pro_copy_carry_the_command_and_caveat(self, no_key: None) assert "--exclude-dirs" not in cta assert err is not None assert err.tier == "pro" - assert "let us know your use case" in cta + assert "let us know your use case." in cta assert cta.rstrip().endswith("reporails.com/contact/atoms") assert cta.index("api, tests") < cta.index("let us know your use case") anonymous = plain_cta(preflight_oversized(payload, has_api_key=False)) # type: ignore[arg-type] @@ -940,9 +957,7 @@ def test_server_atom_cap_refusal_for_pro_gives_the_reason_for_the_link(self) -> upgrade_url="https://reporails.com/contact/atoms", ) cta = plain_cta(err) - assert cta.startswith( - "Project exceeds 10,000-atom cap (12,000 atoms). \u2014 let us know your use case \u2192 " - ) + assert cta.startswith("Project exceeds 10,000-atom cap (12,000 atoms). Let us know your use case. \u2192 ") assert cta.endswith("reporails.com/contact/atoms") assert "Leave these out" not in cta diff --git a/tests/unit/test_glob_matches.py b/tests/unit/test_glob_matches.py index 297e872..5d2ca31 100644 --- a/tests/unit/test_glob_matches.py +++ b/tests/unit/test_glob_matches.py @@ -4,7 +4,7 @@ import pytest -from reporails_cli.core.platform.utils.utils import glob_matches +from reporails_cli.core.platform.contract.glob_pattern import GLOB_CHARS, glob_matches @pytest.mark.unit @@ -54,9 +54,111 @@ def test_glob_matches_expands_a_brace_group_with_a_comma(path: str, pattern: str @pytest.mark.unit @pytest.mark.subsys_classify def test_brace_alternatives_expand_only_a_comma_group() -> None: - from reporails_cli.core.platform.utils.utils import brace_alternatives + from reporails_cli.core.platform.contract.glob_pattern import brace_alternatives assert brace_alternatives("*.{ts,tsx}") == ("*.ts", "*.tsx") assert brace_alternatives("{a,b}/{c,d}") == ("a/c", "a/d", "b/c", "b/d") assert brace_alternatives("{x}") == ("{x}",) assert brace_alternatives("{{x}}") == ("{{x}}",) + + +def _reference_match(parts: tuple[str, ...], pattern: tuple[str, ...]) -> bool: + """The plain recursive reading of a glob: `**` spans zero or more segments, a trailing one at least one.""" + from fnmatch import fnmatchcase + + if not pattern: + return not parts + head, rest = pattern[0], pattern[1:] + if head == "**": + if not rest: + return bool(parts) + return any(_reference_match(parts[skip:], rest) for skip in range(len(parts) + 1)) + return bool(parts) and fnmatchcase(parts[0], head) and _reference_match(parts[1:], rest) + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_the_matcher_agrees_with_the_plain_recursive_reading_on_every_small_case() -> None: + from itertools import product + + segments = ("a", "b", "*", "[ab]", "**") + names = ("a", "b", "ab") + patterns = [p for size in range(1, 5) for p in product(segments, repeat=size)] + paths = [p for size in range(0, 4) for p in product(names, repeat=size)] + for pattern in patterns: + for parts in paths: + expected = _reference_match(parts, pattern) + assert glob_matches("/".join(parts), "/".join(pattern), anchored=True) is expected, (parts, pattern) + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize( + ("path", "pattern", "expected"), + [ + ("x/a/b/y", "**/a/b/**", True), + ("a/x/b/y", "**/a/b/**", False), + ("a/b", "**/a/b/**", False), + ("a/b/a/b/c", "a/**/a/b/c", True), + ("[a]/x", "[[]a]/*", True), + ("a/x", "[[]a]/*", False), + ("ab", "?", False), + ("a", "?", True), + ], +) +def test_runs_between_double_stars_escaped_brackets_and_single_characters( + path: str, pattern: str, expected: bool +) -> None: + assert _reference_match(tuple(path.split("/")), tuple(pattern.split("/"))) is expected + assert glob_matches(path, pattern, anchored=True) is expected + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_prefix_matching_agrees_with_testing_every_leading_part_on_every_small_case() -> None: + from itertools import product + + segments = ("a", "b", "*", "[ab]", "**") + names = ("a", "b", "ab") + patterns = [p for size in range(1, 4) for p in product(segments, repeat=size)] + paths = [p for size in range(0, 4) for p in product(names, repeat=size)] + for pattern in patterns: + glob = "/".join(pattern) + for parts in paths: + expected = any(glob_matches("/".join(parts[:k]), glob, anchored=True) for k in range(1, len(parts) + 1)) + assert glob_matches("/".join(parts), glob, anchored=True, prefix=True) is expected, (parts, pattern) + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_a_brace_in_a_segment_without_a_comma_is_read_literally() -> None: + assert frozenset("*?[{") == GLOB_CHARS + assert glob_matches("a{x}b/c", "a{x}b/c", anchored=True) + assert not glob_matches("axb/c", "a{x}b/c", anchored=True) + assert glob_matches("{x}", "{x}") + assert not glob_matches("x", "{x}") + + +@pytest.mark.unit +@pytest.mark.subsys_classify +@pytest.mark.parametrize( + "text", ["plain", "[slug]", "a*b", "a?b", "{a,b}", "a,b", "{x}", "[!x]", "a]b", "[{]", "a-b.c"] +) +def test_escaped_text_matches_only_itself(text: str) -> None: + from reporails_cli.core.platform.contract.glob_pattern import escape_glob + + assert glob_matches(text, escape_glob(text), anchored=True) + assert glob_matches(f"p/{text}/x", "**/" + escape_glob(text) + "/**", anchored=True) + for other in ("plain", "slug", "axb", "a", "b", "x", "a,b", "{a,b}", "!", "{"): + assert glob_matches(other, escape_glob(text), anchored=True) is (other == text) + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_an_escaped_brace_does_not_open_a_group() -> None: + from reporails_cli.core.platform.contract.glob_pattern import brace_alternatives + + assert brace_alternatives("**/[{]a,b}") == ("**/[{]a,b}",) + assert glob_matches("{a,b}", "**/[{]a,b}", anchored=True) + assert not glob_matches("a", "**/[{]a,b}", anchored=True) + assert glob_matches("x/y.ts", "x/*.{ts,tsx}", anchored=True) diff --git a/tests/unit/test_glob_walk_owner.py b/tests/unit/test_glob_walk_owner.py index 482e679..6469082 100644 --- a/tests/unit/test_glob_walk_owner.py +++ b/tests/unit/test_glob_walk_owner.py @@ -10,6 +10,7 @@ from reporails_cli.core.discovery import walk from reporails_cli.core.discovery.agent_discovery import ci_glob, glob_file_type_patterns +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.lint.mechanical.checks import _resolve_glob_targets @@ -43,7 +44,9 @@ def test_symlinked_rules_folder_is_discovered(tmp_path: Path) -> None: _write(shared / "deep" / "more.md") (tmp_path / ".claude" / "rules").mkdir(parents=True) os.symlink(shared, tmp_path / ".claude" / "rules" / "linked", target_is_directory=True) - found = glob_file_type_patterns(tmp_path, [".claude/rules/**/*.md"], {"scope": "path_scoped"}, frozenset()) + found = glob_file_type_patterns( + tmp_path, [".claude/rules/**/*.md"], {"scope": "path_scoped"}, RunExclusions.none(tmp_path) + ) assert _rel(tmp_path, found) == {".claude/rules/linked/style.md", ".claude/rules/linked/deep/more.md"} @@ -54,7 +57,7 @@ def test_symlinked_skill_folder_is_discovered(tmp_path: Path) -> None: _write(real / "SKILL.md") (tmp_path / ".claude" / "skills").mkdir(parents=True) os.symlink(real, tmp_path / ".claude" / "skills" / "deploy", target_is_directory=True) - found = glob_file_type_patterns(tmp_path, [".claude/skills/*/SKILL.md"], {}, frozenset()) + found = glob_file_type_patterns(tmp_path, [".claude/skills/*/SKILL.md"], {}, RunExclusions.none(tmp_path)) assert _rel(tmp_path, found) == {".claude/skills/deploy/SKILL.md"} @@ -65,7 +68,7 @@ def test_ci_glob_never_lists_a_nested_excluded_folder(tmp_path: Path, monkeypatc _write(tmp_path / "web" / "core" / "deep" / "y.md") _write(tmp_path / "a" / "node_modules" / "pkg" / "z.md") listed = _spy_listings(monkeypatch) - found = ci_glob(tmp_path, "**/*.md", frozenset({"core", "node_modules"})) + found = ci_glob(tmp_path, "**/*.md", RunExclusions.parse(tmp_path, ["core", "node_modules"], ())) assert _rel(tmp_path, found) == {"a/.claude/agents/x.md"} assert listed assert not [p for p in listed if Path(p).name in {"core", "node_modules", "deep", "pkg"}] @@ -99,9 +102,11 @@ def test_mechanical_targets_are_files_only(tmp_path: Path) -> None: @pytest.mark.subsys_lint def test_case_insensitive_prefix_folders_are_resolved(tmp_path: Path) -> None: _write(tmp_path / ".Claude" / "Agents" / "x.md") - found = ci_glob(tmp_path, ".claude/agents/*.md", frozenset()) + found = ci_glob(tmp_path, ".claude/agents/*.md", RunExclusions.none(tmp_path)) assert _rel(tmp_path, found) == {".Claude/Agents/x.md"} - assert _rel(tmp_path, ci_glob(tmp_path, ".claude/agents/X.MD", frozenset())) == {".Claude/Agents/x.md"} + assert _rel(tmp_path, ci_glob(tmp_path, ".claude/agents/X.MD", RunExclusions.none(tmp_path))) == { + ".Claude/Agents/x.md" + } @pytest.mark.unit @@ -110,7 +115,7 @@ def test_case_sensitive_match_is_the_default(tmp_path: Path) -> None: _write(tmp_path / ".Claude" / "x.md") if (tmp_path / ".claude").exists(): pytest.skip("case-insensitive filesystem: .claude and .Claude are the same folder") - assert list(walk.walk_glob_matches(tmp_path, ".claude/*.md", frozenset())) == [] + assert list(walk.walk_glob_matches(tmp_path, ".claude/*.md", RunExclusions.none(tmp_path))) == [] @pytest.mark.unit @@ -119,7 +124,7 @@ def test_symlink_cycle_terminates(tmp_path: Path) -> None: rules = tmp_path / ".claude" / "rules" _write(rules / "a.md") os.symlink(tmp_path / ".claude", rules / "loop", target_is_directory=True) - found = ci_glob(tmp_path, ".claude/rules/**/*.md", frozenset()) + found = ci_glob(tmp_path, ".claude/rules/**/*.md", RunExclusions.none(tmp_path)) assert ".claude/rules/a.md" in _rel(tmp_path, found) @@ -136,6 +141,9 @@ def spy(self: Path, *args: object, **kwargs: object) -> bool: return real(self, *args, **kwargs) # type: ignore[arg-type] monkeypatch.setattr(Path, "is_file", spy) - assert _rel(tmp_path, walk.walk_markdown(tmp_path, frozenset())) == {"a/one.md", "two.md"} - assert _rel(tmp_path, walk.walk_glob_matches(tmp_path, "**/*.md", frozenset())) == {"a/one.md", "two.md"} + assert _rel(tmp_path, walk.walk_markdown(tmp_path, RunExclusions.none(tmp_path))) == {"a/one.md", "two.md"} + assert _rel(tmp_path, walk.walk_glob_matches(tmp_path, "**/*.md", RunExclusions.none(tmp_path))) == { + "a/one.md", + "two.md", + } assert stats == [] diff --git a/tests/unit/test_left_out_folders.py b/tests/unit/test_left_out_folders.py new file mode 100644 index 0000000..e7b5b1e --- /dev/null +++ b/tests/unit/test_left_out_folders.py @@ -0,0 +1,165 @@ +"""Left-out folders are tested through the one path matcher, in every walk and through symlinks.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import pytest + +from reporails_cli.core.discovery.exclusions import RunExclusions +from reporails_cli.core.discovery.plugin_roots import find_plugin_roots +from reporails_cli.core.discovery.walk import walk_glob_matches, walk_markdown +from reporails_cli.core.platform.policy.exclusion import folder_value_problem +from reporails_cli.core.platform.policy.preflight import _files_by_name, preflight_oversized + + +def _tree(root: Path, *rels: str) -> None: + for rel in rels: + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text("x\n", encoding="utf-8") + + +def _walked(root: Path, skip: RunExclusions) -> set[str]: + return {Path(p).relative_to(root).as_posix() for p in walk_markdown(root, skip)} + + +def _link(link: Path, target: Path) -> None: + try: + link.symlink_to(target, target_is_directory=True) + except OSError: + pytest.skip("symlinks cannot be created here") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize( + ("value", "left_out", "kept"), + [ + ("[slug]", ["[slug]/a.md", "app/[slug]/a.md"], ["s/a.md", "slug/a.md", "l/a.md"]), + ("app/[id]", ["app/[id]/a.md"], ["app/i/a.md", "app/d/a.md", "app/id/a.md", "x/app/[id]/a.md"]), + ("{a,b}", ["{a,b}/a.md", "p/{a,b}/a.md"], ["a/a.md", "b/a.md", "p/a/a.md", "a,b/a.md"]), + ("a,b", ["a,b/a.md", "p/a,b/a.md"], ["a/a.md", "b/a.md", "{a,b}/a.md"]), + ], +) +def test_a_folder_spelled_with_glob_characters_is_left_out_by_its_value_only( + tmp_path: Path, value: str, left_out: list[str], kept: list[str] +) -> None: + _tree(tmp_path, *left_out, *kept) + skip = RunExclusions.parse(tmp_path, [value], ()) + assert _walked(tmp_path, skip) == set(kept) + assert all(skip.leaves_out(tmp_path / rel) for rel in left_out) + assert not any(skip.leaves_out(tmp_path / rel) for rel in kept) + assert all(skip.covers((tmp_path / rel).parent) for rel in left_out) + assert not any(skip.covers((tmp_path / rel).parent) for rel in kept) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_symlink_into_a_folder_left_out_by_name_is_entered_by_no_walk(tmp_path: Path) -> None: + _tree(tmp_path, "docs/pkg/x.md", "docs/pkg/plugin.json", "keep.md") + _link(tmp_path / "link", tmp_path / "docs" / "pkg") + skip = RunExclusions.parse(tmp_path, ["docs"], ()) + open_ = RunExclusions.none(tmp_path) + assert _walked(tmp_path, skip) == {"keep.md"} + assert skip.covers(tmp_path / "link") and skip.covers(tmp_path / "link" / "x.md") + assert not open_.covers(tmp_path / "link") + assert [p.name for p in walk_glob_matches(tmp_path, "link/*.md", open_)] == ["x.md"] + assert list(walk_glob_matches(tmp_path, "link/*.md", skip)) == [] + assert not skip.prunes(str(tmp_path / "keep.md"), "keep.md", str(tmp_path / "keep.md")) + assert skip.prunes(str(tmp_path / "link"), "link", os.path.realpath(tmp_path / "link")) + assert find_plugin_roots(tmp_path, "plugin.json", open_) == [tmp_path / "docs" / "pkg"] + assert find_plugin_roots(tmp_path, "plugin.json", skip) == [] + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_plugin_root_inside_a_folder_left_out_is_not_found(tmp_path: Path) -> None: + _tree(tmp_path, "docs/pl/plugin.json", "pl2/plugin.json") + assert find_plugin_roots(tmp_path, "plugin.json", RunExclusions.none(tmp_path)) == [ + tmp_path / "docs" / "pl", + tmp_path / "pl2", + ] + assert find_plugin_roots(tmp_path, "plugin.json", RunExclusions.parse(tmp_path, ["docs"], ())) == [tmp_path / "pl2"] + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize("value", ["docs", "pkg/docs"]) +def test_a_left_out_folder_holding_a_named_file_is_entered_only_on_the_way_to_it(tmp_path: Path, value: str) -> None: + _tree(tmp_path, "pkg/docs/a.md", "pkg/docs/sub/s.md", "pkg/docs/sub/deep/d.md", "pkg/docs/hold/h.md", "pkg/k.md") + skip = RunExclusions.parse(tmp_path, [value], ()).with_named([tmp_path / "pkg" / "docs" / "hold" / "h.md"]) + assert _walked(tmp_path, skip) == {"pkg/docs/a.md", "pkg/docs/hold/h.md", "pkg/k.md"} + assert not skip.covers(tmp_path / "pkg" / "docs" / "hold" / "h.md") + assert skip.covers(tmp_path / "pkg" / "docs" / "sub") + assert skip.covers(tmp_path / "pkg" / "docs" / "sub" / "deep" / "d.md") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_covers_and_the_walk_agree_on_every_folder(tmp_path: Path) -> None: + _tree( + tmp_path, + "a.md", + "docs/d.md", + "docs/in/i.md", + "pkg/p.md", + "pkg/x/x.md", + "pkg/x/deep/y.md", + "pkg/ok/o.md", + "pkg/ok/docs/z.md", + "other/pkg/x/q.md", + "[id]/b.md", + "node_modules/n.md", + ) + _link(tmp_path / "alias", tmp_path / "docs" / "in") + _link(tmp_path / "pkg" / "ok" / "back", tmp_path / "pkg" / "x") + skip = RunExclusions.parse(tmp_path, ["docs", "pkg/x", "[id]"], ()) + walked = _walked(tmp_path, skip) + folders = sorted(p for p in tmp_path.rglob("*") if p.is_dir() or p.is_symlink()) + assert folders + for folder in folders: + rel = folder.relative_to(tmp_path).as_posix() + reached = any(w == rel or w.startswith(rel + "/") for w in walked) + assert skip.covers(folder) is (not reached), rel + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_folder_name_never_matches_a_folder_above_the_project_root(tmp_path: Path) -> None: + root = tmp_path / "docs" / "proj" + _tree(root, "a.md", "sub/b.md") + skip = RunExclusions.parse(root, ["docs", "proj"], ()) + assert _walked(root, skip) == {"a.md", "sub/b.md"} + assert not skip.leaves_out(root / "a.md") + assert not skip.covers(root / "sub") + assert not skip.prunes(str(root / "sub"), "sub", str(root / "sub")) + assert skip.record() == ((("docs", "proj")), ()) + + +@pytest.mark.unit +@pytest.mark.subsys_funnel +def test_the_over_cap_route_survives_paths_with_empty_or_dot_segments() -> None: + per_file = { + "a//b.md": 3, + "./a/b.md": 3, + "docs/x.md": 2, + "x/./y/z.md": 1, + "/abs/q.md": 1, + "[id]/p.md": 1, + "a b/c.md": 1, + } + by_name = _files_by_name(per_file) + assert "" not in by_name and "." not in by_name + assert all(folder_value_problem(name) is None for name in by_name) + assert by_name["a"] == {"a//b.md", "./a/b.md"} + assert by_name["docs"] == {"docs/x.md"} + assert by_name["[id]"] == {"[id]/p.md"} + assert by_name["a b"] == {"a b/c.md"} + payload = { + "files": [{"path": path} for path in per_file], + "atoms": [{"fi": i} for i, n in enumerate(per_file.values()) for _ in range(n * 2000)], + } + err = preflight_oversized(payload, has_api_key=False) + assert err is not None + assert all(name and folder_value_problem(name) is None for name in err.exclude_route) diff --git a/tests/unit/test_mechanical.py b/tests/unit/test_mechanical.py index 8c5e7f1..c6534e9 100644 --- a/tests/unit/test_mechanical.py +++ b/tests/unit/test_mechanical.py @@ -408,6 +408,24 @@ def test_all_dirs_have_entrypoint_passes(self, tmp_path: Path) -> None: result = skill_entrypoint_present(tmp_path, {}, classified) assert result.passed + @pytest.mark.unit + @pytest.mark.subsys_lint + @pytest.mark.parametrize( + ("left_out", "listed"), + [([], True), (["nofile"], False), (["./.claude/skills/nofile"], False), (["other"], True)], + ) + def test_a_skill_folder_the_run_leaves_out_is_not_listed_as_missing_its_entry( + self, tmp_path: Path, left_out: list[str], listed: bool + ) -> None: + from reporails_cli.core.discovery.exclusions import RunExclusions + + self._make_skill(tmp_path, "alpha") + (tmp_path / ".claude" / "skills" / "nofile").mkdir() + args = {"_exclusions": RunExclusions.parse(tmp_path, left_out, ())} + result = skill_entrypoint_present(tmp_path, args, self._classified_alpha(tmp_path)) + assert result.passed is not listed + assert ("nofile" in result.message) is listed + @pytest.mark.unit @pytest.mark.subsys_lint def test_missing_entrypoint_in_sibling_dir_fails(self, tmp_path: Path) -> None: diff --git a/tests/unit/test_mechanical_run_exclusions.py b/tests/unit/test_mechanical_run_exclusions.py index c1d4ba4..841cbf0 100644 --- a/tests/unit/test_mechanical_run_exclusions.py +++ b/tests/unit/test_mechanical_run_exclusions.py @@ -63,7 +63,7 @@ def test_rule_paths_glob_matches_files_inside_a_left_out_folder(tmp_path: Path) def test_file_absent_ignores_a_forbidden_file_in_a_left_out_folder(tmp_path: Path) -> None: (tmp_path / "vendored").mkdir() (tmp_path / "vendored" / "BAD.md").write_text("x\n", encoding="utf-8") - left_out = RunExclusions(root=tmp_path, dirs=frozenset({"vendored"}), walk_dirs=frozenset({"vendored"})) + left_out = RunExclusions.parse(tmp_path, ["vendored"], ()) args = {"pattern": "**/BAD.md", "_exclusions": left_out} assert file_absent(tmp_path, args, []).passed kept = RunExclusions.none(tmp_path) diff --git a/tests/unit/test_memory_classification.py b/tests/unit/test_memory_classification.py index 2e0e00d..9544638 100644 --- a/tests/unit/test_memory_classification.py +++ b/tests/unit/test_memory_classification.py @@ -125,6 +125,7 @@ def test_memory_index_eager_siblings_on_demand(self, tmp_path: Path) -> None: @pytest.mark.unit @pytest.mark.subsys_lint def test_memory_folder_is_found_for_a_project_path_with_underscore_and_dot(tmp_path: Path, monkeypatch) -> None: + from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.memory_locator import memory_entries_for_agent project = tmp_path / "rv_mem.proj" @@ -136,6 +137,6 @@ def test_memory_folder_is_found_for_a_project_path_with_underscore_and_dot(tmp_p monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("USERPROFILE", str(home)) # Path.home() reads USERPROFILE on Windows - entries = memory_entries_for_agent("claude", project) + entries = memory_entries_for_agent("claude", project, RunExclusions.none(project)) assert [e.path.name for e in entries] == ["MEMORY.md"] diff --git a/tests/unit/test_memory_locator_patterns.py b/tests/unit/test_memory_locator_patterns.py index d1fbd53..8f11a79 100644 --- a/tests/unit/test_memory_locator_patterns.py +++ b/tests/unit/test_memory_locator_patterns.py @@ -6,6 +6,7 @@ import pytest +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.memory_locator import _entries_from_directory_globs @@ -16,6 +17,6 @@ def test_a_scope_holding_one_pattern_as_text_enumerates_its_directory(tmp_path: (tmp_path / "mem" / "note.md").write_text("# n\n", encoding="utf-8") spec = {"scopes": {"project": {"patterns": "mem/"}}} - entries = _entries_from_directory_globs("claude", "subagent_memory", spec, tmp_path) + entries = _entries_from_directory_globs("claude", "subagent_memory", spec, tmp_path, RunExclusions.none(tmp_path)) assert [e.path.name for e in entries] == ["note.md"] diff --git a/tests/unit/test_pipeline_mapping.py b/tests/unit/test_pipeline_mapping.py index 3e5aa9a..e997d13 100644 --- a/tests/unit/test_pipeline_mapping.py +++ b/tests/unit/test_pipeline_mapping.py @@ -750,3 +750,107 @@ def test_the_cache_holds_the_map_as_mapped_before_membership_is_applied(monkeypa assert seen == [("skills", "")] # cached as mapped assert (result.files[0].type, result.files[0].skill) == ("generic", "") # then decided for this run + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_apply_generic_scan_warns_when_the_fold_fails(monkeypatch, caplog): + """A failed fold returns no imports and says generic scanning was skipped.""" + + def boom(*_a, **_k): + raise OSError("disk gone") + + monkeypatch.setattr(mapping, "load_file_types", boom) + with caplog.at_level(logging.WARNING, logger=mapping.__name__): + assert mapping.apply_generic_scan(Path("/proj"), [], "claude", True) == ([], {}) + assert any("Generic scanning skipped" in r.getMessage() and "disk gone" in r.getMessage() for r in caplog.records) + + +@pytest.mark.unit +@pytest.mark.subsys_map +@pytest.mark.parametrize(("agent", "other_added"), [("claude", False), ("", True)]) +def test_explicit_agent_places_no_file_another_agent_owns(tmp_path, agent, other_added): + """A named file inside a left-out folder that another agent lists is placed under that agent + only when no agent was asked for; an explicit run never gains the other agent.""" + from reporails_cli.core.discovery.agents import DetectedAgent, get_known_agents + from reporails_cli.core.discovery.exclusions import RunExclusions + + known = get_known_agents() + cursor_file = tmp_path / "ext" / ".cursor" / "rules" / "r.mdc" + cursor_file.parent.mkdir(parents=True) + cursor_file.write_text("- Use uv.\n", encoding="utf-8") + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("# P\n", encoding="utf-8") + detected = [ + DetectedAgent(known["claude"], [claude_md], [], []), + DetectedAgent(known["cursor"], [], [], [cursor_file]), + ] + exclusions = RunExclusions.parse(tmp_path, ["ext"], ()).with_named([cursor_file]) + + _, _, _, filtered = mapping.resolve_agent_filters(agent, detected, tmp_path, exclusions) + + ids = [d.agent_type.id for d in filtered] + assert ("cursor" in ids) is other_added + placed = [f for d in filtered for f in (*d.instruction_files, *d.rule_files, *d.config_files)] + assert (cursor_file in placed) is other_added + + +def _unlisted_named_case(tmp_path, folder, rel): + """A claude-only detection (the named file is not listed) and exclusions leaving out `folder`.""" + from reporails_cli.core.discovery.agents import DetectedAgent, get_known_agents + from reporails_cli.core.discovery.exclusions import RunExclusions + + named = tmp_path / rel + named.parent.mkdir(parents=True) + named.write_text("- Use uv.\n", encoding="utf-8") + claude_md = tmp_path / "CLAUDE.md" + claude_md.write_text("# P\n", encoding="utf-8") + detected = [DetectedAgent(get_known_agents()["claude"], [claude_md], [], [])] + return named, detected, RunExclusions.parse(tmp_path, [folder], ()).with_named([named]) + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_explicit_agent_skips_an_unlisted_file_its_types_do_not_classify(tmp_path): + """A cursor file the config left out of detection is not placed under an explicit claude run.""" + named, detected, exclusions = _unlisted_named_case(tmp_path, "rules", ".cursor/rules/a.mdc") + _, _, _, filtered = mapping.resolve_agent_filters("claude", detected, tmp_path, exclusions) + placed = [f for d in filtered for f in (*d.instruction_files, *d.rule_files, *d.config_files)] + assert named not in placed + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_explicit_agent_places_an_unlisted_file_its_types_classify(tmp_path): + named, detected, exclusions = _unlisted_named_case(tmp_path, "rules", ".claude/rules/x.md") + _, _, _, filtered = mapping.resolve_agent_filters("claude", detected, tmp_path, exclusions) + claude = next(d for d in filtered if d.agent_type.id == "claude") + assert named in claude.rule_files + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_without_an_explicit_agent_an_unclassified_unlisted_file_is_an_instruction_file(tmp_path): + named, detected, exclusions = _unlisted_named_case(tmp_path, "rules", ".cursor/rules/a.mdc") + _, _, _, filtered = mapping.resolve_agent_filters("", detected, tmp_path, exclusions) + placed = [f for d in filtered for f in (*d.instruction_files, *d.rule_files, *d.config_files)] + assert named in placed + + +@pytest.mark.unit +def test_an_agent_whose_file_types_cannot_be_read_claims_no_file(monkeypatch, tmp_path): + real = mapping.load_file_types + others = [a for a in mapping.get_known_agents() if a != "claude"] + unreadable = others[0] + + def load(agent, *args, **kwargs): + if agent == unreadable: + raise OSError("unreadable") + return real(agent, *args, **kwargs) + + monkeypatch.setattr(mapping, "load_file_types", load) + monkeypatch.setattr(mapping, "get_known_agents", lambda: {unreadable: object()}) + path = tmp_path / "notes.md" + path.write_text("x\n", encoding="utf-8") + assert mapping._claimed_by_other(path, "claude", tmp_path) is False + assert mapping._kind_of(path, unreadable, tmp_path) == "instruction_files" diff --git a/tests/unit/test_run_exclusions.py b/tests/unit/test_run_exclusions.py index 4290c2e..0f2d99d 100644 --- a/tests/unit/test_run_exclusions.py +++ b/tests/unit/test_run_exclusions.py @@ -45,9 +45,11 @@ def _gs_project(tmp_path: Path, config: str = "") -> Path: def test_flags_join_config_for_folders_and_globs(tmp_path: Path) -> None: _write_config(tmp_path, "exclude_dirs: [ext]\nexclude_files: ['a/*.md']\n") excl = load_run_exclusions(tmp_path, ["docs"], ["b/*.md"]) - assert excl.dirs == {"ext", "docs"} + assert {d.text for d in excl.dirs} == {"ext", "docs"} assert set(excl.files) == {"a/*.md", "b/*.md"} - assert excl.walk_dirs == DEFAULT_EXCLUDE_DIRS | {"ext", "docs"} + for name in (*DEFAULT_EXCLUDE_DIRS, "ext", "docs"): + assert excl.prunes(str(tmp_path / name), name, os.path.realpath(tmp_path / name)) + assert not excl.prunes(str(tmp_path / "other"), "other", str(tmp_path / "other")) @pytest.mark.unit @@ -71,20 +73,20 @@ def test_leaves_out_logical_resolved_and_symlinked_root(tmp_path: Path, monkeypa link = tmp_path / "link" link.symlink_to(real, target_is_directory=True) # Root given through the link, path given resolved. - assert RunExclusions(root=link, dirs=frozenset({"ext"})).leaves_out((real / "ext" / "x.md").resolve()) + assert RunExclusions.parse(link, ["ext"], ()).leaves_out((real / "ext" / "x.md").resolve()) # Root resolved, path given through the link. - assert RunExclusions(root=real.resolve(), dirs=frozenset({"ext"})).leaves_out(link / "ext" / "x.md") + assert RunExclusions.parse(real.resolve(), ["ext"], ()).leaves_out(link / "ext" / "x.md") # Glob form through the link. assert RunExclusions(root=link, files=("ext/*.md",)).leaves_out((real / "ext" / "x.md").resolve()) # Relative logical path against an absolute root's cwd-independent form (cwd on the same drive). monkeypatch.chdir(tmp_path) - assert RunExclusions(root=real, dirs=frozenset({"ext"})).leaves_out(Path(os.path.relpath(real / "ext" / "x.md"))) + assert RunExclusions.parse(real, ["ext"], ()).leaves_out(Path(os.path.relpath(real / "ext" / "x.md"))) @pytest.mark.unit @pytest.mark.subsys_map def test_record_sorted_drops_put_back_keeps_unmatched(tmp_path: Path) -> None: - excl = RunExclusions(root=tmp_path, dirs=frozenset({"zeta", "ext", "gone"}), files=("b/*.md", "a/*.md", "b/*.md")) + excl = RunExclusions.parse(tmp_path, ["zeta", "ext", "gone"], ["b/*.md", "a/*.md", "b/*.md"]) assert excl.record() == (("ext", "gone", "zeta"), ("a/*.md", "b/*.md")) named = excl.with_named([tmp_path / "ext" / "x.md", tmp_path / "a" / "y.md"]) assert named.record() == (("ext", "gone", "zeta"), ("a/*.md", "b/*.md")) @@ -94,15 +96,19 @@ def test_record_sorted_drops_put_back_keeps_unmatched(tmp_path: Path) -> None: @pytest.mark.unit @pytest.mark.subsys_map def test_leaves_out_exempts_named_paths_and_walks_keep_their_folders(tmp_path: Path) -> None: - excl = RunExclusions(root=tmp_path, dirs=frozenset({"ext", "other"}), files=("a/*.md",)) + excl = RunExclusions.parse(tmp_path, ["ext", "other"], ["a/*.md"]) named = excl.with_named([tmp_path / "ext" / "x.md", tmp_path / "a" / "y.md"]) assert excl.leaves_out(tmp_path / "ext" / "x.md") assert not named.leaves_out(tmp_path / "ext" / "x.md") assert not named.leaves_out(tmp_path / "a" / "y.md") assert named.leaves_out(tmp_path / "ext" / "z.md") assert named.leaves_out(tmp_path / "other" / "z.md") - assert "ext" in excl.feature_walk_dirs and "other" in excl.feature_walk_dirs - assert "ext" not in named.feature_walk_dirs and "other" in named.feature_walk_dirs + assert excl.prunes(str(tmp_path / "ext"), "ext", str(tmp_path / "ext")) and excl.prunes( + str(tmp_path / "other"), "other", str(tmp_path / "other") + ) + assert not named.prunes(str(tmp_path / "ext"), "ext", str(tmp_path / "ext")) and named.prunes( + str(tmp_path / "other"), "other", str(tmp_path / "other") + ) # ─── The link walk ────────────────────────────────────────────────────── @@ -113,7 +119,7 @@ def test_leaves_out_exempts_named_paths_and_walks_keep_their_folders(tmp_path: P @pytest.mark.parametrize( "excl", [ - lambda root: RunExclusions(root=root, dirs=frozenset({"ext"})), + lambda root: RunExclusions.parse(root, ["ext"], ()), lambda root: RunExclusions(root=root, files=("ext/*.md",)), ], ids=["folder", "glob"], @@ -139,9 +145,7 @@ def test_walker_never_enters_a_left_out_target(tmp_path: Path, excl) -> None: def test_walker_never_skips_a_seed(tmp_path: Path) -> None: project = _gs_project(tmp_path) seed = project / "ext" / "notes.md" - edges = walk_markdown_links( - {seed: "main"}, project, {seed}, exclusions=RunExclusions(root=project, dirs=frozenset({"ext"})) - ) + edges = walk_markdown_links({seed: "main"}, project, {seed}, exclusions=RunExclusions.parse(project, ["ext"], ())) assert edges == [] # the seed itself is walked; its only link (a.md) is left out other = project / "CLAUDE.md" edges = walk_markdown_links( @@ -208,4 +212,87 @@ def test_mcp_discover_files_fold_never_checks_a_left_out_file(tmp_path, case, co assert not names & {"notes.md", "ref.md", "a.md"} assert "docs/b.md" in discovered[3] assert not any("ext" in k.split("/") for k in discovered[3]) - assert discovered[5].dirs | set(discovered[5].files) + assert discovered[5].dirs or discovered[5].files + + +# ─── The Excluded record on a narrowed run ────────────────────────────── + + +def _scope_state(project: Path, path_targets: set[Path], specs: list[tuple[str, str]]) -> CheckState: + clear_agent_cache() + inputs = CheckInputs( + targets=[], + format_opt="json", + agent="claude", + exclude_dirs=["ext"], + exclude_files=None, + ascii_mode=True, + strict=False, + verbose=False, + heal=False, + dry_run=False, + cwd=False, + project_root=project, + ) + state = CheckState(inputs=inputs) + state.targets.target = project + state.targets.path_targets = path_targets + state.targets.capability_specs = specs + return state + + +@pytest.mark.unit +@pytest.mark.subsys_cli_ux +@pytest.mark.parametrize( + ("case", "kept"), + [ + ("file", False), + ("file+folder", True), + ("file+listing", True), + ("named-capability", False), + ("named-capability+listing", True), + ], +) +def test_excluded_record_drops_a_named_files_pattern_only_when_the_run_is_only_named_files( + tmp_path: Path, case: str, kept: bool +) -> None: + project = _gs_project(tmp_path) + named = project / "ext" / "notes.md" + targets, specs, cap_paths = {named}, [], frozenset() + if case == "file+folder": + targets = {named, project / "docs"} + elif case == "file+listing": + specs = [("hooks", "")] + elif case.startswith("named-capability"): + targets, specs, cap_paths = set(), [("skills", "backlog")], frozenset({named}) + if case.endswith("listing"): + specs.append(("hooks", "")) + state = _scope_state(project, targets, specs) + check_flow._resolve_scope_at_target(state, cap_paths) + assert (state.render.excluded[0] == ("ext",)) is kept + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_covers_a_named_folder_hit_spares_a_folder_holding_a_named_file(tmp_path: Path) -> None: + named_file = tmp_path / "pkg" / "docs" / "a.md" + named_file.parent.mkdir(parents=True) + named_file.write_text("x") + (named_file.parent / "b.md").write_text("x") + excl = RunExclusions.parse(tmp_path, ["docs"], ()).with_named([named_file]) + assert not excl.covers(named_file) + assert not excl.covers(tmp_path / "pkg" / "docs") + assert not excl.covers(tmp_path / "pkg" / "docs" / "b.md") + assert excl.covers(tmp_path / "docs" / "b.md") + assert not excl.covers(tmp_path / "pkg" / "other.md") + assert not excl.covers(tmp_path.parent / "docs" / "b.md") + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_a_file_named_like_a_default_folder_is_not_covered_but_its_contents_are(tmp_path: Path) -> None: + (tmp_path / "build").write_text("x") + (tmp_path / "dist").mkdir() + excl = RunExclusions.none(tmp_path) + assert not excl.covers(tmp_path / "build") + assert excl.covers(tmp_path / "dist" / "a.md") diff --git a/tests/unit/test_run_exclusions_paths.py b/tests/unit/test_run_exclusions_paths.py index 93dd8e5..73516b5 100644 --- a/tests/unit/test_run_exclusions_paths.py +++ b/tests/unit/test_run_exclusions_paths.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from pathlib import Path from types import SimpleNamespace @@ -16,9 +17,8 @@ @pytest.mark.subsys_lint def test_trailing_slash_and_dot_prefix_normalize(tmp_path: Path) -> None: left_out = load_run_exclusions(tmp_path, ["rules/", "./docs/", "/", ""], None) - assert left_out.dirs == frozenset({"rules", "docs"}) - assert left_out.record() == (("docs", "rules"), ()) - assert "rules" in left_out.walk_dirs + assert left_out.record() == (("./docs", "rules"), ()) + assert left_out.prunes(str(tmp_path / "x" / "rules"), "rules", os.path.realpath(str(tmp_path / "x" / "rules"))) assert left_out.leaves_out(tmp_path / "x" / "rules" / "a.md") assert left_out.leaves_out(tmp_path / "docs" / "a.md") @@ -30,12 +30,21 @@ def test_path_form_value_is_relative_to_the_root(tmp_path: Path) -> None: assert path_form.leaves_out(tmp_path / ".claude" / "rules" / "a.md") assert not path_form.leaves_out(tmp_path / "docs" / "rules" / "b.md") assert not path_form.leaves_out(tmp_path / "x" / ".claude" / "rules" / "a.md") - assert ".claude/rules" not in path_form.walk_dirs - assert ".claude/rules" not in path_form.feature_walk_dirs + assert path_form.prunes( + str(tmp_path / ".claude" / "rules"), "rules", os.path.realpath(str(tmp_path / ".claude" / "rules")) + ) + assert not path_form.prunes( + str(tmp_path / "docs" / "rules"), "rules", os.path.realpath(str(tmp_path / "docs" / "rules")) + ) + assert not path_form.prunes( + str(tmp_path / "x" / ".claude" / "rules"), "rules", os.path.realpath(str(tmp_path / "x" / ".claude" / "rules")) + ) name_form = load_run_exclusions(tmp_path, ["rules"], None) assert name_form.leaves_out(tmp_path / ".claude" / "rules" / "a.md") assert name_form.leaves_out(tmp_path / "docs" / "rules" / "b.md") - assert "rules" in name_form.walk_dirs + assert name_form.prunes( + str(tmp_path / "docs" / "rules"), "rules", os.path.realpath(str(tmp_path / "docs" / "rules")) + ) @pytest.mark.unit @@ -79,5 +88,27 @@ def test_symlinked_importer_reads_the_source_next_to_the_real_file(tmp_path: Pat effective_agent="claude", lint_result=None, ) - assert _imports_left_out(inp, RunExclusions(root, dirs=frozenset({"ext"}))) + assert _imports_left_out(inp, RunExclusions.parse(root, ["ext"], ())) assert "(from ext/notes.md:3" in _imported_note(atom, root) + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_covers_asks_every_folder_from_the_root_down(tmp_path: Path) -> None: + excl = RunExclusions.parse(tmp_path, ["scratch", "pkg/docs"], ()) + assert excl.covers(tmp_path / "node_modules" / "x" / "a.md") + assert excl.covers(tmp_path / "a" / "scratch" / "b" / "a.md") + assert excl.covers(tmp_path / "pkg" / "docs" / "a.md") + assert not excl.covers(tmp_path / "other" / "pkg" / "docs" / "a.md") + assert not excl.covers(tmp_path / "pkg" / "keep" / "a.md") + assert not excl.covers(tmp_path.parent / "node_modules" / "a.md") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_record_reports_each_folder_in_the_form_it_was_given(tmp_path: Path) -> None: + excl = RunExclusions.parse(tmp_path, ["./docs", "pkg/docs", "scratch", "scratch/"], ()) + assert excl.record() == (("./docs", "pkg/docs", "scratch"), ()) + named = excl.with_named([tmp_path / "pkg" / "docs" / "a.md"]) + assert named.record(narrowed=True) == (("./docs", "scratch"), ()) + assert named.record() == (("./docs", "pkg/docs", "scratch"), ()) diff --git a/tests/unit/test_scorecard_readability.py b/tests/unit/test_scorecard_readability.py index 884a0e2..821d7b9 100644 --- a/tests/unit/test_scorecard_readability.py +++ b/tests/unit/test_scorecard_readability.py @@ -352,7 +352,7 @@ def _no_findings(capsys: pytest.CaptureFixture[str], **fields: object) -> str: from reporails_cli.core.platform.runtime.merger import CombinedResult from reporails_cli.formatters.text import display - result = CombinedResult(quality=None, **fields) # type: ignore[arg-type] + result = CombinedResult(**{"quality": None, **fields}) # type: ignore[arg-type] display.print_text_result(result, elapsed_ms=0, ascii_mode=True, verbose=False) return capsys.readouterr().out @@ -366,6 +366,18 @@ def test_no_findings_run_names_what_was_left_out(self, capsys: pytest.CaptureFix assert "Excluded docs/, *.generated.md — not checked" in out assert "imported text" not in out + @pytest.mark.unit + @pytest.mark.subsys_cli_ux + def test_no_findings_run_prints_no_score_so_the_line_drops_the_score_clause( + self, capsys: pytest.CaptureFixture[str] + ) -> None: + out = self._no_findings( + capsys, omissions=Omissions(excluded_dirs=("docs",)), quality=_Quality(display_score=7.4) + ) + assert "No findings." in out + assert "Excluded docs/ — not checked" in out + assert "the score covers the rest" not in out + @pytest.mark.unit @pytest.mark.subsys_cli_ux def test_no_findings_run_with_imported_text_counted(self, capsys: pytest.CaptureFixture[str]) -> None: diff --git a/tests/unit/test_symlink_detection.py b/tests/unit/test_symlink_detection.py index a86ada6..6b1d58b 100644 --- a/tests/unit/test_symlink_detection.py +++ b/tests/unit/test_symlink_detection.py @@ -13,7 +13,7 @@ import pytest -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.features import detect_features_filesystem pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="symlinks require admin on Windows") @@ -63,7 +63,7 @@ def test_finds_file_inside_symlinked_directory(self, tmp_path: Path) -> None: skills_dir.mkdir(parents=True) os.symlink(str(canonical), str(skills_dir / "audit")) - results = walk_glob(skills_dir, "SKILL.md", DEFAULT_EXCLUDE_DIRS) + results = walk_glob(skills_dir, "SKILL.md", RunExclusions.none(skills_dir)) rel = [str(p.relative_to(project)) for p in results] assert ".claude/skills/audit/SKILL.md" in rel @@ -85,7 +85,7 @@ def test_breaks_symlink_cycle(self, tmp_path: Path) -> None: os.symlink(str(b), str(a / "loop")) os.symlink(str(a), str(b / "loop")) - results = walk_glob(root, "SKILL.md", DEFAULT_EXCLUDE_DIRS) + results = walk_glob(root, "SKILL.md", RunExclusions.none(root)) # Must find the file exactly once despite the cycle; must not hang. assert len(results) == 1 @@ -107,7 +107,7 @@ def test_dedupes_two_symlinks_to_same_target(self, tmp_path: Path) -> None: os.symlink(str(canonical), str(project / "via_a")) os.symlink(str(canonical), str(project / "via_b")) - results = walk_glob(project, "SKILL.md", DEFAULT_EXCLUDE_DIRS) + results = walk_glob(project, "SKILL.md", RunExclusions.none(project)) assert len(results) == 1 @@ -125,7 +125,7 @@ def test_exact_name_dangling_symlink_excluded(self, tmp_path: Path) -> None: os.symlink(str(tmp_path / "missing-target.md"), str(tmp_path / "CLAUDE.md")) - assert ci_glob(tmp_path, "CLAUDE.md") == [] + assert ci_glob(tmp_path, "CLAUDE.md", RunExclusions.none(tmp_path)) == [] @pytest.mark.unit @pytest.mark.subsys_lint @@ -138,7 +138,7 @@ def test_wildcard_dangling_symlink_excluded(self, tmp_path: Path) -> None: (rules / "real.md").write_text("# Real\n") os.symlink(str(tmp_path / "hub" / "gone.md"), str(rules / "dangling.md")) - results = ci_glob(tmp_path, ".claude/rules/*.md") + results = ci_glob(tmp_path, ".claude/rules/*.md", RunExclusions.none(tmp_path)) assert [p.name for p in results] == ["real.md"] @@ -152,6 +152,6 @@ def test_valid_symlink_kept(self, tmp_path: Path) -> None: target.write_text("# Shared\n") os.symlink(str(target), str(tmp_path / "CLAUDE.md")) - results = ci_glob(tmp_path, "CLAUDE.md") + results = ci_glob(tmp_path, "CLAUDE.md", RunExclusions.none(tmp_path)) assert [p.name for p in results] == ["CLAUDE.md"] diff --git a/tests/unit/test_walk_excludes.py b/tests/unit/test_walk_excludes.py index 7183b42..b3d22c6 100644 --- a/tests/unit/test_walk_excludes.py +++ b/tests/unit/test_walk_excludes.py @@ -2,17 +2,25 @@ from __future__ import annotations +import os from pathlib import Path import pytest -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS, load_project_exclude_dirs +from reporails_cli.core.discovery.exclusions import RunExclusions, load_run_exclusions from reporails_cli.core.discovery.plugin_roots import find_plugin_roots -from reporails_cli.core.discovery.walk import walk_files, walk_glob, walk_markdown +from reporails_cli.core.discovery.walk import walk_files, walk_glob, walk_glob_matches, walk_markdown _NOISE = (".git", "node_modules", "__pycache__") +class _PruneNothing: + """A pruner that skips no folder.""" + + def prunes(self, path: str, name: str, real: str) -> bool: + return False + + def _tree(root: Path) -> None: (root / "CLAUDE.md").write_text("x\n", encoding="utf-8") for name in (*_NOISE, "docs", "scratch"): @@ -30,21 +38,20 @@ def _rel(root: Path, paths: object) -> set[str]: def test_default_set_keeps_every_walker_out_of_vcs_cache_and_dependency_dirs(tmp_path: Path) -> None: _tree(tmp_path) expected_names = {"CLAUDE.md", "docs/CLAUDE.md", "scratch/CLAUDE.md"} - assert _rel(tmp_path, walk_glob(tmp_path, "CLAUDE.md", DEFAULT_EXCLUDE_DIRS)) == expected_names - assert _rel(tmp_path, walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)) == expected_names | { + skip = RunExclusions.none(tmp_path) + assert _rel(tmp_path, walk_glob(tmp_path, "CLAUDE.md", skip)) == expected_names + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == expected_names | { "docs/note.md", "scratch/note.md", } - assert _rel(tmp_path, walk_files(tmp_path, DEFAULT_EXCLUDE_DIRS)) == _rel( - tmp_path, walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS) - ) + assert _rel(tmp_path, walk_files(tmp_path, skip)) == _rel(tmp_path, walk_markdown(tmp_path, skip)) @pytest.mark.unit @pytest.mark.subsys_lint def test_a_walker_given_nothing_to_exclude_enters_everything(tmp_path: Path) -> None: _tree(tmp_path) - found = _rel(tmp_path, walk_glob(tmp_path, "CLAUDE.md", frozenset())) + found = _rel(tmp_path, walk_glob(tmp_path, "CLAUDE.md", _PruneNothing())) assert {f"{name}/CLAUDE.md" for name in _NOISE} <= found @@ -54,9 +61,12 @@ def test_project_config_exclude_dirs_extend_the_default_set(tmp_path: Path) -> N _tree(tmp_path) (tmp_path / ".ails").mkdir() (tmp_path / ".ails" / "config.yml").write_text("exclude_dirs:\n - scratch\n", encoding="utf-8") - resolved = load_project_exclude_dirs(tmp_path) - assert resolved > DEFAULT_EXCLUDE_DIRS - assert "scratch" in resolved + resolved = load_run_exclusions(tmp_path) + assert resolved.prunes(str(tmp_path / "scratch"), "scratch", os.path.realpath(str(tmp_path / "scratch"))) + assert resolved.prunes( + str(tmp_path / "node_modules"), "node_modules", os.path.realpath(str(tmp_path / "node_modules")) + ) + assert not resolved.prunes(str(tmp_path / "docs"), "docs", os.path.realpath(str(tmp_path / "docs"))) assert _rel(tmp_path, walk_glob(tmp_path, "CLAUDE.md", resolved)) == {"CLAUDE.md", "docs/CLAUDE.md"} assert "scratch/note.md" not in _rel(tmp_path, walk_markdown(tmp_path, resolved)) @@ -67,9 +77,35 @@ def test_plugin_root_search_honours_the_exclude_set(tmp_path: Path) -> None: for name in ("vendored", "mine"): (tmp_path / name / ".claude-plugin").mkdir(parents=True) (tmp_path / name / ".claude-plugin" / "plugin.json").write_text("{}", encoding="utf-8") - assert find_plugin_roots(tmp_path, ".claude-plugin/plugin.json", DEFAULT_EXCLUDE_DIRS | {"vendored"}) == [ - tmp_path / "mine" - ] + assert find_plugin_roots( + tmp_path, ".claude-plugin/plugin.json", RunExclusions.parse(tmp_path, ["vendored"], ()) + ) == [tmp_path / "mine"] + + +def _vendored_plugin(project: Path) -> None: + manifest = project / "pkg" / "vendored" / "demo" / ".claude-plugin" / "plugin.json" + manifest.parent.mkdir(parents=True) + manifest.write_text('{"name": "demo", "description": "Demo plugin"}\n', encoding="utf-8") + skill = manifest.parents[1] / "skills" / "fmt" / "SKILL.md" + skill.parent.mkdir(parents=True) + skill.write_text("---\nname: fmt\ndescription: Formats files\n---\n\nRun `ruff format`.\n", encoding="utf-8") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize(("config", "found"), [("", True), ("exclude_dirs:\n - pkg/vendored\n", False)]) +def test_agent_detection_skips_a_marker_under_a_config_folder_path(tmp_path: Path, config: str, found: bool) -> None: + from reporails_cli.core.discovery.agent_markers import agent_has_marker + from reporails_cli.core.discovery.agents import clear_agent_cache, detect_agents, get_known_agents + + _vendored_plugin(tmp_path) + if config: + (tmp_path / ".ails").mkdir() + (tmp_path / ".ails" / "config.yml").write_text(config, encoding="utf-8") + known = get_known_agents() + assert agent_has_marker(tmp_path, known["claude"], known, load_run_exclusions(tmp_path)) is found + clear_agent_cache() + assert ("claude" in {a.agent_type.id for a in detect_agents(tmp_path)}) is found @pytest.mark.unit @@ -80,8 +116,8 @@ def test_local_config_exclude_dirs_reach_the_walkers_and_agent_detection(tmp_pat _tree(tmp_path) (tmp_path / ".ails").mkdir() (tmp_path / ".ails" / "config.local.yml").write_text("exclude_dirs:\n - scratch\n", encoding="utf-8") - resolved = load_project_exclude_dirs(tmp_path) - assert "scratch" in resolved + resolved = load_run_exclusions(tmp_path) + assert resolved.prunes(str(tmp_path / "scratch"), "scratch", os.path.realpath(str(tmp_path / "scratch"))) assert _rel(tmp_path, walk_glob(tmp_path, "CLAUDE.md", resolved)) == {"CLAUDE.md", "docs/CLAUDE.md"} detected = {path for agent in detect_agents(tmp_path) for path in _rel(tmp_path, agent.instruction_files)} assert "CLAUDE.md" in detected @@ -94,7 +130,9 @@ def test_global_config_exclude_dirs_extend_the_resolved_set(tmp_path: Path) -> N home = Path.home() / ".reporails" home.mkdir(parents=True, exist_ok=True) (home / "config.yml").write_text("exclude_dirs:\n - elsewhere\n", encoding="utf-8") - assert "elsewhere" in load_project_exclude_dirs(tmp_path) + assert load_run_exclusions(tmp_path).prunes( + str(tmp_path / "elsewhere"), "elsewhere", os.path.realpath(str(tmp_path / "elsewhere")) + ) @pytest.mark.unit @@ -111,13 +149,140 @@ def test_global_config_exclude_dirs_extend_the_resolved_set(tmp_path: Path) -> N def test_a_glob_under_an_excluded_folder_or_above_the_root_matches_nothing( tmp_path: Path, pattern: str, files: list[str] ) -> None: - from reporails_cli.core.discovery.walk import walk_glob_matches - root = tmp_path / "proj" root.mkdir() (tmp_path / "outside.md").write_text("x\n", encoding="utf-8") for name in files: (root / name).parent.mkdir(parents=True, exist_ok=True) (root / name).write_text("x\n", encoding="utf-8") - exclude = frozenset({"skills", "node_modules"}) + exclude = RunExclusions.parse(root, ["skills"], ()) assert list(walk_glob_matches(root, pattern, exclude)) == [] + + +@pytest.fixture +def listed(monkeypatch: pytest.MonkeyPatch) -> list[str]: + """The path of every directory a walk lists.""" + from reporails_cli.core.discovery import walk + + paths: list[str] = [] + real = walk.list_dir + + def counting(path: str) -> object: + paths.append(path) + return real(path) + + monkeypatch.setattr(walk, "list_dir", counting) + return paths + + +def _docs_tree(root: Path) -> None: + for rel in ("pkg/docs/a.md", "docs/b.md", "pkg/keep/c.md"): + (root / rel).parent.mkdir(parents=True, exist_ok=True) + (root / rel).write_text("x\n", encoding="utf-8") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +@pytest.mark.parametrize("value", ["pkg/docs", "./pkg/docs"]) +def test_a_folder_path_is_never_entered(tmp_path: Path, listed: list[str], value: str) -> None: + _docs_tree(tmp_path) + skip = RunExclusions.parse(tmp_path, [value], ()) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == {"docs/b.md", "pkg/keep/c.md"} + assert str(tmp_path / "pkg" / "docs") not in listed + assert str(tmp_path / "docs") in listed + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_folder_name_is_never_entered_at_any_depth(tmp_path: Path, listed: list[str]) -> None: + _docs_tree(tmp_path) + skip = RunExclusions.parse(tmp_path, ["docs"], ()) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == {"pkg/keep/c.md"} + assert not [p for p in listed if Path(p).name == "docs"] + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_symlink_to_an_excluded_folder_path_is_pruned(tmp_path: Path, listed: list[str]) -> None: + _docs_tree(tmp_path) + try: + (tmp_path / "alias").symlink_to(tmp_path / "pkg" / "docs", target_is_directory=True) + except OSError: + pytest.skip("symlinks cannot be created here") + skip = RunExclusions.parse(tmp_path, ["pkg/docs"], ()) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == {"docs/b.md", "pkg/keep/c.md"} + assert str(tmp_path / "alias") not in listed + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_folder_holding_a_named_file_is_entered_but_its_excluded_sibling_is_not(tmp_path: Path) -> None: + for rel in ("pkg/docs/a.md", "pkg/docs/sub/s.md", "pkg/other/o.md", "node_modules/n.md"): + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text("x\n", encoding="utf-8") + skip = RunExclusions.parse(tmp_path, ["pkg/docs", "pkg/other"], ()).with_named([tmp_path / "pkg" / "docs" / "a.md"]) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == {"pkg/docs/a.md"} + by_name = RunExclusions.parse(tmp_path, ["docs", "other"], ()).with_named([tmp_path / "pkg" / "docs" / "a.md"]) + assert _rel(tmp_path, walk_markdown(tmp_path, by_name)) == {"pkg/docs/a.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_default_folder_is_pruned_even_when_it_holds_a_named_file(tmp_path: Path) -> None: + (tmp_path / "node_modules").mkdir() + (tmp_path / "node_modules" / "n.md").write_text("x\n", encoding="utf-8") + skip = RunExclusions.parse(tmp_path, ["other"], ()).with_named([tmp_path / "node_modules" / "n.md"]) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == set() + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_glob_under_an_excluded_folder_path_matches_nothing(tmp_path: Path) -> None: + _docs_tree(tmp_path) + skip = RunExclusions.parse(tmp_path, ["pkg/docs"], ()) + assert list(walk_glob_matches(tmp_path, "pkg/docs/*.md", skip)) == [] + assert _rel(tmp_path, walk_glob_matches(tmp_path, "pkg/keep/*.md", skip)) == {"pkg/keep/c.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_symlink_into_a_folder_left_out_by_name_is_not_entered(tmp_path: Path) -> None: + (tmp_path / "vendor" / "pkg").mkdir(parents=True) + (tmp_path / "vendor" / "pkg" / "x.md").write_text("x\n", encoding="utf-8") + (tmp_path / "keep.md").write_text("x\n", encoding="utf-8") + try: + (tmp_path / "link").symlink_to(tmp_path / "vendor" / "pkg", target_is_directory=True) + except OSError: + pytest.skip("symlinks cannot be created here") + skip = RunExclusions.parse(tmp_path, ["vendor"], ()) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == {"keep.md"} + assert skip.leaves_out(tmp_path / "link" / "x.md") + + +@pytest.mark.unit +@pytest.mark.subsys_lint +def test_a_folder_left_out_by_name_that_holds_a_named_file_is_entered_but_a_bare_subfolder_is_not( + tmp_path: Path, +) -> None: + for rel in ("pkg/docs/a.md", "pkg/docs/sub/s.md", "pkg/docs/hold/h.md", "pkg/keep/k.md"): + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text("x\n", encoding="utf-8") + skip = RunExclusions.parse(tmp_path, ["docs"], ()).with_named( + [tmp_path / "pkg" / "docs" / "a.md", tmp_path / "pkg" / "docs" / "hold" / "h.md"] + ) + assert _rel(tmp_path, walk_markdown(tmp_path, skip)) == {"pkg/docs/a.md", "pkg/docs/hold/h.md", "pkg/keep/k.md"} + + +@pytest.mark.unit +@pytest.mark.subsys_classify +def test_child_real_resolves_a_junction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from reporails_cli.core.discovery.walk import child_real + + target = tmp_path / "target" + target.mkdir() + link = tmp_path / "link" + link.mkdir() + monkeypatch.setattr(os.path, "realpath", lambda p: str(target) if p == str(link) else p) + monkeypatch.setattr(os.path, "isjunction", lambda p: p == str(link)) + assert child_real(str(link), "link", str(tmp_path), False) == str(target) + assert child_real(str(tmp_path / "plain"), "plain", str(tmp_path), False) == str(tmp_path / "plain") diff --git a/tests/unit/test_walk_glob_runs.py b/tests/unit/test_walk_glob_runs.py index 05035cd..967c8ae 100644 --- a/tests/unit/test_walk_glob_runs.py +++ b/tests/unit/test_walk_glob_runs.py @@ -10,6 +10,7 @@ import pytest from reporails_cli.core.discovery.agent_discovery import ci_glob +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.walk import walk_glob_matches, walk_markdown from reporails_cli.core.lint.mechanical import runner from reporails_cli.core.lint.mechanical.checks import _resolve_glob_targets @@ -50,9 +51,9 @@ def test_guard_relative_root_matches_glob(tmp_path: Path, monkeypatch: pytest.Mo (tmp_path / ".claude" / "rules" / "x.md").write_text("x\n", encoding="utf-8") monkeypatch.chdir(tmp_path) expected = Path(".claude/rules/x.md") - assert ci_glob(Path("."), ".claude/rules/*.md") == [expected] + assert ci_glob(Path("."), ".claude/rules/*.md", RunExclusions.none(Path("."))) == [expected] assert _resolve_glob_targets(".claude/rules/*.md", Path(".")) == [expected] - assert list(walk_glob_matches(Path("."), "**/*.md", frozenset())) == [expected] + assert list(walk_glob_matches(Path("."), "**/*.md", RunExclusions.none(Path(".")))) == [expected] def _looping_symlink(root: Path) -> None: @@ -67,10 +68,10 @@ def test_guard_glob_walk_does_not_report_loops_but_markdown_walk_does( ) -> None: _looping_symlink(tmp_path) caplog.set_level(logging.WARNING) - list(walk_glob_matches(tmp_path, "*.md", frozenset())) + list(walk_glob_matches(tmp_path, "*.md", RunExclusions.none(tmp_path))) glob_records = [r for r in caplog.records if "Circular symlink" in r.getMessage()] caplog.clear() - list(walk_markdown(tmp_path, frozenset())) + list(walk_markdown(tmp_path, RunExclusions.none(tmp_path))) md_records = [r for r in caplog.records if "Circular symlink" in r.getMessage()] assert len(glob_records) == 0 assert len(md_records) == 1 diff --git a/tests/unit/test_walk_glob_shared.py b/tests/unit/test_walk_glob_shared.py index 5fea9fe..2f336c6 100644 --- a/tests/unit/test_walk_glob_shared.py +++ b/tests/unit/test_walk_glob_shared.py @@ -8,10 +8,17 @@ import pytest from reporails_cli.core.discovery import walk as ad -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS +from reporails_cli.core.discovery.exclusions import RunExclusions from tests.conftest import skip_if_case_insensitive_fs +class _PruneNothing: + """A pruner that skips no folder.""" + + def prunes(self, path: str, name: str, real: str) -> bool: + return False + + def _tree(root: Path) -> None: for sub in ("a/b/c", "a/d", "e"): (root / sub).mkdir(parents=True) @@ -41,7 +48,10 @@ def test_walks_in_one_pass_read_each_directory_once(tmp_path: Path, scans: list[ skip_if_case_insensitive_fs(tmp_path) _tree(tmp_path) with ad.shared_dir_listings(): - found = [ad.walk_glob(tmp_path, name, DEFAULT_EXCLUDE_DIRS) for name in ("CLAUDE.md", "AGENTS.md", "SKILL.md")] + found = [ + ad.walk_glob(tmp_path, name, RunExclusions.none(tmp_path)) + for name in ("CLAUDE.md", "AGENTS.md", "SKILL.md") + ] assert [len(f) for f in found] == [1, 1, 1] assert len(scans) == len(set(scans)) == 6 @@ -51,7 +61,7 @@ def test_walks_in_one_pass_read_each_directory_once(tmp_path: Path, scans: list[ def test_walks_outside_a_pass_read_the_disk_each_time(tmp_path: Path, scans: list[str]) -> None: _tree(tmp_path) for name in ("CLAUDE.md", "AGENTS.md"): - ad.walk_glob(tmp_path, name, DEFAULT_EXCLUDE_DIRS) + ad.walk_glob(tmp_path, name, RunExclusions.none(tmp_path)) assert len(scans) == 12 @@ -60,10 +70,10 @@ def test_walks_outside_a_pass_read_the_disk_each_time(tmp_path: Path, scans: lis def test_a_new_pass_sees_a_file_added_since_the_last_one(tmp_path: Path) -> None: _tree(tmp_path) with ad.shared_dir_listings(): - before = ad.walk_glob(tmp_path, "AGENTS.md", DEFAULT_EXCLUDE_DIRS) + before = ad.walk_glob(tmp_path, "AGENTS.md", RunExclusions.none(tmp_path)) (tmp_path / "a" / "d" / "AGENTS.md").write_text("x\n", encoding="utf-8") with ad.shared_dir_listings(): - after = ad.walk_glob(tmp_path, "AGENTS.md", DEFAULT_EXCLUDE_DIRS) + after = ad.walk_glob(tmp_path, "AGENTS.md", RunExclusions.none(tmp_path)) assert len(before) == 1 assert sorted(p.relative_to(tmp_path).as_posix() for p in after) == ["a/b/AGENTS.md", "a/d/AGENTS.md"] @@ -74,9 +84,11 @@ def test_shared_and_unshared_walks_find_the_same_files_in_the_same_order(tmp_pat _tree(tmp_path) (tmp_path / "loop").symlink_to(tmp_path, target_is_directory=True) (tmp_path / "e" / "link").symlink_to(tmp_path / "a" / "b", target_is_directory=True) - plain = [ad.walk_glob(tmp_path, n, frozenset({"d"})) for n in ("AGENTS.md", "SKILL.md")] + plain = [ad.walk_glob(tmp_path, n, RunExclusions.parse(tmp_path, ["d"], ())) for n in ("AGENTS.md", "SKILL.md")] with ad.shared_dir_listings(): - shared = [ad.walk_glob(tmp_path, n, frozenset({"d"})) for n in ("AGENTS.md", "SKILL.md")] + shared = [ + ad.walk_glob(tmp_path, n, RunExclusions.parse(tmp_path, ["d"], ())) for n in ("AGENTS.md", "SKILL.md") + ] assert plain == shared assert all(plain) @@ -87,7 +99,7 @@ def test_a_symlink_to_an_ancestor_is_entered_once(tmp_path: Path) -> None: skip_if_case_insensitive_fs(tmp_path) _tree(tmp_path) (tmp_path / "a" / "b" / "up").symlink_to(tmp_path / "a", target_is_directory=True) - found = ad.walk_glob(tmp_path, "SKILL.md", DEFAULT_EXCLUDE_DIRS) + found = ad.walk_glob(tmp_path, "SKILL.md", RunExclusions.none(tmp_path)) assert sorted(p.relative_to(tmp_path).as_posix() for p in found) == ["a/b/c/SKILL.md"] @@ -96,9 +108,9 @@ def test_a_symlink_to_an_ancestor_is_entered_once(tmp_path: Path) -> None: def test_markdown_walk_and_name_walk_share_one_scan_of_each_directory(tmp_path: Path, scans: list[str]) -> None: _tree(tmp_path) with ad.shared_dir_listings(): - by_name = ad.walk_glob(tmp_path, "AGENTS.md", DEFAULT_EXCLUDE_DIRS) - markdown = sorted(p.name for p in ad.walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)) - files = sorted(p.name for p in ad.walk_files(tmp_path, DEFAULT_EXCLUDE_DIRS)) + by_name = ad.walk_glob(tmp_path, "AGENTS.md", RunExclusions.none(tmp_path)) + markdown = sorted(p.name for p in ad.walk_markdown(tmp_path, RunExclusions.none(tmp_path))) + files = sorted(p.name for p in ad.walk_files(tmp_path, RunExclusions.none(tmp_path))) assert [p.name for p in by_name] == ["AGENTS.md"] assert markdown == ["AGENTS.md", "CLAUDE.md", "SKILL.md", "skill.md"] == files assert len(scans) == len(set(scans)) == 6 @@ -108,8 +120,8 @@ def test_markdown_walk_and_name_walk_share_one_scan_of_each_directory(tmp_path: @pytest.mark.subsys_lint def test_markdown_walk_outside_a_pass_reads_the_disk_each_time(tmp_path: Path, scans: list[str]) -> None: _tree(tmp_path) - list(ad.walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)) - list(ad.walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)) + list(ad.walk_markdown(tmp_path, RunExclusions.none(tmp_path))) + list(ad.walk_markdown(tmp_path, RunExclusions.none(tmp_path))) assert len(scans) == 12 @@ -118,10 +130,10 @@ def test_markdown_walk_outside_a_pass_reads_the_disk_each_time(tmp_path: Path, s def test_a_pass_entered_inside_a_pass_keeps_sharing_its_listings(tmp_path: Path, scans: list[str]) -> None: _tree(tmp_path) with ad.shared_dir_listings(): - ad.walk_glob(tmp_path, "AGENTS.md", DEFAULT_EXCLUDE_DIRS) + ad.walk_glob(tmp_path, "AGENTS.md", RunExclusions.none(tmp_path)) with ad.shared_dir_listings(): - ad.walk_glob(tmp_path, "SKILL.md", DEFAULT_EXCLUDE_DIRS) - ad.walk_glob(tmp_path, "CLAUDE.md", DEFAULT_EXCLUDE_DIRS) + ad.walk_glob(tmp_path, "SKILL.md", RunExclusions.none(tmp_path)) + ad.walk_glob(tmp_path, "CLAUDE.md", RunExclusions.none(tmp_path)) assert len(scans) == len(set(scans)) == 6 @@ -139,8 +151,8 @@ def test_markdown_walk_order_and_content_match_os_walk(tmp_path: Path) -> None: seen.update(os.path.realpath(os.path.join(dirpath, d)) for d in dirs) expected.extend(p for p in (Path(dirpath) / f for f in files) if p.suffix == ".md" and p.is_file()) with ad.shared_dir_listings(): - shared = list(ad.walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)) - assert list(ad.walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)) == expected == shared + shared = list(ad.walk_markdown(tmp_path, RunExclusions.none(tmp_path))) + assert list(ad.walk_markdown(tmp_path, RunExclusions.none(tmp_path))) == expected == shared assert expected @@ -154,9 +166,9 @@ def test_plugin_root_and_marker_scans_replay_the_walkers_listings(tmp_path: Path _tree(tmp_path) registry = get_known_agents() with ad.shared_dir_listings(): - ad.walk_glob(tmp_path, "CLAUDE.md", DEFAULT_EXCLUDE_DIRS) + ad.walk_glob(tmp_path, "CLAUDE.md", RunExclusions.none(tmp_path)) assert scan_marker_at(tmp_path, registry["claude"], registry) - assert find_plugin_roots(tmp_path, ".claude-plugin/plugin.json", DEFAULT_EXCLUDE_DIRS) == [] + assert find_plugin_roots(tmp_path, ".claude-plugin/plugin.json", RunExclusions.none(tmp_path)) == [] assert len(scans) == len(set(scans)) @@ -169,5 +181,5 @@ def test_walk_glob_warns_on_circular_symlink_named_like_the_file( (tmp_path / "sub" / "x").symlink_to(tmp_path / "sub" / "CLAUDE.md") (tmp_path / "sub" / "CLAUDE.md").symlink_to(tmp_path / "sub" / "x") with caplog.at_level("WARNING"): - assert ad.walk_glob(tmp_path, "CLAUDE.md", frozenset()) == [] + assert ad.walk_glob(tmp_path, "CLAUDE.md", _PruneNothing()) == [] assert any("Circular symlink detected" in r.getMessage() for r in caplog.records) diff --git a/tests/unit/test_walk_markdown.py b/tests/unit/test_walk_markdown.py index b0afaef..0d5d709 100644 --- a/tests/unit/test_walk_markdown.py +++ b/tests/unit/test_walk_markdown.py @@ -7,7 +7,7 @@ import pytest -from reporails_cli.core.discovery.exclusions import DEFAULT_EXCLUDE_DIRS +from reporails_cli.core.discovery.exclusions import RunExclusions from reporails_cli.core.discovery.walk import walk_files, walk_markdown @@ -28,7 +28,7 @@ def test_walk_markdown_yields_files_inside_symlinked_directory(tmp_path: Path) - skills_dir.mkdir(parents=True) os.symlink(str(canonical), str(skills_dir / "orient")) - found = list(walk_markdown(skills_dir, DEFAULT_EXCLUDE_DIRS)) + found = list(walk_markdown(skills_dir, RunExclusions.none(skills_dir))) rels = {p.relative_to(project).as_posix() for p in found} assert ".claude/skills/orient/SKILL.md" in rels @@ -46,7 +46,7 @@ def test_walk_markdown_breaks_symlink_cycle(tmp_path: Path) -> None: os.symlink(str(b), str(a / "loop")) os.symlink(str(a), str(b / "loop")) - found = list(walk_markdown(root, DEFAULT_EXCLUDE_DIRS)) + found = list(walk_markdown(root, RunExclusions.none(root))) assert len(found) == 1 assert found[0].name == "SKILL.md" @@ -64,7 +64,7 @@ def test_walk_markdown_dedupes_two_symlinks_to_same_target(tmp_path: Path) -> No os.symlink(str(canonical), str(project / "via_a")) os.symlink(str(canonical), str(project / "via_b")) - found = list(walk_markdown(project, DEFAULT_EXCLUDE_DIRS)) + found = list(walk_markdown(project, RunExclusions.none(project))) assert len(found) == 1 @@ -73,7 +73,7 @@ def test_walk_markdown_dedupes_two_symlinks_to_same_target(tmp_path: Path) -> No def test_walk_markdown_ignores_non_markdown(tmp_path: Path) -> None: (tmp_path / "keep.md").write_text("# keep\n", encoding="utf-8") (tmp_path / "skip.txt").write_text("nope\n", encoding="utf-8") - found = {p.name for p in walk_markdown(tmp_path, DEFAULT_EXCLUDE_DIRS)} + found = {p.name for p in walk_markdown(tmp_path, RunExclusions.none(tmp_path))} assert found == {"keep.md"} @@ -82,7 +82,7 @@ def test_walk_markdown_ignores_non_markdown(tmp_path: Path) -> None: def test_walk_files_applies_predicate(tmp_path: Path) -> None: (tmp_path / "match.txt").write_text("yes\n", encoding="utf-8") (tmp_path / "skip.bin").write_bytes(b"\x00\x01\x02") - found = {p.name for p in walk_files(tmp_path, DEFAULT_EXCLUDE_DIRS, lambda p: p.suffix == ".txt")} + found = {p.name for p in walk_files(tmp_path, RunExclusions.none(tmp_path), lambda p: p.suffix == ".txt")} assert found == {"match.txt"} @@ -95,5 +95,5 @@ def test_walk_files_follows_symlinked_subdir(tmp_path: Path) -> None: project = tmp_path / "project" project.mkdir() os.symlink(str(canonical), str(project / "via")) - found = {p.name for p in walk_files(project, DEFAULT_EXCLUDE_DIRS)} + found = {p.name for p in walk_files(project, RunExclusions.none(project))} assert "inner.md" in found diff --git a/tests/unit/test_windows_path_forms.py b/tests/unit/test_windows_path_forms.py index 780d850..7fa89e8 100644 --- a/tests/unit/test_windows_path_forms.py +++ b/tests/unit/test_windows_path_forms.py @@ -6,7 +6,8 @@ import pytest -from reporails_cli.core.platform.utils.utils import config_pattern_matches, glob_matches +from reporails_cli.core.platform.contract.glob_pattern import glob_matches +from reporails_cli.core.platform.utils.utils import config_pattern_matches @pytest.mark.unit From d4aca88290da6e6825164546b5554fe5bfb3fe48 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Wed, 21 Oct 2026 14:52:54 +0200 Subject: [PATCH 25/31] MCP: a new prepare tool maps a project's instruction files without checking them, so the next validate returns sooner --- UNRELEASED.md | 1 + docs/agent-support.md | 2 +- src/reporails_cli/core/pipeline/mapping.py | 40 ++++--- src/reporails_cli/interfaces/mcp/prepare.py | 38 ++++++ .../interfaces/mcp/remedy_brief.py | 2 +- src/reporails_cli/interfaces/mcp/server.py | 82 +++++++------ src/reporails_cli/interfaces/mcp/tools.py | 112 ++++++++++-------- .../interfaces/mcp/validate_targets.py | 10 ++ tests/integration/test_mcp_e2e.py | 10 +- tests/integration/test_mcp_prepare_warm.py | 73 ++++++++++++ tests/unit/test_bio_tagger_mut.py | 2 +- tests/unit/test_mcp_discovery_map_parity.py | 47 +++++++- tests/unit/test_mcp_pipeline_wiring.py | 18 +-- tests/unit/test_mcp_prepare.py | 100 ++++++++++++++++ tests/unit/test_mcp_validate_inputs.py | 4 +- tests/unit/test_pipeline_mapping.py | 67 +++++++++++ tests/unit/test_remedy_brief.py | 6 +- 17 files changed, 490 insertions(+), 124 deletions(-) create mode 100644 src/reporails_cli/interfaces/mcp/prepare.py create mode 100644 tests/integration/test_mcp_prepare_warm.py create mode 100644 tests/unit/test_mcp_prepare.py diff --git a/UNRELEASED.md b/UNRELEASED.md index 53445d8..0851fdb 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,6 +2,7 @@ ### Added +- MCP: a new `prepare` tool maps a project's instruction files without checking them, so the next `validate` returns sooner. - The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` the x-ray leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed diff --git a/docs/agent-support.md b/docs/agent-support.md index d77bab3..0631299 100644 --- a/docs/agent-support.md +++ b/docs/agent-support.md @@ -25,7 +25,7 @@ A skill's supporting markdown files (`reference.md`, `examples/*.md`, anything b ## Plugin support -Recognition (above) is what `ails check` **scans**. The reporails **plugin** is how each agent **runs** the remedy loop: it delivers the `ails` skill and the reporails MCP server as a portable [Agent Plugins](https://agent-plugins.org) package. Agents that consume the portable form need no wrapper; the rest carry a thin native manifest. Installing the plugin registers the MCP server (`validate` / `remedy_brief` / `preflight` / `explain`) and loads the `ails` skill — one skill, maintained once, across all five. +Recognition (above) is what `ails check` **scans**. The reporails **plugin** is how each agent **runs** the remedy loop: it delivers the `ails` skill and the reporails MCP server as a portable [Agent Plugins](https://agent-plugins.org) package. Agents that consume the portable form need no wrapper; the rest carry a thin native manifest. Installing the plugin registers the MCP server (`validate` / `prepare` / `remedy_brief` / `preflight` / `explain`) and loads the `ails` skill — one skill, maintained once, across all five. Install it in your agent: diff --git a/src/reporails_cli/core/pipeline/mapping.py b/src/reporails_cli/core/pipeline/mapping.py index f85e3ee..eedfd45 100644 --- a/src/reporails_cli/core/pipeline/mapping.py +++ b/src/reporails_cli/core/pipeline/mapping.py @@ -12,6 +12,7 @@ import dataclasses import logging +import threading from collections.abc import Callable from pathlib import Path from typing import NamedTuple @@ -370,6 +371,10 @@ def _apply_run_context(ruleset_map: RulesetMap, filtered_agents: list[DetectedAg stamp_file_agents(ruleset_map, filtered_agents, target) +# One map at a time in this process; a caller that waited reads the map the first one cached. +_MAP_LOCK = threading.Lock() + + def map_instruction_files( target: Path, instruction_files: list[Path], @@ -402,26 +407,27 @@ def map_instruction_files( agree on attribution. """ - emit = progress if progress is not None else (lambda _msg: None) + with _MAP_LOCK: + emit = progress if progress is not None else (lambda _msg: None) - full_cache = FullMapCache(get_global_cache_dir()) - identity = compute_identity( - list(instruction_files), root=target, segmentation=get_project_config(target).mapper.segmentation - ) - cached = full_cache.get(identity) - if cached is not None: - _apply_run_context(cached, filtered_agents, target) - return cached + full_cache = FullMapCache(get_global_cache_dir()) + identity = compute_identity( + list(instruction_files), root=target, segmentation=get_project_config(target).mapper.segmentation + ) + cached = full_cache.get(identity) + if cached is not None: + _apply_run_context(cached, filtered_agents, target) + return cached - daemon_status = _resolve_daemon(emit, spawn_daemon=spawn_daemon) + daemon_status = _resolve_daemon(emit, spawn_daemon=spawn_daemon) - ruleset_map = _map_via_daemon_or_process( - daemon_status, list(instruction_files), target, get_project_config(target).mapper, emit - ) - if ruleset_map is not None: - full_cache.put(identity, ruleset_map) # the map as mapped; a later run with other agents decides again - _apply_run_context(ruleset_map, filtered_agents, target) - return ruleset_map + ruleset_map = _map_via_daemon_or_process( + daemon_status, list(instruction_files), target, get_project_config(target).mapper, emit + ) + if ruleset_map is not None: + full_cache.put(identity, ruleset_map) # the map as mapped; a later run with other agents decides again + _apply_run_context(ruleset_map, filtered_agents, target) + return ruleset_map def _resolve_daemon(emit: Callable[[str], None], *, spawn_daemon: bool = True) -> DaemonStatus: diff --git a/src/reporails_cli/interfaces/mcp/prepare.py b/src/reporails_cli/interfaces/mcp/prepare.py new file mode 100644 index 0000000..82e265e --- /dev/null +++ b/src/reporails_cli/interfaces/mcp/prepare.py @@ -0,0 +1,38 @@ +"""MCP `prepare` tool: map the files a `validate` would check, without checking them. + +The map is kept on disk, so the next `validate` with the same `path` and `targets` returns +sooner. Nothing here contacts the server, reads an account or counts as a `validate` call. +""" + +from pathlib import Path + +from reporails_cli.core.platform.contract.json_value import JsonObject +from reporails_cli.interfaces.mcp.tools import Scope, _resolve_scan_target, build_map, scope_for_path +from reporails_cli.interfaces.mcp.validate_targets import named_files, resolve_validate_targets, target_tokens + + +def prepare_for_path(path: str, targets: list[str] | None = None) -> JsonObject: + """Map the files `validate(path, targets)` checks; `{"files": n}` is how many are ready. + + Answers `validate`'s own payload for every condition that stops it (model not ready, + unknown target, missing path, unknown agent), and `mapper_unavailable` when the map + cannot be built. + """ + try: + named: frozenset[Path] = frozenset() + tokens = target_tokens(targets) + target = Path(path).resolve() + if tokens and target.exists(): + resolved = resolve_validate_targets(tokens, _resolve_scan_target(target)[0]) + if isinstance(resolved, dict): + return resolved + named = named_files(resolved) + scope = scope_for_path(path, named) + if not isinstance(scope, Scope): + return {"files": 0} if "files" in scope else scope + ruleset_map, mapper_error = build_map(scope.scan_root, scope.instruction_files, scope.filter_agents) + if ruleset_map is None: + return {"error": "mapper_unavailable", "mapper_error": mapper_error} + return {"files": len(ruleset_map.files)} + except (FileNotFoundError, ValueError, RuntimeError) as e: + return {"error": str(e)} diff --git a/src/reporails_cli/interfaces/mcp/remedy_brief.py b/src/reporails_cli/interfaces/mcp/remedy_brief.py index c4d9989..1652fe2 100644 --- a/src/reporails_cli/interfaces/mcp/remedy_brief.py +++ b/src/reporails_cli/interfaces/mcp/remedy_brief.py @@ -62,7 +62,7 @@ def _sorted_findings(findings: list[JsonObject]) -> list[JsonObject]: def _pipeline_failure_reason(payload: JsonObject) -> str: """The best available cause string from a pipeline `payload` that yielded no map: the mapper's own swallowed exception text (`mapper_error` — see - `interfaces/mcp/tools.py::_build_map`) when there is one, else the payload's own + `interfaces/mcp/tools.py::build_map`) when there is one, else the payload's own `message` / `error` field (discovery's `needs_install` / unknown-agent / path-not-found payloads), else `_NO_MAP_FALLBACK` naming nothing more specific.""" reason = payload.get("mapper_error") or payload.get("message") or payload.get("error") diff --git a/src/reporails_cli/interfaces/mcp/server.py b/src/reporails_cli/interfaces/mcp/server.py index 7dfec83..3fe0fe8 100644 --- a/src/reporails_cli/interfaces/mcp/server.py +++ b/src/reporails_cli/interfaces/mcp/server.py @@ -1,12 +1,8 @@ """MCP server for reporails — exposes validation tools to MCP clients. -Registers `validate` / `preflight` / `explain` on the mcp 2.x high-level -`MCPServer`. `validate` and `preflight` return a pre-built `CallToolResult` -(see `_result`) carrying the wire payload as `structured_content` plus a compact -text mirror; `explain` returns text with `structured_output=False`. Returning a -pre-built result rather than a typed return leaves `output_schema` unset, so the -SDK derives no output schema from an internal model — only the explicit wire -payload is exposed and no non-wire field can reach the schema. +Registers `validate` / `prepare` / `remedy_brief` / `heal_apply` / `preflight` / `explain` on the +mcp 2.x high-level `MCPServer`. Tools return a pre-built `CallToolResult` (see `_result`): the wire +payload as `structured_content` plus a compact text mirror, with no derived output schema. """ import asyncio @@ -31,6 +27,7 @@ from reporails_cli.interfaces.mcp.feedback import file_feedback from reporails_cli.interfaces.mcp.heal_apply import heal_apply_message, heal_apply_write, release_written from reporails_cli.interfaces.mcp.idle_release import lifespan, touch_activity +from reporails_cli.interfaces.mcp.prepare import prepare_for_path from reporails_cli.interfaces.mcp.remedy_brief import build_remedy_brief from reporails_cli.interfaces.mcp.rule_tools import explain_tool, preflight_tool from reporails_cli.interfaces.mcp.scan_fingerprint import ( @@ -46,7 +43,12 @@ run_pipeline_for_path, unpaid_signed_in_reply, ) -from reporails_cli.interfaces.mcp.validate_targets import keep_target_locations, resolve_validate_targets +from reporails_cli.interfaces.mcp.validate_targets import ( + keep_target_locations, + named_files, + resolve_validate_targets, + target_tokens, +) # The long-lived MCP server never needs a heavy ML backend: block it before any tool runs, so a # transitive import cannot pull one in and slow the first validate call. @@ -85,11 +87,6 @@ class _CircuitState: _validate_states: dict[str, _CircuitState] = {} -def _target_tokens(targets: list[str] | None) -> tuple[str, ...]: - """The distinct non-empty target tokens, sorted — one spelling per set of targets.""" - return tuple(sorted({t.strip() for t in targets or () if t and t.strip()})) - - def _state_key(path: str, tokens: tuple[str, ...]) -> str: """The circuit-breaker / workflow key: the resolved path, plus the targets when any — a targeted `validate` and a whole-project one keep separate counters and workflows.""" @@ -113,14 +110,11 @@ def _serve_remedy_brief( def _remedy_brief_reply(path: str, location: int, targets: list[str] | None, has_guide: bool) -> JsonObject: - """`remedy_brief(path, location, targets, has_guide)`'s body: a caller that does not - carry the rewrite guide (`has_guide` false) is refused with `plugin_update_required` before - anything else — no state read, no build, no snapshot, no breaker counter touched. Reads the last `validate(path, - targets)`'s stored workflow and scan root, and builds the location's rewrite brief. - Never runs `validate`'s own pipeline for `path`. A - served brief (no error) resets the path's `call_count` — the agent is still working this - rewrite loop, not re-validating without progress — so a multi-round pass survives - past `_MAX_CALLS`; `consecutive_unchanged` (the actual no-progress guard) is untouched. + """`remedy_brief(path, location, targets, has_guide)`'s body: a caller without the rewrite + guide (`has_guide` false) is refused with `plugin_update_required` before anything else. Reads + the last `validate(path, targets)`'s stored workflow and scan root and builds the location's + rewrite brief, never running `validate`'s pipeline. A served brief resets the path's + `call_count` so a multi-round pass survives past `_MAX_CALLS`; `consecutive_unchanged` is untouched. An unpaid or offline caller's `validate` never carries a `workflow` key at all (a DIFFERENT cause from never having called `validate`): `workflow_requires_pro` names that @@ -135,7 +129,7 @@ def _remedy_brief_reply(path: str, location: int, targets: list[str] | None, has "Update the plugin, then run heal again." ), } - state = _validate_states.get(_state_key(path, _target_tokens(targets))) + state = _validate_states.get(_state_key(path, target_tokens(targets))) if state is None or not state.full_payload: return { "error": "no_workflow", @@ -220,21 +214,17 @@ def _with_preservation( async def _fresh_validate_payload( path: str, tokens: tuple[str, ...], scan_root: Path, state: _CircuitState ) -> JsonObject | tuple[JsonObject, RulesetMap | None, float | None]: - """Run the pipeline fresh (never served from `state.full_payload`) — resolve `tokens` to a - location selection first when targeted, narrow the payload's `workflow.locations` to it - (`keep_target_locations`), and memoize the map/score on `state` for the next same-content - `validate` / preservation pass. Returns `(payload, ruleset_map, score)`; a target-resolution - failure returns its own error payload instead (the caller returns it unchanged).""" + """Run the pipeline fresh (never served from `state.full_payload`): resolve `tokens` first, + narrow `workflow.locations` to them (`keep_target_locations`) and memoize the map/score on + `state`. Returns `(payload, ruleset_map, score)`, or the target-resolution error payload.""" selection: tuple[set[Path], set[Path]] | None = None if tokens: resolved = await asyncio.to_thread(resolve_validate_targets, tokens, scan_root) if isinstance(resolved, dict): return resolved selection = resolved - # Blocking pipeline work (mapping, m-probes, server lint) runs off the event loop so a - # slow map/lint never blocks other concurrent tool calls. - named = (frozenset(selection[0]),) if selection is not None and selection[0] else () - payload, ruleset_map, score = await asyncio.to_thread(run_pipeline_for_path, path, True, *named) + # Blocking pipeline work runs off the event loop. + payload, ruleset_map, score = await asyncio.to_thread(run_pipeline_for_path, path, True, named_files(selection)) if selection is not None and "files" in payload: payload = keep_target_locations(payload, *selection, scan_root, tokens) if "files" in payload: @@ -303,9 +293,8 @@ async def _validate_reply(path: str, full: bool, targets: list[str] | None = Non The circuit-breaker state read-modify-write runs inline on the event loop (no `await` between the `_validate_states` read and its write), so concurrent same-path calls serialize; - only the blocking pipeline (`run_pipeline_for_path`) is offloaded to a worker thread. The - pipeline always runs `full=True` internally and the bounded view is derived here via - `bound_validate_payload`, so a cached full payload serves either shape. Returns a + only the blocking pipeline is offloaded to a worker thread. The pipeline always runs + `full=True` and the bounded view is derived via `bound_validate_payload`. Returns a structured dict for every expected condition (needs_install / path_not_found / circuit_breaker); an unexpected exception propagates for the SDK to surface. """ @@ -322,7 +311,7 @@ async def _validate_reply(path: str, full: bool, targets: list[str] | None = Non # keep a same-content repeat validate from ever hitting the cached reply). scan_root = _resolve_scan_target(target)[0] - tokens = _target_tokens(targets) + tokens = target_tokens(targets) state, unchanged, base_hash = await _begin_validate_call(path, tokens, full, target, scan_root) if state.call_count > _MAX_CALLS or state.consecutive_unchanged >= _MAX_UNCHANGED: @@ -387,7 +376,7 @@ async def _heal_apply_reply(path: str, targets: list[str] | None = None) -> str: if model_error is not None or not target.exists(): return heal_apply_message(model_error or {"error": f"Path not found: {target}"}) scan_root = _resolve_scan_target(target)[0] - result = await _fresh_validate_payload(path, _target_tokens(targets), scan_root, _CircuitState()) + result = await _fresh_validate_payload(path, target_tokens(targets), scan_root, _CircuitState()) if isinstance(result, dict): return heal_apply_message(result) payload, ruleset_map, _score = result @@ -476,10 +465,29 @@ async def validate(path: str = ".", full: bool = False, targets: list[str] | Non payload = await _run_validate(path, bool(full), targets) if not has_text_view(payload, full=bool(full)): return _result(payload) - state = _validate_states.setdefault(_state_key(path, _target_tokens(targets)), _CircuitState()) + state = _validate_states.setdefault(_state_key(path, target_tokens(targets)), _CircuitState()) return _text_result(render_text_view(unseen_notices(payload, state.shown_notices))) +@server.tool( + name="prepare", + description=( + "Map the instruction files that `validate(path, targets)` checks, without checking them, so the next" + " `validate` with the same `path` and `targets` returns sooner. Makes no server call and returns no" + " findings: `files` is how many files are ready. The reporails plugin calls it in the background as" + " files load or change." + ), + annotations=_READ_ONLY, +) +async def prepare(path: str = ".", targets: list[str] | None = None) -> CallToolResult: + """Map the files `validate` would check; see the tool description.""" + touch_activity() + try: + return _result(await asyncio.to_thread(prepare_for_path, path, targets)) + except ConfigUnreadableError as exc: + return _result(_config_unreadable(exc)) + + @server.tool( name="remedy_brief", description=( diff --git a/src/reporails_cli/interfaces/mcp/tools.py b/src/reporails_cli/interfaces/mcp/tools.py index 32deee1..84a5f4d 100644 --- a/src/reporails_cli/interfaces/mcp/tools.py +++ b/src/reporails_cli/interfaces/mcp/tools.py @@ -65,6 +65,18 @@ class LeftOut(NamedTuple): NOTHING_LEFT_OUT = LeftOut() +class Scope(NamedTuple): + """Which files a `validate` at one path checks, and how they are read.""" + + scan_root: Path + single_file: Path | None + filter_agents: list[DetectedAgent] + effective_agent: str + instruction_files: list[Path] + file_type_by_path: dict[str, str] + left_out: LeftOut + + def _unknown_agent_error(agent: str) -> JsonObject: """The structured MCP equivalent of the CLI's `_validate_agent` exit. @@ -130,7 +142,7 @@ def _discover_files( return found.filtered, found.effective_agent, instruction_files, file_type_by_path, found.excluded, found.exclusions -def _build_map( +def build_map( target: Path, instruction_files: list[Path], filter_agents: list[DetectedAgent] | None = None ) -> tuple[RulesetMap | None, str | None]: """Build the ruleset map via the shared warm path, so `validate` reuses a warm model @@ -231,39 +243,6 @@ def _display_score(result: CombinedResult, target: Path | None = None, scan_root return None -def _run_pipeline( - target: Path, full: bool = False, named: frozenset[Path] = frozenset() -) -> tuple[JsonObject, RulesetMap | None, float | None]: - """Run the full check pipeline; returns `(payload, ruleset_map, file_score)`. - - A file `target` narrows discovery to its project root and the validated set - to that single file, mirroring the CLI single-path mode. Unless `full` is set the - payload is bounded to a top-N-per-file envelope so a large repo stays under the - harness per-tool-result token cap. `ruleset_map` is `None` on a structured - error/needs-install payload (the caller checks `payload` for that). `file_score` is - the single file's own display score (`None` for a directory target or an unscored - file) — the same value `remedy_brief` reports for that file. - """ - scan_root, single_file = _resolve_scan_target(target) - discovery = _discover_files(scan_root, single_file=single_file, named=named) - if isinstance(discovery, dict): - return discovery, None, None - model_error = model_not_ready_error() - if model_error is not None: - return model_error, None, None - filter_agents, effective_agent, instruction_files, file_type_by_path, excluded, exclusions = discovery - return _lint_discovered( - scan_root, - effective_agent, - instruction_files, - filter_agents, - file_type_by_path=file_type_by_path, - full=full, - single_file=single_file, - left_out=LeftOut(excluded, exclusions), - ) - - def _lint_discovered( scan_root: Path, effective_agent: str, @@ -294,7 +273,7 @@ def _lint_discovered( top-N-per-file envelope (aggregate stats + surface_health stay whole). """ - ruleset_map, mapper_error = _build_map(scan_root, instruction_files, filter_agents) + ruleset_map, mapper_error = build_map(scan_root, instruction_files, filter_agents) result, funnel_error = _check_and_assemble( scan_root, instruction_files, @@ -539,24 +518,63 @@ def _rules_missing_payload() -> JsonObject: } +def scope_for_path(path: str, named: frozenset[Path] = frozenset()) -> Scope | JsonObject: + """The files a `validate` at `path` checks, or the reply to give when there is nothing to check. + + A directory `path` roots at itself and a file `path` at its project, with that one file + checked. For a directory `path`, `named` holds files the caller named, which are checked + whatever is excluded; for a file `path` it is ignored. The reply is a structured payload + when the rules are missing, the path does not exist, the agent is unknown, the project has + no instruction files (the empty-project envelope) or the model set is not ready. + """ + if not is_initialized(): + return _rules_missing_payload() + target = Path(path).resolve() + if not target.exists(): + return {"error": f"Path not found: {target}"} + scan_root, single_file = _resolve_scan_target(target) + discovery = _discover_files(scan_root, single_file=single_file, named=named) + if isinstance(discovery, dict): + return discovery + model_error = model_not_ready_error() + if model_error is not None: + return model_error + filter_agents, effective_agent, instruction_files, file_type_by_path, excluded, exclusions = discovery + return Scope( + scan_root, + single_file, + filter_agents, + effective_agent, + instruction_files, + file_type_by_path, + LeftOut(excluded, exclusions), + ) + + def run_pipeline_for_path( path: str, full: bool = False, named: frozenset[Path] = frozenset() ) -> tuple[JsonObject, RulesetMap | None, float | None]: """Run the pipeline for `path` (directory or file); returns `(payload, ruleset_map, file_score)`. `ruleset_map` and `file_score` are `None` on a structured error/needs-install - payload — the caller checks `"error"` / `"needs_install"` in the payload. For a directory - `path`, `named` holds files the caller named, which are checked whatever is excluded; the - Excluded record still lists every pattern the user set. For a file `path` only that file is - named and `named` is ignored. + payload — the caller checks `"error"` / `"needs_install"` in the payload. A file `path` + narrows the validated set to that single file; unless `full` is set the payload is bounded + to a top-N-per-file envelope so a large repo stays under the harness per-tool-result token + cap, and `file_score` is that single file's own display score (`None` for a directory or an + unscored file). `named` is read as in `scope_for_path`. """ - if not is_initialized(): - return _rules_missing_payload(), None, None - target = Path(path).resolve() - if not target.exists(): - return {"error": f"Path not found: {target}"}, None, None - # When `path` is an existing file, the pipeline narrows discovery to the - # file's project root and the validated set to that single file. try: - return _run_pipeline(target, full=full, named=named) + scope = scope_for_path(path, named) + if isinstance(scope, dict): + return scope, None, None + return _lint_discovered( + scope.scan_root, + scope.effective_agent, + scope.instruction_files, + scope.filter_agents, + file_type_by_path=scope.file_type_by_path, + full=full, + single_file=scope.single_file, + left_out=scope.left_out, + ) except (FileNotFoundError, ValueError, RuntimeError) as e: return {"error": str(e)}, None, None diff --git a/src/reporails_cli/interfaces/mcp/validate_targets.py b/src/reporails_cli/interfaces/mcp/validate_targets.py index e352863..4b5b960 100644 --- a/src/reporails_cli/interfaces/mcp/validate_targets.py +++ b/src/reporails_cli/interfaces/mcp/validate_targets.py @@ -21,6 +21,16 @@ from reporails_cli.interfaces.mcp.remedy_brief import resolve_from_root +def target_tokens(targets: list[str] | None) -> tuple[str, ...]: + """The distinct non-empty target tokens, sorted — one spelling per set of targets.""" + return tuple(sorted({t.strip() for t in targets or () if t and t.strip()})) + + +def named_files(selection: tuple[set[Path], set[Path]] | None) -> frozenset[Path]: + """The files a resolved target selection names directly (none for no selection).""" + return frozenset(selection[0]) if selection is not None else frozenset() + + def _resolve_one_target( token: str, token_agent: str, scan_root: Path, effective_agent: str, exclusions: RunExclusions | None ) -> tuple[set[Path], set[Path]] | JsonObject: diff --git a/tests/integration/test_mcp_e2e.py b/tests/integration/test_mcp_e2e.py index be5e9f4..35752f9 100644 --- a/tests/integration/test_mcp_e2e.py +++ b/tests/integration/test_mcp_e2e.py @@ -127,7 +127,7 @@ def test_all_tools_are_read_only(self) -> None: from reporails_cli.interfaces.mcp.server import server as mcp_server tools = {t.name: t for t in _run_async(mcp_server.list_tools())} - for name in ("validate", "remedy_brief", "preflight", "explain"): + for name in ("validate", "prepare", "remedy_brief", "preflight", "explain"): assert tools[name].annotations is not None assert tools[name].annotations.read_only_hint is True @@ -142,12 +142,12 @@ class TestListTools: @pytest.mark.subsys_cli_ux @pytest.mark.subsys_api def test_all_tools_present(self) -> None: - """list_tools should return the current surface: validate, remedy_brief, heal_apply, preflight, explain.""" + """list_tools returns the current surface: validate, prepare, remedy_brief, heal_apply, preflight, explain.""" from reporails_cli.interfaces.mcp.server import server as mcp_server tools = _run_async(mcp_server.list_tools()) names = {t.name for t in tools} - assert names == {"validate", "remedy_brief", "heal_apply", "preflight", "explain"} + assert names == {"validate", "prepare", "remedy_brief", "heal_apply", "preflight", "explain"} @pytest.mark.e2e @pytest.mark.subsys_cli_ux @@ -294,11 +294,11 @@ def test_missing_rules_folder_names_what_is_missing_and_what_to_do(self) -> None @pytest.mark.subsys_cli_ux @pytest.mark.subsys_api def test_runtime_error_returns_error_json(self, level2_project: Path) -> None: - """RuntimeError from _run_pipeline must return JSON error, not crash.""" + """RuntimeError from scope_for_path must return JSON error, not crash.""" with ( patch("reporails_cli.interfaces.mcp.tools.is_initialized", return_value=True), patch( - "reporails_cli.interfaces.mcp.tools._run_pipeline", + "reporails_cli.interfaces.mcp.tools.scope_for_path", side_effect=RuntimeError("Unsupported operating system"), ), ): diff --git a/tests/integration/test_mcp_prepare_warm.py b/tests/integration/test_mcp_prepare_warm.py new file mode 100644 index 0000000..e415504 --- /dev/null +++ b/tests/integration/test_mcp_prepare_warm.py @@ -0,0 +1,73 @@ +"""`prepare` then `validate`: the reply is the one a cold `validate` gives, and no map is rebuilt.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from reporails_cli.core.pipeline import mapping +from reporails_cli.interfaces.mcp import tools +from reporails_cli.interfaces.mcp.prepare import prepare_for_path + +_has_onnx_model = ( + Path(__file__).resolve().parents[2] + / "src" + / "reporails_cli" + / "bundled" + / "models" + / "minilm-l6-v2" + / "onnx" + / "model.onnx" +).exists() +requires_model = pytest.mark.skipif(not _has_onnx_model, reason="Bundled ONNX model not available") + + +def _project(tmp_path: Path) -> Path: + project = tmp_path / "proj" + (project / ".claude" / "rules").mkdir(parents=True) + (project / "CLAUDE.md").write_text("# Project\n\nAlways run the tests before you commit.\n", encoding="utf-8") + (project / ".claude" / "rules" / "style.md").write_text( + "# Style\n\nNever leave commented-out code in a change.\n", encoding="utf-8" + ) + return project + + +def _use_cache(monkeypatch: pytest.MonkeyPatch, cache: Path) -> None: + monkeypatch.setattr("reporails_cli.core.pipeline.mapping.get_global_cache_dir", lambda: cache) + monkeypatch.setattr( + "reporails_cli.core.pipeline.mapping._resolve_daemon", + lambda *_a, **_k: mapping.DaemonStatus.UNAVAILABLE, + ) + + +@requires_model +@pytest.mark.integration +@pytest.mark.subsys_server +def test_validate_after_prepare_replies_as_a_cold_validate_and_does_not_map_again( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("AILS_SERVER_URL", "http://127.0.0.1:9") + monkeypatch.setattr( + "reporails_cli.core.platform.config.config.get_global_config_path", lambda: tmp_path / "no-global.yml" + ) + project = _project(tmp_path) + + _use_cache(monkeypatch, tmp_path / "cold-cache") + cold = json.dumps(tools.run_pipeline_for_path(str(project), full=True)[0], sort_keys=True) + + _use_cache(monkeypatch, tmp_path / "warm-cache") + assert prepare_for_path(str(project))["files"] == 2 + real_map = mapping._map_via_daemon_or_process + maps: list[int] = [] + + def counting(*args: object, **kwargs: object): + maps.append(1) + return real_map(*args, **kwargs) + + monkeypatch.setattr("reporails_cli.core.pipeline.mapping._map_via_daemon_or_process", counting) + warm = json.dumps(tools.run_pipeline_for_path(str(project), full=True)[0], sort_keys=True) + + assert maps == [] + assert warm == cold diff --git a/tests/unit/test_bio_tagger_mut.py b/tests/unit/test_bio_tagger_mut.py index a59fa46..1fd83d2 100644 --- a/tests/unit/test_bio_tagger_mut.py +++ b/tests/unit/test_bio_tagger_mut.py @@ -475,7 +475,7 @@ def test_concurrent_cold_graph_hash_does_not_raise(tmp_path: Path, monkeypatch: mutation landing while another thread's `json.dumps` walks the same dict raises `RuntimeError: dictionary changed size during iteration`; `core/pipeline/mapping.py` lets that propagate out of `compute_identity` uncaught, and `interfaces/mcp/tools.py - ::_build_map` swallows it into a bare `logger.warning` + `None` — surfaced to the + ::build_map` swallows it into a bare `logger.warning` + `None` — surfaced to the MCP client only as `brief_unavailable`'s generic "produced no map". `json.dumps` is monkeypatched to walk `_sidecar_data`'s items with a small sleep diff --git a/tests/unit/test_mcp_discovery_map_parity.py b/tests/unit/test_mcp_discovery_map_parity.py index c293f67..3ef27ac 100644 --- a/tests/unit/test_mcp_discovery_map_parity.py +++ b/tests/unit/test_mcp_discovery_map_parity.py @@ -1,6 +1,6 @@ """Red-first tests for the MCP discovery/map head sharing the CLI pipeline. -Mapper invocation: the MCP `_build_map` must route through the shared +Mapper invocation: the MCP `build_map` must route through the shared `map_instruction_files` warm path (daemon + whole-map cache), not a cold in-process `map_ruleset`. Agent filter: MCP `_discover_files` must discover against the `resolve_agent_filters` `filtered` list — the resolved, exclude-filtered agent set the CLI @@ -22,7 +22,7 @@ @pytest.mark.unit @pytest.mark.subsys_map def test_build_map_routes_through_shared_warm_path(monkeypatch): - """Surface 2: `_build_map` calls the shared `map_instruction_files` builder.""" + """Surface 2: `build_map` calls the shared `map_instruction_files` builder.""" seen = {} def fake_map(target, files, **_kw): @@ -32,7 +32,7 @@ def fake_map(target, files, **_kw): monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.map_instruction_files", fake_map) - ruleset_map, mapper_error = tools._build_map(Path("/proj"), [Path("/proj/CLAUDE.md")]) + ruleset_map, mapper_error = tools.build_map(Path("/proj"), [Path("/proj/CLAUDE.md")]) assert ruleset_map == "SENTINEL_MAP", "MCP must map via the shared warm path, not a cold map_ruleset" assert mapper_error is None @@ -43,7 +43,7 @@ def fake_map(target, files, **_kw): @pytest.mark.unit @pytest.mark.subsys_map def test_build_map_keeps_the_swallowed_exception_text_for_its_caller(monkeypatch): - """REGRESSION: `_build_map` still logs a swallowed `RuntimeError`/`ImportError` (unchanged), + """REGRESSION: `build_map` still logs a swallowed `RuntimeError`/`ImportError` (unchanged), but must also hand its text back to the caller instead of only a bare `None` — the live concurrency defect surfaced to an MCP client as a generic "produced no map" because the real cause (a mapper exception) reached nowhere past `logger.warning`.""" @@ -53,7 +53,7 @@ def raising_map(*_a, **_kw): monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.map_instruction_files", raising_map) - ruleset_map, mapper_error = tools._build_map(Path("/proj"), [Path("/proj/CLAUDE.md")]) + ruleset_map, mapper_error = tools.build_map(Path("/proj"), [Path("/proj/CLAUDE.md")]) assert ruleset_map is None assert mapper_error == "RuntimeError: dictionary changed size during iteration" @@ -90,3 +90,40 @@ def fake_get_all(target, agents=None): assert captured["agents"] == sentinel_filtered, "discovery must key off the filtered agent set" assert filter_agents == sentinel_filtered assert agent == "claude" + + +@pytest.mark.unit +@pytest.mark.subsys_map +def test_prepare_and_validate_map_the_same_files_for_a_named_left_out_file(monkeypatch, tmp_path): + """A file named in `targets` that the config leaves out is mapped by `prepare` exactly as by `validate`.""" + import asyncio + + from reporails_cli.core.discovery.agents import clear_agent_cache + from reporails_cli.core.platform.runtime.merger import CombinedResult + from reporails_cli.interfaces.mcp import server + from reporails_cli.interfaces.mcp.prepare import prepare_for_path + + project = tmp_path / "proj" + (project / "packages" / "x").mkdir(parents=True) + (project / ".ails").mkdir() + (project / ".ails" / "config.yml").write_text("exclude_dirs: [packages]\n", encoding="utf-8") + (project / "CLAUDE.md").write_text("# Root\n\nAlways run the tests.\n", encoding="utf-8") + (project / "packages" / "x" / "CLAUDE.md").write_text("# X\n\nAlways run the tests.\n", encoding="utf-8") + clear_agent_cache() + seen: list[tuple[Path, list[Path]]] = [] + + def capture(target, files, **_kw): + seen.append((target, sorted(files))) + + monkeypatch.setattr(tools, "is_initialized", lambda: True) + monkeypatch.setattr(tools, "model_not_ready_error", lambda: None) + monkeypatch.setattr(tools, "map_instruction_files", capture) + monkeypatch.setattr(tools, "_check_and_assemble", lambda *_a, **_k: (CombinedResult(), None)) + tokens = ("packages/x/CLAUDE.md",) + + asyncio.run(server._fresh_validate_payload(str(project), tokens, project, server._CircuitState())) + prepare_for_path(str(project), list(tokens)) + + assert len(seen) == 2 + assert seen[0] == seen[1] + assert {p.resolve() for p in seen[1][1]} >= {(project / "packages" / "x" / "CLAUDE.md").resolve()} diff --git a/tests/unit/test_mcp_pipeline_wiring.py b/tests/unit/test_mcp_pipeline_wiring.py index 9e81a55..b588290 100644 --- a/tests/unit/test_mcp_pipeline_wiring.py +++ b/tests/unit/test_mcp_pipeline_wiring.py @@ -42,8 +42,8 @@ def test_run_local_checks_threads_scoped_into_m_probes(monkeypatch, scoped): @pytest.mark.unit @pytest.mark.subsys_server -def test_run_pipeline_scopes_a_single_file_target(monkeypatch, tmp_path): - """`_run_pipeline` on a file target must run the mechanical probes `scoped=True`, +def test_run_pipeline_for_path_scopes_a_single_file_target(monkeypatch, tmp_path): + """`run_pipeline_for_path` on a file target must run the mechanical probes `scoped=True`, while a directory target (below) runs them unscoped.""" captured = {} single_file = tmp_path / "CLAUDE.md" @@ -53,7 +53,9 @@ def test_run_pipeline_scopes_a_single_file_target(monkeypatch, tmp_path): "reporails_cli.interfaces.mcp.tools._discover_files", lambda _target, single_file=None, named=frozenset(): ([], "claude", [single_file], {}, ((), ()), None), ) - monkeypatch.setattr("reporails_cli.interfaces.mcp.tools._build_map", lambda *_a, **_k: (None, None)) + monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.is_initialized", lambda: True) + monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.model_not_ready_error", lambda: None) + monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.build_map", lambda *_a, **_k: (None, None)) monkeypatch.setattr( "reporails_cli.core.lint.rule_runner.run_m_probes", lambda *_a, **kw: captured.update(kw) or [], @@ -64,14 +66,14 @@ def test_run_pipeline_scopes_a_single_file_target(monkeypatch, tmp_path): ) monkeypatch.setattr("reporails_cli.formatters.json.format_combined_result", lambda *_a, **_k: {"files": {}}) - tools._run_pipeline(single_file, full=True) + tools.run_pipeline_for_path(str(single_file), full=True) assert captured.get("scoped") is True @pytest.mark.unit @pytest.mark.subsys_server -def test_run_pipeline_does_not_scope_a_directory_target(monkeypatch, tmp_path): +def test_run_pipeline_for_path_does_not_scope_a_directory_target(monkeypatch, tmp_path): captured = {} claude_md = tmp_path / "CLAUDE.md" claude_md.write_text("# Claude\n", encoding="utf-8") @@ -80,7 +82,9 @@ def test_run_pipeline_does_not_scope_a_directory_target(monkeypatch, tmp_path): "reporails_cli.interfaces.mcp.tools._discover_files", lambda _target, single_file=None, named=frozenset(): ([], "claude", [claude_md], {}, ((), ()), None), ) - monkeypatch.setattr("reporails_cli.interfaces.mcp.tools._build_map", lambda *_a, **_k: (None, None)) + monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.is_initialized", lambda: True) + monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.model_not_ready_error", lambda: None) + monkeypatch.setattr("reporails_cli.interfaces.mcp.tools.build_map", lambda *_a, **_k: (None, None)) monkeypatch.setattr( "reporails_cli.core.lint.rule_runner.run_m_probes", lambda *_a, **kw: captured.update(kw) or [], @@ -91,7 +95,7 @@ def test_run_pipeline_does_not_scope_a_directory_target(monkeypatch, tmp_path): ) monkeypatch.setattr("reporails_cli.formatters.json.format_combined_result", lambda *_a, **_k: {"files": {}}) - tools._run_pipeline(tmp_path, full=True) + tools.run_pipeline_for_path(str(tmp_path), full=True) assert captured.get("scoped") is False diff --git a/tests/unit/test_mcp_prepare.py b/tests/unit/test_mcp_prepare.py new file mode 100644 index 0000000..eaa3dfa --- /dev/null +++ b/tests/unit/test_mcp_prepare.py @@ -0,0 +1,100 @@ +"""The MCP `prepare` tool: it maps what `validate` would check and does nothing else.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest + +from reporails_cli.core.discovery.agents import clear_agent_cache +from reporails_cli.core.platform.dto.ruleset import RulesetMap +from reporails_cli.interfaces.mcp import server, tools +from reporails_cli.interfaces.mcp.prepare import prepare_for_path + +pytestmark = [pytest.mark.unit, pytest.mark.subsys_server] + + +def _map(n_files: int = 0) -> RulesetMap: + return RulesetMap.model_construct( + schema_version="1", embedding_model="m", generated_at="t", files=tuple(range(n_files)), atoms=() + ) + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + root = tmp_path / "proj" + root.mkdir() + (root / "CLAUDE.md").write_text("# Project\n\nAlways run the tests.\n", encoding="utf-8") + clear_agent_cache() + monkeypatch.setattr(tools, "is_initialized", lambda: True) + monkeypatch.setattr(tools, "model_not_ready_error", lambda: None) + return root + + +def test_prepare_answers_the_number_of_mapped_files(project, monkeypatch): + monkeypatch.setattr(tools, "map_instruction_files", lambda *_a, **_k: _map(3)) + assert prepare_for_path(str(project)) == {"files": 3} + + +def test_prepare_builds_no_api_client_and_leaves_the_validate_state_alone(project, monkeypatch): + def refuse(*_a, **_k): + raise AssertionError("prepare must not contact the server") + + monkeypatch.setattr(tools, "AilsClient", refuse) + monkeypatch.setattr(tools, "has_api_key", refuse) + monkeypatch.setattr(tools, "map_instruction_files", lambda *_a, **_k: _map(1)) + server._validate_states.clear() + + result = asyncio.run(server.prepare(str(project))) + + assert json.loads(result.content[0].text) == {"files": 1} + assert result.structured_content == {"files": 1} + assert server._validate_states == {} + + +def test_prepare_passes_model_downloading_through_without_mapping(project, monkeypatch): + reply = {"error": "model_downloading", "message": "downloading"} + monkeypatch.setattr(tools, "model_not_ready_error", lambda: reply) + + def refuse(*_a, **_k): + raise AssertionError("no map while the model downloads") + + monkeypatch.setattr(tools, "map_instruction_files", refuse) + assert prepare_for_path(str(project)) == reply + + +def test_prepare_answers_target_not_found_as_validate_does(project, monkeypatch): + monkeypatch.setattr(tools, "map_instruction_files", lambda *_a, **_k: _map(1)) + reply = prepare_for_path(str(project), ["nope/missing.md"]) + assert reply["error"] == "target_not_found" + + +def test_prepare_answers_config_unreadable_as_validate_does(project): + (project / ".ails").mkdir() + (project / ".ails" / "config.yml").write_text("disabled_rules: [CORE:E:0003\n", encoding="utf-8") + reply = json.loads(asyncio.run(server.prepare(str(project))).content[0].text) + assert reply["error"] == "config_unreadable" + assert str(project / ".ails" / "config.yml") in reply["message"] + + +def test_prepare_on_a_project_with_nothing_to_map_answers_zero(tmp_path, monkeypatch): + empty = tmp_path / "empty" + empty.mkdir() + clear_agent_cache() + monkeypatch.setattr(tools, "is_initialized", lambda: True) + monkeypatch.setattr(tools, "model_not_ready_error", lambda: None) + assert prepare_for_path(str(empty)) == {"files": 0} + + +def test_prepare_names_a_missing_path(project): + assert prepare_for_path(str(project / "gone"))["error"].startswith("Path not found") + + +def test_prepare_answers_mapper_unavailable_when_no_map_comes_back(project, monkeypatch): + def broken(*_a, **_k): + raise RuntimeError("no model") + + monkeypatch.setattr(tools, "map_instruction_files", broken) + assert prepare_for_path(str(project)) == {"error": "mapper_unavailable", "mapper_error": "RuntimeError: no model"} diff --git a/tests/unit/test_mcp_validate_inputs.py b/tests/unit/test_mcp_validate_inputs.py index 4b71354..627a838 100644 --- a/tests/unit/test_mcp_validate_inputs.py +++ b/tests/unit/test_mcp_validate_inputs.py @@ -308,7 +308,9 @@ def test_a_run_that_maps_more_files_does_not_read_as_a_change_on_the_next_call( def pipeline_replies(project: Path, monkeypatch: pytest.MonkeyPatch) -> list[dict[str, Any]]: """The real `_fresh_validate_payload`, fed by a pipeline stub that answers from a queue.""" replies: list[dict[str, Any]] = [] - monkeypatch.setattr(server, "run_pipeline_for_path", lambda path, full: (replies.pop(0), None, 0.0)) + monkeypatch.setattr( + server, "run_pipeline_for_path", lambda path, full, named=frozenset(): (replies.pop(0), None, 0.0) + ) monkeypatch.setattr(server, "_with_preservation", lambda payload, *a, **k: payload) return replies diff --git a/tests/unit/test_pipeline_mapping.py b/tests/unit/test_pipeline_mapping.py index e997d13..4a2e57e 100644 --- a/tests/unit/test_pipeline_mapping.py +++ b/tests/unit/test_pipeline_mapping.py @@ -766,6 +766,60 @@ def boom(*_a, **_k): assert any("Generic scanning skipped" in r.getMessage() and "disk gone" in r.getMessage() for r in caplog.records) +# --------------------------------------------------------------------------- +# One map at a time per process +# --------------------------------------------------------------------------- + + +def _real_cache_project(monkeypatch, tmp_path: Path) -> Path: + project = tmp_path / "proj" + project.mkdir() + (project / "CLAUDE.md").write_text("# Project\n\nRun the tests.\n", encoding="utf-8") + monkeypatch.setattr("reporails_cli.core.pipeline.mapping.get_global_cache_dir", lambda: tmp_path / "cache") + monkeypatch.setattr( + "reporails_cli.core.pipeline.mapping._resolve_daemon", + lambda *_a, **_k: mapping.DaemonStatus.UNAVAILABLE, + ) + return project + + +def _slow_counting_map(monkeypatch, calls: list[int]) -> None: + import time + + def slow_map(*_a: object, **_k: object) -> RulesetMap: + calls.append(1) + time.sleep(0.3) + return _ruleset_map("slow") + + monkeypatch.setattr("reporails_cli.core.pipeline.mapping._map_via_daemon_or_process", slow_map) + + +@pytest.mark.unit +@pytest.mark.subsys_map +@pytest.mark.requires_model +def test_two_threads_mapping_the_same_files_map_once(monkeypatch, tmp_path): + """The caller that waited for the first map reads the map the first one cached.""" + import threading + + project = _real_cache_project(monkeypatch, tmp_path) + calls: list[int] = [] + _slow_counting_map(monkeypatch, calls) + results: list[RulesetMap | None] = [] + + def run() -> None: + results.append(mapping.map_instruction_files(project, [project / "CLAUDE.md"], spawn_daemon=False)) + + threads = [threading.Thread(target=run) for _ in range(2)] + for t in threads: + t.start() + for t in threads: + t.join() + + assert len(calls) == 1 + assert len(results) == 2 and all(r is not None for r in results) + assert results[0].generated_at == results[1].generated_at == "slow" + + @pytest.mark.unit @pytest.mark.subsys_map @pytest.mark.parametrize(("agent", "other_added"), [("claude", False), ("", True)]) @@ -854,3 +908,16 @@ def load(agent, *args, **kwargs): path.write_text("x\n", encoding="utf-8") assert mapping._claimed_by_other(path, "claude", tmp_path) is False assert mapping._kind_of(path, unreadable, tmp_path) == "instruction_files" + + +@pytest.mark.unit +@pytest.mark.subsys_map +@pytest.mark.requires_model +def test_a_single_call_still_maps_once(monkeypatch, tmp_path): + project = _real_cache_project(monkeypatch, tmp_path) + calls: list[int] = [] + _slow_counting_map(monkeypatch, calls) + + result = mapping.map_instruction_files(project, [project / "CLAUDE.md"], spawn_daemon=False) + + assert result is not None and calls == [1] diff --git a/tests/unit/test_remedy_brief.py b/tests/unit/test_remedy_brief.py index 293c8ac..7ec1fef 100644 --- a/tests/unit/test_remedy_brief.py +++ b/tests/unit/test_remedy_brief.py @@ -110,7 +110,7 @@ def test_a_file_with_no_map_makes_the_whole_brief_a_structured_error(monkeypatch @pytest.mark.unit @pytest.mark.subsys_server def test_no_map_names_the_mapper_s_own_swallowed_exception(monkeypatch, tmp_path: Path) -> None: - """The live concurrency defect: `_build_map` swallows a mapper exception into a payload + """The live concurrency defect: `build_map` swallows a mapper exception into a payload `mapper_error` field (`interfaces/mcp/tools.py`). `brief_unavailable`'s message must name THAT — not the generic "produced no map" — and must prefer it over a bare `error` field when both are present, since `mapper_error` is the more specific cause.""" @@ -703,7 +703,9 @@ def test_a_brief_after_a_project_validate_takes_a_fresh_baseline(monkeypatch, tm edited = "# T\n\nKeep `foo`.\n" file.write_text(edited, encoding="utf-8") monkeypatch.setattr(server, "model_not_ready_error", lambda: None) - monkeypatch.setattr(server, "run_pipeline_for_path", lambda path, full=False: ({"files": {}}, fake_map, None)) + monkeypatch.setattr( + server, "run_pipeline_for_path", lambda path, full=False, named=frozenset(): ({"files": {}}, fake_map, None) + ) server._validate_states.clear() asyncio.run(server._run_validate(str(tmp_path), True)) remedy_brief.build_remedy_brief(location, tmp_path, fake_map) From 1cbc84bf16f3c2e5a50e2daf81714bed565bc041 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Thu, 22 Oct 2026 19:53:46 +0200 Subject: [PATCH 26/31] tests: the event-log file-mode tests and the linked-AGENTS.md test run on POSIX systems only --- UNRELEASED.md | 1 + tests/unit/test_expected_loads.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/UNRELEASED.md b/UNRELEASED.md index 0851fdb..7dc62a4 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -40,4 +40,5 @@ - A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. - Build checks fail when the environment is read, or a program is started, outside the one place that owns it. +- The event-log file-mode tests and the linked-AGENTS.md test run on POSIX systems only. - Every build check, not only the quick local one, fails on a test that is marked as more than one kind of test or as none. diff --git a/tests/unit/test_expected_loads.py b/tests/unit/test_expected_loads.py index b8f23c1..eafc116 100644 --- a/tests/unit/test_expected_loads.py +++ b/tests/unit/test_expected_loads.py @@ -5,6 +5,7 @@ import json import os import stat +import sys import time from pathlib import Path @@ -230,6 +231,9 @@ def test_agents_md_linking_to_claude_local_md_is_an_alias(tmp_path: Path) -> Non @pytest.mark.unit @pytest.mark.subsys_classify +@pytest.mark.skipif( + sys.platform == "win32", reason="a linked AGENTS.md whose target sits in a left-out folder is not listed on Windows" +) def test_agents_md_linking_into_agents_dir_or_excluded_folder_stays_skipped(tmp_path: Path) -> None: root = _project(tmp_path) _write(root / "CLAUDE.md") @@ -503,6 +507,7 @@ def test_oversize_folder_loses_oldest_sessions_first(tmp_path: Path) -> None: @pytest.mark.unit @pytest.mark.subsys_classify +@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX file modes") def test_non_log_files_are_left_alone_and_logs_become_private(tmp_path: Path) -> None: folder = _xray() other = _write(folder / "notes.txt", "mine") @@ -521,6 +526,7 @@ def test_non_log_files_are_left_alone_and_logs_become_private(tmp_path: Path) -> @pytest.mark.unit @pytest.mark.subsys_classify +@pytest.mark.skipif(sys.platform == "win32", reason="Windows has no POSIX file modes") def test_missing_folder_is_created_private(tmp_path: Path) -> None: expected_loads(_project(tmp_path)) assert stat.S_IMODE(_xray().stat().st_mode) == 0o700 From d660fa531ca0eb1f6821b6eb852ef00d89cec35d Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Thu, 22 Oct 2026 20:18:23 +0200 Subject: [PATCH 27/31] MCP prepare: detects the project's files afresh on each call, so a file added or removed since the last call is mapped as validate finds it --- UNRELEASED.md | 2 +- src/reporails_cli/interfaces/mcp/prepare.py | 4 ++++ tests/unit/test_mcp_prepare.py | 17 +++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 7dc62a4..a9138f3 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,7 +2,7 @@ ### Added -- MCP: a new `prepare` tool maps a project's instruction files without checking them, so the next `validate` returns sooner. +- MCP: a new `prepare` tool maps a project's instruction files without checking them, so the next `validate` returns sooner. It sees files added or removed since its last call. - The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` the x-ray leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed diff --git a/src/reporails_cli/interfaces/mcp/prepare.py b/src/reporails_cli/interfaces/mcp/prepare.py index 82e265e..de3f325 100644 --- a/src/reporails_cli/interfaces/mcp/prepare.py +++ b/src/reporails_cli/interfaces/mcp/prepare.py @@ -6,6 +6,7 @@ from pathlib import Path +from reporails_cli.core.discovery.agents import clear_agent_cache from reporails_cli.core.platform.contract.json_value import JsonObject from reporails_cli.interfaces.mcp.tools import Scope, _resolve_scan_target, build_map, scope_for_path from reporails_cli.interfaces.mcp.validate_targets import named_files, resolve_validate_targets, target_tokens @@ -19,6 +20,9 @@ def prepare_for_path(path: str, targets: list[str] | None = None) -> JsonObject: cannot be built. """ try: + # A long-running server keeps the detected-agent list between calls: detect afresh, so a + # file added or removed since the last call is mapped as `validate` will find it. + clear_agent_cache() named: frozenset[Path] = frozenset() tokens = target_tokens(targets) target = Path(path).resolve() diff --git a/tests/unit/test_mcp_prepare.py b/tests/unit/test_mcp_prepare.py index eaa3dfa..1b7be2a 100644 --- a/tests/unit/test_mcp_prepare.py +++ b/tests/unit/test_mcp_prepare.py @@ -98,3 +98,20 @@ def broken(*_a, **_k): monkeypatch.setattr(tools, "map_instruction_files", broken) assert prepare_for_path(str(project)) == {"error": "mapper_unavailable", "mapper_error": "RuntimeError: no model"} + + +def test_prepare_maps_a_file_added_since_the_last_call(project, monkeypatch): + mapped: list[list[str]] = [] + + def record(_target, files, **_k): + mapped.append(sorted(Path(f).name for f in files)) + return _map(len(files)) + + monkeypatch.setattr(tools, "map_instruction_files", record) + prepare_for_path(str(project)) + rules = project / ".claude" / "rules" + rules.mkdir(parents=True) + (rules / "style.md").write_text("# Style\n\nUse snake_case for functions.\n", encoding="utf-8") + prepare_for_path(str(project)) + assert "style.md" not in mapped[0] + assert "style.md" in mapped[1] From dfb36d964ca93d7e88f5d409cb76f0abfa614bcf Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Thu, 22 Oct 2026 20:18:57 +0200 Subject: [PATCH 28/31] release: 0.6.5 changelog, UNRELEASED reset --- CHANGELOG.md | 43 +++++++++++++++++++++++++++++++++++++++++++ UNRELEASED.md | 33 --------------------------------- 2 files changed, 43 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3723ff7..eb1d010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,48 @@ # Changelog +## 0.6.5 + +### Added + +- MCP: a new `prepare` tool maps a project's instruction files without checking them, so the next `validate` returns sooner. It sees files added or removed since its last call. +- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` the x-ray leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. + +### Changed + +- A global npm install stays on its version until you update it; `ails version` shows `npm` for an install from the npm package (npx included) and `ails update` prints the command that updates it. +- A run through the npm package no longer re-checks the package index for a newer release every time it starts. +- The context x-ray's event logs in `~/.reporails/xray/` are private to you, removed after 30 days (sooner if Claude Code is set to keep sessions for less), and the folder stays under 100 MB. +- `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. +- The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. +- A folder value in `--exclude-dirs` or `exclude_dirs` that holds `*`, `?`, a backslash or a `..` step stops the run with an error that names the flag or the config file it came from and points to `--exclude-files` / `exclude_files` for file patterns; such a value used to be listed as left out while leaving nothing out. `ails config set exclude_dirs` refuses it before writing, with an error that names the setting, and the MCP tools answer `config_unreadable`. + +### Fixed + +- A first run through `npx` on a machine without `uv` installs `uv` and finishes your command, instead of stopping to ask you to restart your shell. The install messages go to stderr, so the command's own output, such as `-f json`, stays clean. +- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. +- On Windows, `ails install` no longer counts an `ails` in the current folder or in uv's cache as installed, and an empty PATHEXT no longer hides an installed `ails`. +- `ails update` reports a uv run that cannot start or times out, instead of crashing. +- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. +- A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. +- `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. +- A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. +- A space before the address in `AILS_SERVER_URL` no longer stops `ails check` from reaching that server. +- `--exclude-dirs` and `exclude_dirs` read a value with a `/` as a folder path from the project root: `/docs`, `./docs`, `docs/.` and an absolute path inside the project leave out the root's `docs` folder, where `/docs` and an absolute path used to leave out nothing and `./docs` every `docs` folder. The `Excluded` line lists such a folder as `./docs`. Folder names holding `[` and `]`, such as `[slug]`, can be left out. +- A folder you leave out by path is skipped during the scan, as a folder name is, so a large left-out folder costs no scan time, and an agent whose files sit only inside a folder left out in `exclude_dirs` is not detected. The scan does not enter a left-out folder through a symlink, or on Windows a junction, that points inside it either. +- The skill entry-point check no longer reports a skill folder you left out. +- On a run that names a file and also checks a folder or a capability listing, the `Excluded` line keeps a folder or pattern the named file sits in. +- `ails check --agent ` no longer checks another agent's file that you named inside a left-out folder. +- A run with no findings no longer says the score covers the rest of the project, since it shows no score. +- With `generic_scanning: true`, a failure while reading the files your instruction files import or link is logged as a warning instead of silently checking none of them. +- The over-cap message never suggests leaving out a folder whose name `--exclude-dirs` would refuse, and on Pro its lines end with a full stop. + +### Internal + +- A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. +- Build checks fail when the environment is read, or a program is started, outside the one place that owns it. +- The event-log file-mode tests and the linked-AGENTS.md test run on POSIX systems only. +- Every build check, not only the quick local one, fails on a test that is marked as more than one kind of test or as none. + ## 0.6.4 ### Changed diff --git a/UNRELEASED.md b/UNRELEASED.md index a9138f3..3052d77 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -2,43 +2,10 @@ ### Added -- MCP: a new `prepare` tool maps a project's instruction files without checking them, so the next `validate` returns sooner. It sees files added or removed since its last call. -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` the x-ray leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. - ### Changed -- A global npm install stays on its version until you update it; `ails version` shows `npm` for an install from the npm package (npx included) and `ails update` prints the command that updates it. -- A run through the npm package no longer re-checks the package index for a newer release every time it starts. -- The context x-ray's event logs in `~/.reporails/xray/` are private to you, removed after 30 days (sooner if Claude Code is set to keep sessions for less), and the folder stays under 100 MB. -- `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. -- The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. -- A folder value in `--exclude-dirs` or `exclude_dirs` that holds `*`, `?`, a backslash or a `..` step stops the run with an error that names the flag or the config file it came from and points to `--exclude-files` / `exclude_files` for file patterns; such a value used to be listed as left out while leaving nothing out. `ails config set exclude_dirs` refuses it before writing, with an error that names the setting, and the MCP tools answer `config_unreadable`. - ### Fixed -- A first run through `npx` on a machine without `uv` installs `uv` and finishes your command, instead of stopping to ask you to restart your shell. The install messages go to stderr, so the command's own output, such as `-f json`, stays clean. -- `ails install` run through npx, `pnpm dlx` or `bunx`, or from a project's own dependency, puts `ails` on your PATH instead of reporting that copy as already installed. -- On Windows, `ails install` no longer counts an `ails` in the current folder or in uv's cache as installed, and an empty PATHEXT no longer hides an installed `ails`. -- `ails update` reports a uv run that cannot start or times out, instead of crashing. -- A project's CLAUDE.md that a subfolder's CLAUDE.md links to is listed and checked as the project's main file, not as that subfolder's file. -- A pinned `npx @reporails/cli@X.Y.Z` or `npm install -g @reporails/cli@X.Y.Z` runs that version; a bare `npx @reporails/cli` runs the latest release unless a copy is installed globally or in the project. -- `ails check` honours Claude Code's Project instructions setting saved under the plugin ID Claude Code has used since 2.1.285 (`cc-plugin-agents-md@builtin`), as well as the older `agents-md@builtin`. -- A rules file in a subdirectory's `.claude/rules/` folder is checked as loading on demand, as Claude Code loads it, rather than at session start. -- A space before the address in `AILS_SERVER_URL` no longer stops `ails check` from reaching that server. -- `--exclude-dirs` and `exclude_dirs` read a value with a `/` as a folder path from the project root: `/docs`, `./docs`, `docs/.` and an absolute path inside the project leave out the root's `docs` folder, where `/docs` and an absolute path used to leave out nothing and `./docs` every `docs` folder. The `Excluded` line lists such a folder as `./docs`. Folder names holding `[` and `]`, such as `[slug]`, can be left out. -- A folder you leave out by path is skipped during the scan, as a folder name is, so a large left-out folder costs no scan time, and an agent whose files sit only inside a folder left out in `exclude_dirs` is not detected. The scan does not enter a left-out folder through a symlink, or on Windows a junction, that points inside it either. -- The skill entry-point check no longer reports a skill folder you left out. -- On a run that names a file and also checks a folder or a capability listing, the `Excluded` line keeps a folder or pattern the named file sits in. -- `ails check --agent ` no longer checks another agent's file that you named inside a left-out folder. -- A run with no findings no longer says the score covers the rest of the project, since it shows no score. -- With `generic_scanning: true`, a failure while reading the files your instruction files import or link is logged as a warning instead of silently checking none of them. -- The over-cap message never suggests leaving out a folder whose name `--exclude-dirs` would refuse, and on Pro its lines end with a full stop. - ### Removed ### Internal - -- A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. -- Build checks fail when the environment is read, or a program is started, outside the one place that owns it. -- The event-log file-mode tests and the linked-AGENTS.md test run on POSIX systems only. -- Every build check, not only the quick local one, fails on a test that is marked as more than one kind of test or as none. From db8315c40c817a5f9a9efd27c2e442541982fbf7 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Thu, 22 Oct 2026 20:39:42 +0200 Subject: [PATCH 29/31] tests: the event-log pruning test checks file modes on POSIX systems only --- UNRELEASED.md | 2 ++ tests/integration/test_expected_loads_managed_only.py | 7 +++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/UNRELEASED.md b/UNRELEASED.md index 3052d77..29d3af8 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -9,3 +9,5 @@ ### Removed ### Internal + +- The event-log pruning test checks file modes on POSIX systems only. diff --git a/tests/integration/test_expected_loads_managed_only.py b/tests/integration/test_expected_loads_managed_only.py index 51d9946..962e14b 100644 --- a/tests/integration/test_expected_loads_managed_only.py +++ b/tests/integration/test_expected_loads_managed_only.py @@ -7,6 +7,7 @@ import shutil import stat import subprocess +import sys import time from pathlib import Path @@ -99,5 +100,7 @@ def test_expected_loads_prunes_the_event_logs_it_names(tmp_path: Path) -> None: assert out["event_log_dir"] == str(xray) assert [Path(i["path"]).name for i in out["files"]] == ["CLAUDE.md"] assert sorted(p.name for p in xray.iterdir()) == [fresh.name] - assert stat.S_IMODE(fresh.stat().st_mode) == 0o600 - assert stat.S_IMODE(xray.stat().st_mode) == 0o700 + # Windows has no POSIX file modes; the pruning above is checked everywhere. + if sys.platform != "win32": + assert stat.S_IMODE(fresh.stat().st_mode) == 0o600 + assert stat.S_IMODE(xray.stat().st_mode) == 0o700 From 5d941111f29cc93e5288ef93a9bed6e4701849e0 Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Thu, 22 Oct 2026 20:39:42 +0200 Subject: [PATCH 30/31] release: 0.6.5 changelog, UNRELEASED reset after the Windows integration test fix --- CHANGELOG.md | 1 + UNRELEASED.md | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1d010..348104d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ - A build check fails when a fixed set of named values is spelled out as plain text in another list or table instead of using the names; the one place that did it, the capability-level table, now uses the names. - Build checks fail when the environment is read, or a program is started, outside the one place that owns it. - The event-log file-mode tests and the linked-AGENTS.md test run on POSIX systems only. +- The event-log pruning test checks file modes on POSIX systems only. - Every build check, not only the quick local one, fails on a test that is marked as more than one kind of test or as none. ## 0.6.4 diff --git a/UNRELEASED.md b/UNRELEASED.md index 29d3af8..3052d77 100644 --- a/UNRELEASED.md +++ b/UNRELEASED.md @@ -9,5 +9,3 @@ ### Removed ### Internal - -- The event-log pruning test checks file modes on POSIX systems only. From 7ec12c6fab1dd25fb6cf598ff63c78a5efe6cd3b Mon Sep 17 00:00:00 2001 From: cleverhoods Date: Thu, 22 Oct 2026 21:22:24 +0200 Subject: [PATCH 31/31] release: 0.6.5 changelog names the ails expected-loads command and the event logs, not the x-ray --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 348104d..88bb68d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,13 @@ ### Added - MCP: a new `prepare` tool maps a project's instruction files without checking them, so the next `validate` returns sooner. It sees files added or removed since its last call. -- The reporails plugin's context x-ray can now see which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` the x-ray leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. +- `ails expected-loads` names which instruction files Claude Code should load in a project and why an AGENTS.md is skipped; an AGENTS.md that links to, or is a copy with exactly the same text of, a CLAUDE.md that loads wherever the AGENTS.md would, is reported as that file rather than as skipped; your own `~/.claude/CLAUDE.md` and `~/.claude/rules/` files are listed; a project CLAUDE.md that links to your own `~/.claude/CLAUDE.md` is listed as the project's own file; under `managed-only` it leaves out exactly the files Claude Code leaves out at launch; an empty file, or one holding only frontmatter, which Claude Code sends nothing for, is not expected to load. ### Changed - A global npm install stays on its version until you update it; `ails version` shows `npm` for an install from the npm package (npx included) and `ails update` prints the command that updates it. - A run through the npm package no longer re-checks the package index for a newer release every time it starts. -- The context x-ray's event logs in `~/.reporails/xray/` are private to you, removed after 30 days (sooner if Claude Code is set to keep sessions for less), and the folder stays under 100 MB. +- Event logs in `~/.reporails/xray/` are private to you, removed after 30 days (sooner if Claude Code is set to keep sessions for less), and the folder stays under 100 MB. - `ails expected-loads` names, for each AGENTS.md Claude Code skips, the `CLAUDE.md`, `.claude/CLAUDE.md` or `CLAUDE.local.md` that made it skip the file. - The MCP `validate` tool now tells the agent which messages are announcements from reporails, so it shows them as announcements rather than as account messages. - A folder value in `--exclude-dirs` or `exclude_dirs` that holds `*`, `?`, a backslash or a `..` step stops the run with an error that names the flag or the config file it came from and points to `--exclude-files` / `exclude_files` for file patterns; such a value used to be listed as left out while leaving nothing out. `ails config set exclude_dirs` refuses it before writing, with an error that names the setting, and the MCP tools answer `config_unreadable`.