Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8ce55cc
feat(discovery): support nested .gitignore files
NodeJSmith Aug 19, 2026
a4dc7fa
feat(config): add extend-select/extend-ignore for additive rule selec…
NodeJSmith Aug 19, 2026
86c24ef
feat(config): add per-file-ignores for scoped rule silencing
NodeJSmith Aug 19, 2026
a0cc848
feat(cache): add per-file result caching
NodeJSmith Aug 19, 2026
ff5613a
fix: use default_factory for per_file_ignores mappingproxy default
NodeJSmith Aug 19, 2026
c367db8
fix(cache): version-namespace cache keys and harden cache writes
NodeJSmith Aug 19, 2026
ba3d60f
fix(discovery): stop descending into gitignored directories
NodeJSmith Aug 19, 2026
5c3286f
fix(cli): skip cache write when content drifts mid-scan
NodeJSmith Aug 19, 2026
fd0505e
fix(config): reject negated per-file-ignores keys
NodeJSmith Aug 19, 2026
bfd17df
fix(cache): restrict version-dir pruning and validate scalar field types
NodeJSmith Aug 19, 2026
b76b74a
fix(discovery): preserve pattern whitespace and check ignored ancestors
NodeJSmith Aug 19, 2026
aa6d1bc
fix(discovery): match git's ignore semantics and harden the result cache
NodeJSmith Aug 20, 2026
2c19e2a
fix(discovery): honour excluded ancestors for explicit paths and vali…
NodeJSmith Aug 20, 2026
895d363
fix(cache): derive the cache key from the bytes actually scanned
NodeJSmith Aug 20, 2026
9abcd4c
ci: run the slow gitignore-parity suite on every CI run
NodeJSmith Aug 20, 2026
7f9649b
test: gate the randomized gitignore suite on CI rather than a marker
NodeJSmith Aug 20, 2026
b533a51
fix(cache): harden entry reads and refuse a symlinked default cache dir
NodeJSmith Aug 20, 2026
3345127
fix(discovery): stop following symlinked .gitignore files
NodeJSmith Aug 20, 2026
8714f8d
fix(source): refuse a resolved path swapped for a symlink
NodeJSmith Aug 20, 2026
84a6ba3
fix(config): report a non-array selection key as a config error
NodeJSmith Aug 20, 2026
947f670
docs: list the interpreter version as a cache key input
NodeJSmith Aug 20, 2026
0cb8ade
test: surface git's stderr when repository setup fails
NodeJSmith Aug 20, 2026
57321bc
fix(cache): create cache markers with O_EXCL instead of exists-then-w…
NodeJSmith Aug 20, 2026
af30cb7
fix(discovery): honour excluded ancestors for configured excludes
NodeJSmith Aug 20, 2026
390cbdf
fix(cli): match per-file-ignores on the file's resolved path
NodeJSmith Aug 20, 2026
bd5fcc5
refactor(discovery): extract the ancestor walk shared by both ancesto…
NodeJSmith Aug 20, 2026
5b75b3b
docs: document ancestor-exclusion precedence and per-file-ignores pat…
NodeJSmith Aug 20, 2026
9ed5f99
fix(discovery): decide trailing whitespace by backslash-run parity
NodeJSmith Aug 20, 2026
c057c28
test(discovery): generate repeated ** segments in the fuzz corner pool
NodeJSmith Aug 20, 2026
f217746
test(discovery): pin the repeated-** collapse against real git
NodeJSmith Aug 20, 2026
c87cfad
fix(discovery): collapse repeated ** runs when rewriting nested patterns
NodeJSmith Aug 20, 2026
739fc0f
test(discovery): pin explicit directories spelled through '..'
NodeJSmith Aug 20, 2026
3909102
fix(discovery): walk explicit directories in resolved form
NodeJSmith Aug 20, 2026
f30bc54
docs(discovery): correct the divergence-direction guarantee
NodeJSmith Aug 20, 2026
d72affa
test(cache): pin the two symlink paths into the cache directory
NodeJSmith Aug 20, 2026
60eafdb
fix(cache): close both symlink paths into the default cache directory
NodeJSmith Aug 20, 2026
9394344
test: harden the git harness and pin the symlink mapping
NodeJSmith Aug 20, 2026
29fd574
test: pin explicit-file '..' ancestry and symlinked version markers
NodeJSmith Aug 20, 2026
278adcb
fix: resolve explicit-file ancestry and reject symlinked version markers
NodeJSmith Aug 20, 2026
058a297
fix(cli): re-scan cached errors under --debug
NodeJSmith Aug 20, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
- name: Install dependencies
run: uv sync --locked

# Runs everything, including the randomized gitignore-parity suite: Actions sets
# `CI`, which is what that suite gates itself on. Deliberately no marker flag here —
# a flag is a second place to keep in sync, and forgetting it fails silently green.
- name: Run tests
run: uv run pytest

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
__pycache__/
*.py[cod]
.house-lint-cache/
19 changes: 17 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,25 @@ signature: `(source, options, *, limit=None) -> list[CandidateFinding]`.

- `HSL900` (suppression-pragma validation) can never be disabled or suppressed — it governs how
every other rule's findings can be silenced (`ignore`, `ignore-next`, `ignore-file` pragmas).
- File discovery does **not** shell out to git or read nested `.gitignore` files — only the root
`.gitignore` plus configured excludes. `--no-gitignore` disables just that root file.
- File discovery does **not** shell out to git. It reads the root `.gitignore` plus every nested
`.gitignore` between the root and each file, reimplementing git's precedence on `pathspec`.
`--no-gitignore` disables that at every level. Because it is a reimplementation, changes to
`discovery.py`'s pattern handling belong in `tests/integration/test_gitignore_parity.py`, which
differentially checks discovery against real `git check-ignore` — adding a case there costs one
`Scenario` entry and needs no expected-value literal. One divergence is known and deliberate
(negated directory-only patterns); see `docs/configuration.md`.
- An ignored directory is pruned rather than enumerated, so `files_skipped` counts one skip per
pruned directory, not one per file inside it.
- Default scan roots are `src`, `tests`, `scripts`, `tools`, `examples`, configurable via
`[tool.house-lint] include`.
- A scanned file is read **exactly once** per scan, by `SourceFile.load()`. The cache key is
derived from that same buffer (`SourceFile.content_bytes` → `hash_source_content`), which is
what stops an entry from ever describing content that was not scanned under that key. Adding a
second read of a scanned path reopens that window;
`test_each_scanned_file_is_read_exactly_once` is what catches it.
- The result cache is namespaced by `<version>-<hash of house-lint's own sources>`, so editing
rule code invalidates it without a version bump. house-lint writes a self-ignoring `.gitignore`
into its own default cache base only — never into a user-supplied `--cache-dir`.

## Conventions

Expand Down
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ house-lint check --select HSL002,HSL103 --ignore HSL103

Each `--select` or `--ignore` occurrence accepts one comma-separated list. Selection is strict: unknown, duplicate, empty, and `HSL900` IDs are usage errors.

To add or remove rules without replacing the rest of your configured selection, use `extend-select`/`extend-ignore` (in `[tool.house-lint]` or as `--extend-select`/`--extend-ignore`) instead of `select`/`ignore`:

```bash
house-lint check --extend-select HSL101
```

`extend-select`/`extend-ignore` layer additively on top of the base selection (configured `select`/`ignore`, or a CLI `--select` override) regardless of where that base came from. A final CLI `--ignore` still always wins.

To silence a rule only for files matching a glob, without touching the selection everywhere else, use `[tool.house-lint.per-file-ignores]`:

```toml
[tool.house-lint.per-file-ignores]
"tests/**" = ["HSL002"]
```

Read [configuration](docs/configuration.md) for discovery, precedence, validation, excludes, and token-family options.

## Paths, roots, and Git ignores
Expand All @@ -93,7 +108,17 @@ house-lint check src/service.py tests

Explicit paths are strict. Missing, out-of-root, and non-Python file arguments are errors; ignored or excluded explicit Python files are counted as skipped. `--root` fixes the project boundary and only considers `<root>/pyproject.toml`. Without `--root`, discovery starts at the current directory. `--config` selects an exact configuration file; without `--root`, its parent becomes the root.

The linter loads only the selected root's `.gitignore`, plus built-in and configured excludes. It does not search nested `.gitignore` files or shell out to Git. Use `--no-gitignore` to disable only the root `.gitignore`.
The linter loads the selected root's `.gitignore` plus every nested `.gitignore` between the root and each discovered file, combined with git's own precedence (a closer `.gitignore` can override a farther one, including via negation), plus built-in and configured excludes. It does not shell out to Git. Use `--no-gitignore` to disable `.gitignore` handling at every level.

## Caching

`check` caches each file's result under `<root>/.house-lint-cache/<version>-<source fingerprint>/`, keyed by the file's content, its effective rule set for that file, and the running Python version — `ast.parse` accepts different grammar across the versions house-lint supports, so a cache shared between venvs must not replay one interpreter's parse result under another. A cache hit skips tokenization, parsing, and rule execution entirely for that file. Upgrading house-lint — or editing its rule code in a working checkout — starts from an empty cache automatically, because both the version and a fingerprint of house-lint's own sources are part of the cache path. Superseded directories are pruned rather than left to accumulate.

`--no-cache` disables reading from the cache but still writes to it, keeping it warm for the next run. `--cache-dir` overrides where the cache lives (still namespaced underneath the path you give it).

house-lint adds a self-ignoring `.gitignore` to its own default `.house-lint-cache/` directory so it stays invisible to `git status`. It never writes one into a directory you name with `--cache-dir` — that directory is yours.

A cache failure never fails a scan, but it is never silent either: an unwritable directory, a full disk or a corrupted entry prints one `warning:` line to stderr the first time it happens in a run. Only that first failure is printed by default. A broken cache directory fails once per scanned file, so printing every one would bury the single fact worth reporting under thousands of near-identical lines; the remainder are shown under `--debug`. Findings and the exit code are unaffected.

## Suppressions

Expand Down
56 changes: 53 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,69 @@ include = ["src", "tests", "scripts", "tools", "examples"]
exclude = []
select = ["HSL001", "HSL002", "HSL003", "HSL004"]
ignore = []
extend-select = []
extend-ignore = []
```

`include` contains literal root-relative files or directories, not globs. An empty array intentionally selects no roots for a full scan. `exclude` uses root-relative Git-ignore-style patterns. Unknown keys, absolute paths, parent traversal, invalid patterns, duplicate IDs, and `HSL900` in `select` or `ignore` are configuration errors.
`include` contains literal root-relative files or directories, not globs. An empty array intentionally selects no roots for a full scan. `exclude` uses root-relative Git-ignore-style patterns. Unknown keys, absolute paths, parent traversal, invalid patterns, duplicate IDs, and `HSL900` in `select`, `ignore`, `extend-select`, `extend-ignore`, or `per-file-ignores` are configuration errors. Unlike the rest of this schema, `extend-select`/`extend-ignore`/`per-file-ignores` are hyphenated by design, matching Ruff's spelling for the same concepts.

## Per-file rule overrides

`[tool.house-lint.per-file-ignores]` maps root-relative Git-ignore-style glob patterns to rule IDs to drop for matching files, without changing the global selection for everything else:

```toml
[tool.house-lint.per-file-ignores]
"tests/**" = ["HSL002"]
"legacy/*.py" = ["HSL001", "HSL003"]
```

Applied after the base selection and `extend-select`/`extend-ignore` resolve, per file: a rule dropped by `per-file-ignores` for a matching file is not detected for that file at all, so a `# house-lint: ignore[...]` pragma naming it there is flagged the same way as suppressing an already-disabled rule. `HSL900` can never appear in a `per-file-ignores` value.

## Discovery and precedence

1. `--root` fixes the project boundary.
2. `--config` selects an exact configuration. Without `--root`, its parent is the root; with `--root`, it must be inside the root.
3. With `--root` and no `--config`, only `<root>/pyproject.toml` is considered.
4. Without either option, the command searches upward from the current directory for the nearest `pyproject.toml` containing `[tool.house-lint]`. If none exists, it uses the nearest ancestor containing `.git` or any `pyproject.toml`; otherwise it uses the current directory.
5. CLI `--select` replaces configured selection, then CLI `--ignore` subtracts IDs. `HSL900` is always added.
5. The base selection is configured `select` minus configured `ignore`, or a CLI `--select` wholesale override when given.
6. `extend-select`/`extend-ignore` (config and CLI, unioned together) layer additively on top of that base, regardless of whether the base came from config or `--select`. `extend-ignore` removes rules from the whole base, not just from `extend-select` — `select = ["HSL001"]` with `extend-ignore = ["HSL001"]` drops HSL001 entirely, it isn't limited to canceling out `extend-select` additions.
7. CLI `--ignore` is applied last and always wins over everything above. `HSL900` is always added.

The root `.gitignore` and every nested `.gitignore` between the root and each discovered file are loaded and combined with git's own precedence — a closer `.gitignore` can override a farther one, including via negation (`!pattern`). Built-in excludes are `.git/`, `.venv/`, `.nox/`, `__pycache__/`, `site-packages/`, and `node_modules/`; configured excludes are added afterwards. `--no-gitignore` disables `.gitignore` handling at every level.

An ignored directory is skipped without being enumerated, which is what keeps a large `.venv/` or `node_modules/` cheap to exclude. The reported `files skipped` count follows from that: one pruned directory counts as one skip, however many files it contains.

Exclusion attaches to the directory, so a negation cannot re-include anything beneath one that is already excluded — `exclude = ["src/generated/", "!src/generated/foo.py"]` leaves `foo.py` excluded, matching git. This holds for built-in excludes, configured `exclude`, and `.gitignore` alike, and it holds however the file is reached: naming `src/generated/foo.py` on the command line skips it just as a full scan does.

`per-file-ignores` patterns are matched against each file's resolved location under the root, not the spelling used to reach it. Naming `src/../tests/a.py`, or a path reached through a symlinked directory, matches the same patterns as `tests/a.py` would. Findings still report the path as typed.

house-lint reimplements git's ignore rules on top of [`pathspec`](https://pypi.org/project/pathspec/) rather than shelling out to git. Two test suites check that reimplementation against real `git check-ignore`: `tests/integration/test_gitignore_parity.py` runs a curated table of pattern shapes, and `tests/integration/test_gitignore_fuzz.py` generates random combinations. The second runs on every CI run and skips locally unless `CI` is set, since it makes thousands of real `git check-ignore` calls; run it by hand with `CI=1 uv run pytest -s tests/integration/test_gitignore_fuzz.py` (`-s` prints the rates below).

One divergence is known and deliberate: a negated directory-only pattern (`!sub/`) re-includes everything beneath it, whereas git re-includes only the `sub` entry itself and re-evaluates each descendant against the remaining patterns. It changes the outcome only when such a negation sits under a broader ignore that also covers the descendants, so it cannot occur at all without a negation. Closing it would mean owning the pattern-to-regex compiler rather than delegating to `pathspec`, which compiles every pattern as a prefix search and so cannot distinguish "this pattern matched this entry" from "it matched an ancestor".

The guarantee that makes that trade acceptable is the *direction* of the divergence: it always errs toward linting a file git would ignore, never toward silently skipping one, so it cannot hide a finding. Over-linting is visible and silenced with one `exclude` entry; under-linting is indistinguishable from a clean run. `test_gitignore_fuzz.py` asserts that direction on every generated combination, and measures the rate against three declared pattern distributions:

| `.gitignore` content | divergence rate | skips a file git lints |
|---|---|---|
| plain names and globs, no negation | 0.00% (0/1500) | never |
| the same, 5% of patterns negated | 0.33% (5/1500) | never |
| corner-hunting pool, 30% negated | 1.47% (22/1500) | never |

A rate is meaningless without the distribution that produced it, which is why all three are declared in the test rather than summarised as one number. Regenerate them there and update this table in the same change.

## Caching

There is no TOML key for caching — it's controlled entirely by CLI flags, since it's a run-to-run performance concern rather than a project convention.

Each file's result is cached under `<root>/.house-lint-cache/<version>-<source fingerprint>/`, flat and keyed by two hashes: the file's raw content, and the file's *effective* rule set for that run (`select`/`ignore`/`extend-select`/`extend-ignore`/`per-file-ignores` and CLI overrides already resolved, plus all three `HSL101`/`HSL102`/`HSL103` option tables, whether or not each of those rules is currently enabled — simpler than tracking which options are actually load-bearing, at the cost of some extra cache invalidation when an unused rule's options change). The running interpreter's major/minor version is always folded in as well, since `ast.parse` accepts different grammar across the Python versions house-lint supports (`type Alias = int` is a `SyntaxError` before 3.12) — without it, a cache shared between venvs could replay a stale `SyntaxError`, or a stale success, that the interpreter actually running the scan would not produce. Switching interpreters is therefore a full cache miss by design. The file's own name is folded in too whenever an enabled `HSL101` token family scopes to `"filenames"`, since that's the one detector whose output depends on the filename rather than purely the content. house-lint is a single-file analyzer with no cross-file dependencies, so this flat scheme is sufficient — there is no dependency graph to invalidate. A cache hit skips tokenization, parsing, and rule execution for that file entirely.

The directory name carries both house-lint's version and a hash of its own Python sources. The version alone would not be enough: it only moves when a release is cut, so editing a detector in a working checkout and re-running would replay the previous detector's results for every unchanged file. The source fingerprint is content-based, so a released install keeps exactly one cache directory across machines and fresh clones.

Superseded directories house-lint created are pruned so they do not accumulate — but only by a run that actually writes a cache entry. A scan where every file is a cache hit deletes nothing, which keeps the sweep from removing a namespace that a concurrent house-lint of a different version is still writing to.

house-lint writes a self-ignoring `.gitignore` into its own default `.house-lint-cache/` base so the cache stays invisible to `git status`. It never writes one into a `--cache-dir` you supply, since that directory may hold unrelated data — or be a project root, where a wildcard ignore would hide the whole project.

Only the root `.gitignore` is loaded. Built-in excludes are `.git/`, `.venv/`, `.nox/`, `__pycache__/`, `site-packages/`, and `node_modules/`; configured excludes are added afterwards. `--no-gitignore` disables only the root `.gitignore`.
`--no-cache` disables reading from the cache but still writes to it, keeping it warm for the next run — the same semantics as Ruff's `--no-cache`. `--cache-dir <path>` overrides the base directory (the namespace segment is still appended underneath it).
Comment thread
coderabbitai[bot] marked this conversation as resolved.

## Rule options

Expand Down
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ build-backend = "uv_build"

[tool.pytest.ini_options]
testpaths = ["tests"]
# No marker filtering: `pytest` runs the same set everywhere. The one suite too slow for a local
# run gates itself on the `CI` environment variable instead — see test_gitignore_fuzz.py.

[tool.ruff]
line-length = 100
Expand Down
Loading