|
| 1 | +# AGENTS.md — coverage-sh |
| 2 | + |
| 3 | +Guidelines for agentic coding agents working in this repository. |
| 4 | + |
| 5 | +## Project Overview |
| 6 | + |
| 7 | +`coverage-sh` is a [coverage.py](https://coverage.readthedocs.io/) plugin that measures |
| 8 | +code coverage for shell scripts executed from Python. The entire implementation lives in |
| 9 | +a single file (`coverage_sh/plugin.py`). The package uses `hatchling` as the build |
| 10 | +backend and `uv` as the package manager. |
| 11 | + |
| 12 | +--- |
| 13 | + |
| 14 | +## Build & Tooling Commands |
| 15 | + |
| 16 | +All commands are run via `uv run`. Sync dependencies first if needed: |
| 17 | + |
| 18 | +```bash |
| 19 | +uv sync --locked --all-extras --dev |
| 20 | +``` |
| 21 | + |
| 22 | +| Task | Command | |
| 23 | +|---|---| |
| 24 | +| Format check | `uv run ruff format --check .` | |
| 25 | +| Auto-format | `uv run ruff format .` | |
| 26 | +| Lint | `uv run ruff check .` | |
| 27 | +| Lint + auto-fix | `uv run ruff check --fix --unsafe-fixes .` | |
| 28 | +| Type check | `uv run mypy .` | |
| 29 | +| Run all tests | `uv run pytest` | |
| 30 | +| Run tests with coverage | `uv run coverage run --parallel-mode -m pytest` | |
| 31 | +| Build package | `uv build` | |
| 32 | + |
| 33 | +**Before marking any task as complete, always run:** |
| 34 | +```bash |
| 35 | +uv run ruff check . && uv run mypy . && uv run pytest |
| 36 | +``` |
| 37 | + |
| 38 | +--- |
| 39 | + |
| 40 | +## Running Tests |
| 41 | + |
| 42 | +```bash |
| 43 | +# All tests |
| 44 | +uv run pytest |
| 45 | + |
| 46 | +# Single test function |
| 47 | +uv run pytest tests/test_plugin.py::test_filename_suffix_should_match_pattern |
| 48 | + |
| 49 | +# Single test class |
| 50 | +uv run pytest tests/test_plugin.py::TestShellFileReporter |
| 51 | + |
| 52 | +# Single method in a class |
| 53 | +uv run pytest tests/test_plugin.py::TestShellFileReporter::test_lines_should_match_reference |
| 54 | + |
| 55 | +# Parametrized variant |
| 56 | +uv run pytest "tests/test_plugin.py::test_end2end[True]" |
| 57 | + |
| 58 | +# By keyword |
| 59 | +uv run pytest -k "test_handle_missing_file" |
| 60 | +``` |
| 61 | + |
| 62 | +CI requires **100% branch coverage** (`--fail-under=100`). Use `# pragma: no cover` |
| 63 | +only for genuinely unreachable defensive branches. |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## Code Style |
| 68 | + |
| 69 | +PEP8 + Black. Run `uv run ruff check --fix --unsafe-fixes .` then `uv run ruff format .` before committing. |
| 70 | + |
| 71 | +--- |
| 72 | + |
| 73 | +## Import Conventions |
| 74 | + |
| 75 | +Every source file begins with: |
| 76 | + |
| 77 | +```python |
| 78 | +from __future__ import annotations |
| 79 | +``` |
| 80 | + |
| 81 | +Import ordering within a file: |
| 82 | + |
| 83 | +1. `from __future__ import annotations` — always first |
| 84 | +2. Standard library bare imports (`import os`, `import sys`, …) |
| 85 | +3. Standard library `from … import …` |
| 86 | +4. Third-party bare imports |
| 87 | +5. Third-party `from … import …` |
| 88 | +6. `if TYPE_CHECKING:` block — types needed only for annotations |
| 89 | + |
| 90 | +```python |
| 91 | +from __future__ import annotations |
| 92 | + |
| 93 | +import contextlib |
| 94 | +import os |
| 95 | +from pathlib import Path |
| 96 | +from typing import TYPE_CHECKING, Any |
| 97 | + |
| 98 | +import coverage |
| 99 | + |
| 100 | +if TYPE_CHECKING: |
| 101 | + from collections.abc import Iterable |
| 102 | + from coverage.types import TLineNo |
| 103 | + from tree_sitter import Node |
| 104 | +``` |
| 105 | + |
| 106 | +- Within the package: use **relative** imports (`from .plugin import ShellPlugin`) |
| 107 | +- In tests and externally: use **absolute** imports (`from coverage_sh.plugin import …`) |
| 108 | +- Put `TYPE_CHECKING`-only imports in the `if TYPE_CHECKING:` block to avoid runtime cost |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## Naming Conventions |
| 113 | + |
| 114 | +| Construct | Convention | Example | |
| 115 | +|---|---|---| |
| 116 | +| Modules / files | `snake_case` | `plugin.py`, `test_plugin.py` | |
| 117 | +| Classes | `PascalCase` | `ShellFileReporter`, `CovLineParser` | |
| 118 | +| Test classes | `Test` prefix | `TestShellFileReporter` | |
| 119 | +| Functions / methods | `snake_case` | `find_executable_files()` | |
| 120 | +| Private methods | leading `_` | `_parse_ast()`, `_iterdir()` | |
| 121 | +| Test methods | `test_<what>_should_<expected_outcome>` | `test_source_should_be_cached` | |
| 122 | +| Module constants | `UPPER_SNAKE_CASE` | `TRACEFILE_PREFIX`, `TMP_PATH` | |
| 123 | +| Local variables | `snake_case` | `fifo_path`, `line_data` | |
| 124 | +| Type aliases | `PascalCase` | `LineData = dict[str, set[int]]` | |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## Error Handling |
| 129 | + |
| 130 | +Three patterns are used — pick the one appropriate to the situation: |
| 131 | + |
| 132 | +**1. Suppress expected, ignorable errors (preferred for cleanup):** |
| 133 | +```python |
| 134 | +with contextlib.suppress(FileNotFoundError): |
| 135 | + self.fifo_path.unlink() |
| 136 | +``` |
| 137 | + |
| 138 | +**2. Re-raise with context (for parse/validation errors):** |
| 139 | +```python |
| 140 | +try: |
| 141 | + _, path_, lineno_, _ = line.split(":::", maxsplit=3) |
| 142 | + lineno = int(lineno_) |
| 143 | +except ValueError as e: |
| 144 | + raise ValueError(f"could not parse line {line}") from e |
| 145 | +``` |
| 146 | + |
| 147 | +**3. Safe fallback / early return for non-fatal failures:** |
| 148 | +```python |
| 149 | +try: |
| 150 | + self._content = self.path.read_text() |
| 151 | +except UnicodeDecodeError: |
| 152 | + return "" |
| 153 | +``` |
| 154 | + |
| 155 | +Never use bare `except:` or `except Exception:` without a very specific reason. |
| 156 | + |
| 157 | +--- |
| 158 | + |
| 159 | +## Testing Patterns |
| 160 | + |
| 161 | +Framework: **pytest** (see `pyproject.toml` for version). |
| 162 | + |
| 163 | +- One test class per source class, one test function per module-level function |
| 164 | +- All tests live in `tests/test_plugin.py`; shared fixtures in `tests/conftest.py` |
| 165 | +- Use plain `assert` statements (not `assertEqual`/`assertTrue` etc.) |
| 166 | +- Use `pytest.raises(ExceptionType, match="regex")` for exception assertions |
| 167 | +- **No monkeypatching** — do not use `monkeypatch.setattr()` to patch attributes; changing |
| 168 | + directories or environment variables directly is fine |
| 169 | +- Use `tmp_path` (pytest built-in) for temporary file isolation |
| 170 | +- Test method naming: `test_<what>_should_<expected_outcome>` |
| 171 | +- Integration tests use `subprocess.run` to invoke `coverage run` against a real project |
| 172 | + in a temp directory |
| 173 | + |
| 174 | +**Test double conventions (defined as inner classes inside test classes):** |
| 175 | +- Spy: subclass that overrides one method and records calls — e.g., `CovLineParserSpy` |
| 176 | +- Fake: minimal implementation of an interface — e.g., `CovWriterFake` |
| 177 | +- Stub: minimal object satisfying a dependency — e.g., `MainThreadStub` |
| 178 | + |
| 179 | +**Parametrize:** |
| 180 | +```python |
| 181 | +@pytest.mark.parametrize("cover_always", [(True), (False)]) |
| 182 | +def test_something(cover_always: bool) -> None: |
| 183 | + ... |
| 184 | +``` |
| 185 | + |
| 186 | +--- |
| 187 | + |
| 188 | +## Architecture Notes |
| 189 | + |
| 190 | +- **Single-module plugin:** all production logic is in `coverage_sh/plugin.py`; |
| 191 | + `coverage_sh/__init__.py` is only a registration entry point |
| 192 | +- **File detection:** uses `python-magic` (libmagic) MIME type detection |
| 193 | + (`text/x-shellscript`), not file extensions — do not assume `.sh` == shell script |
| 194 | +- **Two coverage modes:** subprocess monkey-patching (default) and `cover_always` mode |
| 195 | + (sets `BASH_ENV`/`ENV` globally) |
| 196 | +- **FIFO pipeline:** bash processes write `COV:::<path>:::<lineno>:::` lines via |
| 197 | + `BASH_XTRACEFD` into a named FIFO; `CoverageParserThread` reads and records them |
| 198 | +- **Thread safety:** `CoverageParserThread` and `MonitorThread` coordinate shutdown; |
| 199 | + always call `stop()` then `join()` in that order |
| 200 | +- The `src/coverage_sh/` directory exists but is **not used** — the real package is the |
| 201 | + root-level `coverage_sh/` |
0 commit comments