Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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/
22 changes: 20 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,28 @@ 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. Two divergences are known, both from
`pathspec` deciding directory-only patterns from pattern text rather than from a real `is_dir`:
one over-lints, one **under**-lints (hides findings). Do not assume the old "always errs toward
over-linting" guarantee — it was false and has been removed. See `docs/configuration.md` and
`design/research/2026-08-20-gitignore-style-exclusion-inclusion/`.
- 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
Loading