Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
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/
23 changes: 22 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,13 @@ 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/<house-lint version>/` (gitignored by default), keyed by the file's content and its effective rule set for that file. A cache hit skips tokenization, parsing, and rule execution entirely for that file; an upgrade to a new house-lint version starts from an empty cache automatically, since the version is part of the cache path.

`--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 version-namespaced underneath the path you give it).

## Suppressions

Expand Down
30 changes: 27 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,43 @@ 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.

## 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/<house-lint version>/` (gitignored by default), 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 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; a version upgrade starts from an empty cache automatically, since the version is part of the cache path.

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 and mypy's `--no-cache`/`--no-incremental`. `--cache-dir <path>` overrides the base directory (the version segment is still appended underneath it).

## Rule options

Expand Down
Loading