Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
273 changes: 273 additions & 0 deletions src/house_lint/cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
"""Flat, version-namespaced per-file result cache.

House-lint is a single-file analyzer with no cross-file dependencies, so a flat cache keyed
by (file content hash, effective config hash) is semantically correct — unlike a dependency-
graph cache (e.g. mypy's `.mypy_cache`), there is no invalidation-graph to track. The cache
directory is namespaced by house-lint's own version, so an upgrade invalidates stale entries
automatically without an explicit migration step.

Cache entries are addressed purely by content and config hashes, not by file path — two files
with identical content and an identical effective rule set produce the same entry. Cached
findings and errors are therefore stored without their `path` field; `read_cached_result` takes
the caller-supplied `relative_path` of the file actually being scanned and re-attaches it to
each reconstructed finding/error.
"""

import hashlib
import json
import os
import shutil
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any

from house_lint import __version__
from house_lint.config import HSL101Options, HSL102Options, HSL103Options
from house_lint.results import Finding, LintError
from house_lint.source import MAX_SOURCE_BYTES, read_regular_file_bytes

CACHE_DIRNAME = ".house-lint-cache"


def default_cache_base(root: Path) -> Path:
"""Default cache base directory: `<root>/.house-lint-cache/` (before version-namespacing)."""
return root / CACHE_DIRNAME


def versioned_cache_dir(base: Path) -> Path:
"""Version-namespace a cache base directory, so an upgrade invalidates stale entries.

Applies uniformly to the default base and to a user-supplied `--cache-dir` override —
the override changes *where* the cache lives, not whether it's still safe across upgrades.
"""
return base / __version__
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated


def hash_file_content(path: Path) -> str | None:
"""Hash a file's raw bytes for cache-key purposes, or None if it can't be safely cached.

Reuses `SourceFile`'s nonblocking-read and regular-file guard (via
`read_regular_file_bytes`) so hashing can't stall on a raced FIFO. Any failure here just
means this file is treated as a cache miss for this run — `SourceFile`'s own loading still
runs the real scan and reports a proper `LintError` if warranted.
"""
try:
content = read_regular_file_bytes(path, max_bytes=MAX_SOURCE_BYTES)
except OSError:
return None
if content is None or len(content) > MAX_SOURCE_BYTES:
return None
return hashlib.sha256(content).hexdigest()


def hash_effective_config(
enabled_rules: tuple[str, ...],
hsl101: HSL101Options,
hsl102: HSL102Options,
hsl103: HSL103Options,
*,
filename: str,
python_version: tuple[int, int] | None = None,
) -> str:
"""Hash the config inputs that can change a file's scan outcome, given fixed content.

`enabled_rules` is the per-file effective set (after `per-file-ignores`, `extend-select`,
etc. have already resolved it), not the raw configured selection.

`filename` (the file's own basename, e.g. `path.name`) is folded in only when an enabled
HSL101 token family scopes to `"filenames"` — that's the one detector in this codebase whose
output depends on the file's name rather than purely its content, since it matches spec
tokens against the filename itself (see `_filename_candidates` in rules/spec_tokens.py).
Without this, two files with identical content but different names could otherwise collide
on the same cache entry and silently swap each other's filename-derived findings.

`python_version` (major, minor) defaults to the running interpreter's `sys.version_info[:2]`
and is always folded into the hash. `SourceFile._analyze` parses source with `ast.parse`,
whose accepted grammar differs across the Python versions this project supports (e.g. `type
Alias = int` is a `SyntaxError` before 3.12) — without this, a cache shared across venvs of
different Python versions could replay a stale `SyntaxError` (or a stale success) that no
longer matches the interpreter actually running the scan. Accepting it as a parameter (rather
than reading `sys.version_info` internally) keeps this directly testable.
"""
payload: dict[str, object] = {
"enabled_rules": sorted(enabled_rules),
"hsl101": asdict(hsl101),
"hsl102": asdict(hsl102),
"hsl103": asdict(hsl103),
Comment thread
NodeJSmith marked this conversation as resolved.
"python_version": list(
python_version if python_version is not None else sys.version_info[:2]
),
}
if "HSL101" in enabled_rules and any("filenames" in family.scopes for family in hsl101.tokens):
payload["filename"] = filename
return hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class CachedFileResult:
"""A cacheable per-file scan outcome — everything `FileScanResult` carries except `stop`.

`stop` (the process-boundary internal-error signal) is deliberately excluded: internal
errors are non-deterministic failures, not something a re-run with the same content and
config should replay from cache.
"""

findings: tuple[Finding, ...] = ()
errors: tuple[LintError, ...] = ()
suppressed_count: int = 0
files_scanned: int = 0


def _entry_path(cache_dir: Path, content_hash: str, config_hash: str) -> Path:
return cache_dir / f"{content_hash}-{config_hash}.json"


def _finding_to_payload(finding: Finding) -> dict[str, Any]:
data = finding.to_dict()
del data["path"]
return data


def _finding_from_payload(data: dict[str, Any], *, path: str) -> Finding:
return Finding(path=path, **data)


def _error_to_payload(err: LintError) -> dict[str, Any]:
data = err.to_dict()
del data["path"]
return data


def _error_from_payload(data: dict[str, Any], *, path: str) -> LintError:
return LintError(path=path, **data)


def read_cached_result(
cache_dir: Path, content_hash: str, config_hash: str, *, relative_path: str, debug: bool = False
) -> CachedFileResult | None:
"""Return the cached result for this (content, config) pair, or None on a miss.

A missing entry (the common case — nothing has cached this file/config pair yet) is a
silent miss. An entry that exists but can't be read or parsed is also treated as a miss —
a stale or corrupted cache entry must never fail a scan, only fall back to re-analyzing —
but that case is unusual enough to report under `--debug`, matching how other best-effort
I/O in this codebase (e.g. `scan_file`'s internal-error path) stays silent by default but
diagnosable on request.
"""
path = _entry_path(cache_dir, content_hash, config_hash)
try:
raw = path.read_text(encoding="utf-8")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Refuse symlinks when reading cache entries

When an untrusted checkout pre-creates the predictable default-cache entry as a symlink, this unbounded read_text() follows its target even though only the cache directory components were checked. A link to a FIFO can block a normal scan indefinitely, and a link to /dev/zero or another unbounded source can exhaust memory before corruption handling runs. Open entries with no-follow semantics and verify/bound the regular file before reading.

Useful? React with 👍 / 👎.

except FileNotFoundError:
return None
except OSError as exc:
if debug:
print(f"debug: cache read failed for {relative_path}: {exc}", file=sys.stderr)
return None
try:
payload = json.loads(raw)
return CachedFileResult(
findings=tuple(
_finding_from_payload(item, path=relative_path) for item in payload["findings"]
),
errors=tuple(
_error_from_payload(item, path=relative_path) for item in payload["errors"]
Comment thread
NodeJSmith marked this conversation as resolved.
),
suppressed_count=payload["suppressed_count"],
files_scanned=payload["files_scanned"],
Comment thread
NodeJSmith marked this conversation as resolved.
Outdated
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except (ValueError, KeyError, TypeError) as exc:
if debug:
print(f"debug: cache entry for {relative_path} is corrupted: {exc}", file=sys.stderr)
return None


def _write_self_ignore_marker(base: Path, *, debug: bool = False) -> None:
"""Write a `.gitignore` containing `*` into the cache *base* directory, once.

Mirrors how pytest/mypy self-ignore their own cache directories: a downstream project that
runs house-lint gets an untracked, `git status`-invisible `.house-lint-cache/` without having
to add it to their own `.gitignore` by hand. Written at `base` (the unversioned
`.house-lint-cache/` directory), not the version-namespaced subdirectory, since that's the
path a `git status` in the scanned repo would actually flag. Best-effort: a failed marker
write must never fail the scan.
"""
marker = base / ".gitignore"
try:
if not marker.exists():
marker.write_text("*\n", encoding="utf-8")
except OSError as exc:
if debug:
print(f"debug: cache self-ignore marker write failed: {exc}", file=sys.stderr)


def _prune_stale_version_dirs(cache_dir: Path, *, debug: bool = False) -> None:
"""Remove sibling version directories under `cache_dir`'s base, best-effort.

`versioned_cache_dir` namespaces the cache by `__version__` so an upgrade invalidates stale
entries, but nothing else ever deletes the old version's directory — left alone, upgrades
accumulate `<base>/<old-version>/`, `<base>/<older-version>/`, etc. forever. Run only here,
at the point a cache entry is actually about to be written (not on every read), so a plain
read-only invocation of this house-lint version never triggers deletion of another version's
directory. This narrows, but does not eliminate, the risk window: a *concurrent* process
actively writing under a different version during an overlapping run can still have its
directory removed by this call — best-effort here means "safe to fail," not "race-free."
"""
base = cache_dir.parent
try:
siblings = [child for child in base.iterdir() if child.is_dir() and child != cache_dir]
Comment thread
NodeJSmith marked this conversation as resolved.
except OSError:
return
for sibling in siblings:
try:
shutil.rmtree(sibling)
except OSError as exc:
if debug:
print(f"debug: cache prune of stale version dir failed: {exc}", file=sys.stderr)


def write_cached_result(
cache_dir: Path,
content_hash: str,
config_hash: str,
result: CachedFileResult,
*,
debug: bool = False,
) -> None:
"""Write a cache entry, best-effort. A failed write must never fail the scan itself —
but is reported under `--debug`, so `house-lint check --debug` can diagnose "why isn't
caching working" for a broken cache directory or permissions issue.

Writes atomically (temp file + `os.replace`) so an interrupted process or two concurrent
house-lint runs writing the same entry can never leave a partially-written, corrupted file
in place of a real one.
"""
path = _entry_path(cache_dir, content_hash, config_hash)
payload = {
"findings": [_finding_to_payload(finding) for finding in result.findings],
"errors": [_error_to_payload(err) for err in result.errors],
"suppressed_count": result.suppressed_count,
"files_scanned": result.files_scanned,
}
try:
cache_dir.mkdir(parents=True, exist_ok=True)
_prune_stale_version_dirs(cache_dir, debug=debug)
_write_self_ignore_marker(cache_dir.parent, debug=debug)
Comment thread
NodeJSmith marked this conversation as resolved.
Outdated
temporary = path.with_name(f"{path.name}.{os.getpid()}.tmp")
temporary.write_text(json.dumps(payload), encoding="utf-8")
os.replace(temporary, path)
except OSError as exc:
if debug:
print(f"debug: cache write failed: {exc}", file=sys.stderr)


__all__ = [
"CACHE_DIRNAME",
"CachedFileResult",
"default_cache_base",
"hash_effective_config",
"hash_file_content",
"read_cached_result",
"versioned_cache_dir",
"write_cached_result",
]
Loading