house-lint 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- house_lint-0.1.1/LICENSE +21 -0
- house_lint-0.1.1/PKG-INFO +209 -0
- house_lint-0.1.1/README.md +187 -0
- house_lint-0.1.1/pyproject.toml +60 -0
- house_lint-0.1.1/src/house_lint/__init__.py +9 -0
- house_lint-0.1.1/src/house_lint/__main__.py +6 -0
- house_lint-0.1.1/src/house_lint/analysis.py +161 -0
- house_lint-0.1.1/src/house_lint/cli.py +406 -0
- house_lint-0.1.1/src/house_lint/config.py +297 -0
- house_lint-0.1.1/src/house_lint/discovery.py +349 -0
- house_lint-0.1.1/src/house_lint/registry.py +163 -0
- house_lint-0.1.1/src/house_lint/reporters/__init__.py +6 -0
- house_lint-0.1.1/src/house_lint/reporters/json.py +15 -0
- house_lint-0.1.1/src/house_lint/reporters/text.py +37 -0
- house_lint-0.1.1/src/house_lint/results.py +154 -0
- house_lint-0.1.1/src/house_lint/rules/__init__.py +1 -0
- house_lint-0.1.1/src/house_lint/rules/constants_position.py +99 -0
- house_lint-0.1.1/src/house_lint/rules/exception_names.py +45 -0
- house_lint-0.1.1/src/house_lint/rules/file_length.py +36 -0
- house_lint-0.1.1/src/house_lint/rules/lazy_imports.py +52 -0
- house_lint-0.1.1/src/house_lint/rules/llm_cruft.py +80 -0
- house_lint-0.1.1/src/house_lint/rules/spec_tokens.py +151 -0
- house_lint-0.1.1/src/house_lint/rules/type_checking_position.py +40 -0
- house_lint-0.1.1/src/house_lint/source.py +261 -0
- house_lint-0.1.1/src/house_lint/suppressions.py +309 -0
house_lint-0.1.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jessica
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: house-lint
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Jessica's opinionated Python house-style linter
|
|
5
|
+
Keywords: lint,python,house-style,pre-commit
|
|
6
|
+
Author: Jessica
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
18
|
+
Requires-Dist: cyclopts>=3.0
|
|
19
|
+
Requires-Dist: pathspec>=0.12
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# house-lint
|
|
24
|
+
|
|
25
|
+
> Opinionated Python house-style linter for comments, imports, module layout, and deliberately configured project conventions.
|
|
26
|
+
|
|
27
|
+
`house-lint` packages a specific set of checks used across a handful of Python projects. It is intentionally not a general-purpose style framework, an LLM-authorship detector, or a replacement for Ruff.
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
You need Python 3.11 or later.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
uvx house-lint check
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
You see the selected root, configuration, enabled rules, file counts, findings, and a final summary. The default scan looks for `src`, `tests`, `scripts`, `tools`, and `examples` under the project root.
|
|
38
|
+
|
|
39
|
+
Install it in a project when you want a pinned development dependency:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
uv add --dev house-lint
|
|
43
|
+
uv run house-lint check
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Rules
|
|
47
|
+
|
|
48
|
+
The four default rules are `HSL001`–`HSL004`. `HSL900` always reports invalid suppressions.
|
|
49
|
+
|
|
50
|
+
| ID | Default | Checks |
|
|
51
|
+
| --- | --- | --- |
|
|
52
|
+
| `HSL001` | On | AI-writing tells in comments and docstrings, never ordinary strings |
|
|
53
|
+
| `HSL002` | On | Imports inside function or async-function bodies |
|
|
54
|
+
| `HSL003` | On | Top-level `TYPE_CHECKING` guards followed by imports |
|
|
55
|
+
| `HSL004` | On | Module constants after the first class/function, except derived bindings |
|
|
56
|
+
| `HSL101` | Off | Configured planning/spec tokens in comments, docstrings, and filenames |
|
|
57
|
+
| `HSL102` | Off | Files whose `splitlines()` count exceeds a configured limit |
|
|
58
|
+
| `HSL103` | Off | Bound exception names outside the allowed policy |
|
|
59
|
+
| `HSL900` | Always | Invalid, unused, conflicting, or malformed suppressions |
|
|
60
|
+
|
|
61
|
+
List the installed rule metadata:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
house-lint rules --format json
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The command writes a schema-versioned JSON object containing all eight IDs and their enablement modes.
|
|
68
|
+
|
|
69
|
+
See [the rule reference](docs/rules.md) for exact rule behavior.
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
Put configuration in your project `pyproject.toml`:
|
|
74
|
+
|
|
75
|
+
```toml
|
|
76
|
+
[tool.house-lint]
|
|
77
|
+
include = ["src", "tests"]
|
|
78
|
+
exclude = ["generated/"]
|
|
79
|
+
select = ["HSL001", "HSL002", "HSL003", "HSL004", "HSL101", "HSL102", "HSL103"]
|
|
80
|
+
|
|
81
|
+
[tool.house-lint.rules.HSL102]
|
|
82
|
+
max_lines = 800
|
|
83
|
+
|
|
84
|
+
[tool.house-lint.rules.HSL103]
|
|
85
|
+
allowed = ["exc", "*_exc"]
|
|
86
|
+
|
|
87
|
+
[[tool.house-lint.rules.HSL101.tokens]]
|
|
88
|
+
prefixes = ["AC", "FR", "NFR", "WP"]
|
|
89
|
+
hash = "optional"
|
|
90
|
+
min_digits = 1
|
|
91
|
+
suffix = "optional-lower-alpha"
|
|
92
|
+
scopes = ["comments", "docstrings", "filenames"]
|
|
93
|
+
case_sensitive = true
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`HSL101` requires at least one token family whenever you select it. `HSL102` defaults to `800` lines and `HSL103` defaults to `exc` or `*_exc` when selected.
|
|
97
|
+
|
|
98
|
+
CLI selection overrides configuration:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
house-lint check --select HSL002,HSL103 --ignore HSL103
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Each `--select` or `--ignore` occurrence accepts one comma-separated list. Selection is strict: unknown, duplicate, empty, and `HSL900` IDs are usage errors.
|
|
105
|
+
|
|
106
|
+
Read [configuration](docs/configuration.md) for discovery, precedence, validation, excludes, and token-family options.
|
|
107
|
+
|
|
108
|
+
## Paths, roots, and Git ignores
|
|
109
|
+
|
|
110
|
+
With no paths, `check` scans configured include roots. With paths, it scans only those explicit Python files or recursively expanded explicit directories:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
house-lint check src/service.py tests
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
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.
|
|
117
|
+
|
|
118
|
+
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`.
|
|
119
|
+
|
|
120
|
+
## Suppressions
|
|
121
|
+
|
|
122
|
+
Suppress a finding only with a rule ID and a meaningful reason (at least three alphanumeric characters):
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
def load_plugin():
|
|
126
|
+
import plugin # house-lint: ignore[HSL002] - avoids a circular import
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# house-lint: ignore-next[HSL103] - compatibility callback signature
|
|
130
|
+
try:
|
|
131
|
+
callback()
|
|
132
|
+
except OSError as error:
|
|
133
|
+
raise
|
|
134
|
+
|
|
135
|
+
# house-lint: ignore-file[HSL102] - generated compatibility module
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`ignore` attaches to its containing statement. A comment-only `ignore-next` attaches to the next statement in the same suite, even across ordinary comments and blank lines. A top-of-file `ignore-file` applies to the listed enabled rules throughout the file. `HSL900` cannot be suppressed.
|
|
139
|
+
|
|
140
|
+
Read [suppressions](docs/suppressions.md) before adding one; malformed, misplaced, disabled, unknown, unused, duplicate, and conflicting pragmas produce `HSL900`.
|
|
141
|
+
|
|
142
|
+
## Pre-commit
|
|
143
|
+
|
|
144
|
+
Run the installed CLI from a local pre-commit hook:
|
|
145
|
+
|
|
146
|
+
```yaml
|
|
147
|
+
repos:
|
|
148
|
+
- repo: local
|
|
149
|
+
hooks:
|
|
150
|
+
- id: house-lint
|
|
151
|
+
name: house-lint
|
|
152
|
+
entry: house-lint check
|
|
153
|
+
language: system
|
|
154
|
+
types: [python]
|
|
155
|
+
files: \.py$
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The distributed `.pre-commit-hooks.yaml` exposes the same `house-lint` hook for a published repository. Both forms filter to existing `*.py` files before invocation. The CLI remains strict, so manual explicit paths still fail for missing or non-Python files.
|
|
159
|
+
|
|
160
|
+
## Output and exits
|
|
161
|
+
|
|
162
|
+
Use JSON for machine consumers:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
house-lint check --format json
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
JSON stdout is always one parseable schema-version-1 object. It always includes `root`, `config`, `enabled_rules`, file counts, findings, errors, and summary counts. `root` and `config` are absolute strings when available and `null` otherwise; filename and file-level findings have all location fields set to `null`.
|
|
169
|
+
|
|
170
|
+
Finding `message` values are human-readable display text, not stable machine keys. Machine consumers should use rule IDs and locations for findings, and error `code` values for operational failures.
|
|
171
|
+
|
|
172
|
+
| Exit | Meaning |
|
|
173
|
+
| ---: | --- |
|
|
174
|
+
| 0 | Complete scan with no visible findings or errors |
|
|
175
|
+
| 1 | Complete scan with lint findings, including `HSL900` |
|
|
176
|
+
| 2 | CLI usage or configuration error; scanning did not start |
|
|
177
|
+
| 3 | Incomplete scan: path, traversal, budget, read, decode, tokenize, or syntax error |
|
|
178
|
+
| 4 | Unexpected internal error caught at the CLI boundary |
|
|
179
|
+
|
|
180
|
+
Exit precedence is `4 > 3 > 2 > 1 > 0`. In JSON mode, diagnostics stay in the JSON result on stdout; `--debug` writes additional details only to stderr.
|
|
181
|
+
|
|
182
|
+
`errors[*].code` is the stable machine-readable error taxonomy. `kind`, `phase`, and `operation` provide context and may gain new values without changing an existing error code.
|
|
183
|
+
|
|
184
|
+
| Code | Meaning |
|
|
185
|
+
| --- | --- |
|
|
186
|
+
| `config-error` | CLI argument or configuration loading failure |
|
|
187
|
+
| `path-error` | Invalid root, explicit path, or selected source path |
|
|
188
|
+
| `traversal-error` | Discovery or root `.gitignore` filesystem failure |
|
|
189
|
+
| `budget-error` | Discovery or candidate-count safety limit exceeded |
|
|
190
|
+
| `source-too-large` | A selected source file exceeds the 10 MiB read limit |
|
|
191
|
+
| `read-error` | A selected source file could not be read |
|
|
192
|
+
| `decode-error` | A selected source file could not be decoded |
|
|
193
|
+
| `tokenize-error` | A selected source file could not be tokenized |
|
|
194
|
+
| `syntax-error` | A selected source file could not be parsed as Python |
|
|
195
|
+
| `internal-error` | An unexpected failure crossed the CLI boundary |
|
|
196
|
+
|
|
197
|
+
## Development
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
uv run pytest
|
|
201
|
+
uv run ruff check .
|
|
202
|
+
uv run pyright
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
See [the changelog](CHANGELOG.md) for compatibility notes.
|
|
206
|
+
|
|
207
|
+
## Releases
|
|
208
|
+
|
|
209
|
+
CI tests Python 3.11 through 3.14 and runs Ruff, Pyright, and a package build. [Release Please](https://github.com/googleapis/release-please) manages version bumps, changelog updates, and GitHub releases from Conventional Commits. Distributions are published to PyPI via Trusted Publishing.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# house-lint
|
|
2
|
+
|
|
3
|
+
> Opinionated Python house-style linter for comments, imports, module layout, and deliberately configured project conventions.
|
|
4
|
+
|
|
5
|
+
`house-lint` packages a specific set of checks used across a handful of Python projects. It is intentionally not a general-purpose style framework, an LLM-authorship detector, or a replacement for Ruff.
|
|
6
|
+
|
|
7
|
+
## Quick start
|
|
8
|
+
|
|
9
|
+
You need Python 3.11 or later.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
uvx house-lint check
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
You see the selected root, configuration, enabled rules, file counts, findings, and a final summary. The default scan looks for `src`, `tests`, `scripts`, `tools`, and `examples` under the project root.
|
|
16
|
+
|
|
17
|
+
Install it in a project when you want a pinned development dependency:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
uv add --dev house-lint
|
|
21
|
+
uv run house-lint check
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Rules
|
|
25
|
+
|
|
26
|
+
The four default rules are `HSL001`–`HSL004`. `HSL900` always reports invalid suppressions.
|
|
27
|
+
|
|
28
|
+
| ID | Default | Checks |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `HSL001` | On | AI-writing tells in comments and docstrings, never ordinary strings |
|
|
31
|
+
| `HSL002` | On | Imports inside function or async-function bodies |
|
|
32
|
+
| `HSL003` | On | Top-level `TYPE_CHECKING` guards followed by imports |
|
|
33
|
+
| `HSL004` | On | Module constants after the first class/function, except derived bindings |
|
|
34
|
+
| `HSL101` | Off | Configured planning/spec tokens in comments, docstrings, and filenames |
|
|
35
|
+
| `HSL102` | Off | Files whose `splitlines()` count exceeds a configured limit |
|
|
36
|
+
| `HSL103` | Off | Bound exception names outside the allowed policy |
|
|
37
|
+
| `HSL900` | Always | Invalid, unused, conflicting, or malformed suppressions |
|
|
38
|
+
|
|
39
|
+
List the installed rule metadata:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
house-lint rules --format json
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The command writes a schema-versioned JSON object containing all eight IDs and their enablement modes.
|
|
46
|
+
|
|
47
|
+
See [the rule reference](docs/rules.md) for exact rule behavior.
|
|
48
|
+
|
|
49
|
+
## Configuration
|
|
50
|
+
|
|
51
|
+
Put configuration in your project `pyproject.toml`:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[tool.house-lint]
|
|
55
|
+
include = ["src", "tests"]
|
|
56
|
+
exclude = ["generated/"]
|
|
57
|
+
select = ["HSL001", "HSL002", "HSL003", "HSL004", "HSL101", "HSL102", "HSL103"]
|
|
58
|
+
|
|
59
|
+
[tool.house-lint.rules.HSL102]
|
|
60
|
+
max_lines = 800
|
|
61
|
+
|
|
62
|
+
[tool.house-lint.rules.HSL103]
|
|
63
|
+
allowed = ["exc", "*_exc"]
|
|
64
|
+
|
|
65
|
+
[[tool.house-lint.rules.HSL101.tokens]]
|
|
66
|
+
prefixes = ["AC", "FR", "NFR", "WP"]
|
|
67
|
+
hash = "optional"
|
|
68
|
+
min_digits = 1
|
|
69
|
+
suffix = "optional-lower-alpha"
|
|
70
|
+
scopes = ["comments", "docstrings", "filenames"]
|
|
71
|
+
case_sensitive = true
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
`HSL101` requires at least one token family whenever you select it. `HSL102` defaults to `800` lines and `HSL103` defaults to `exc` or `*_exc` when selected.
|
|
75
|
+
|
|
76
|
+
CLI selection overrides configuration:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
house-lint check --select HSL002,HSL103 --ignore HSL103
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Each `--select` or `--ignore` occurrence accepts one comma-separated list. Selection is strict: unknown, duplicate, empty, and `HSL900` IDs are usage errors.
|
|
83
|
+
|
|
84
|
+
Read [configuration](docs/configuration.md) for discovery, precedence, validation, excludes, and token-family options.
|
|
85
|
+
|
|
86
|
+
## Paths, roots, and Git ignores
|
|
87
|
+
|
|
88
|
+
With no paths, `check` scans configured include roots. With paths, it scans only those explicit Python files or recursively expanded explicit directories:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
house-lint check src/service.py tests
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
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.
|
|
95
|
+
|
|
96
|
+
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`.
|
|
97
|
+
|
|
98
|
+
## Suppressions
|
|
99
|
+
|
|
100
|
+
Suppress a finding only with a rule ID and a meaningful reason (at least three alphanumeric characters):
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
def load_plugin():
|
|
104
|
+
import plugin # house-lint: ignore[HSL002] - avoids a circular import
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# house-lint: ignore-next[HSL103] - compatibility callback signature
|
|
108
|
+
try:
|
|
109
|
+
callback()
|
|
110
|
+
except OSError as error:
|
|
111
|
+
raise
|
|
112
|
+
|
|
113
|
+
# house-lint: ignore-file[HSL102] - generated compatibility module
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`ignore` attaches to its containing statement. A comment-only `ignore-next` attaches to the next statement in the same suite, even across ordinary comments and blank lines. A top-of-file `ignore-file` applies to the listed enabled rules throughout the file. `HSL900` cannot be suppressed.
|
|
117
|
+
|
|
118
|
+
Read [suppressions](docs/suppressions.md) before adding one; malformed, misplaced, disabled, unknown, unused, duplicate, and conflicting pragmas produce `HSL900`.
|
|
119
|
+
|
|
120
|
+
## Pre-commit
|
|
121
|
+
|
|
122
|
+
Run the installed CLI from a local pre-commit hook:
|
|
123
|
+
|
|
124
|
+
```yaml
|
|
125
|
+
repos:
|
|
126
|
+
- repo: local
|
|
127
|
+
hooks:
|
|
128
|
+
- id: house-lint
|
|
129
|
+
name: house-lint
|
|
130
|
+
entry: house-lint check
|
|
131
|
+
language: system
|
|
132
|
+
types: [python]
|
|
133
|
+
files: \.py$
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The distributed `.pre-commit-hooks.yaml` exposes the same `house-lint` hook for a published repository. Both forms filter to existing `*.py` files before invocation. The CLI remains strict, so manual explicit paths still fail for missing or non-Python files.
|
|
137
|
+
|
|
138
|
+
## Output and exits
|
|
139
|
+
|
|
140
|
+
Use JSON for machine consumers:
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
house-lint check --format json
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
JSON stdout is always one parseable schema-version-1 object. It always includes `root`, `config`, `enabled_rules`, file counts, findings, errors, and summary counts. `root` and `config` are absolute strings when available and `null` otherwise; filename and file-level findings have all location fields set to `null`.
|
|
147
|
+
|
|
148
|
+
Finding `message` values are human-readable display text, not stable machine keys. Machine consumers should use rule IDs and locations for findings, and error `code` values for operational failures.
|
|
149
|
+
|
|
150
|
+
| Exit | Meaning |
|
|
151
|
+
| ---: | --- |
|
|
152
|
+
| 0 | Complete scan with no visible findings or errors |
|
|
153
|
+
| 1 | Complete scan with lint findings, including `HSL900` |
|
|
154
|
+
| 2 | CLI usage or configuration error; scanning did not start |
|
|
155
|
+
| 3 | Incomplete scan: path, traversal, budget, read, decode, tokenize, or syntax error |
|
|
156
|
+
| 4 | Unexpected internal error caught at the CLI boundary |
|
|
157
|
+
|
|
158
|
+
Exit precedence is `4 > 3 > 2 > 1 > 0`. In JSON mode, diagnostics stay in the JSON result on stdout; `--debug` writes additional details only to stderr.
|
|
159
|
+
|
|
160
|
+
`errors[*].code` is the stable machine-readable error taxonomy. `kind`, `phase`, and `operation` provide context and may gain new values without changing an existing error code.
|
|
161
|
+
|
|
162
|
+
| Code | Meaning |
|
|
163
|
+
| --- | --- |
|
|
164
|
+
| `config-error` | CLI argument or configuration loading failure |
|
|
165
|
+
| `path-error` | Invalid root, explicit path, or selected source path |
|
|
166
|
+
| `traversal-error` | Discovery or root `.gitignore` filesystem failure |
|
|
167
|
+
| `budget-error` | Discovery or candidate-count safety limit exceeded |
|
|
168
|
+
| `source-too-large` | A selected source file exceeds the 10 MiB read limit |
|
|
169
|
+
| `read-error` | A selected source file could not be read |
|
|
170
|
+
| `decode-error` | A selected source file could not be decoded |
|
|
171
|
+
| `tokenize-error` | A selected source file could not be tokenized |
|
|
172
|
+
| `syntax-error` | A selected source file could not be parsed as Python |
|
|
173
|
+
| `internal-error` | An unexpected failure crossed the CLI boundary |
|
|
174
|
+
|
|
175
|
+
## Development
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
uv run pytest
|
|
179
|
+
uv run ruff check .
|
|
180
|
+
uv run pyright
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
See [the changelog](CHANGELOG.md) for compatibility notes.
|
|
184
|
+
|
|
185
|
+
## Releases
|
|
186
|
+
|
|
187
|
+
CI tests Python 3.11 through 3.14 and runs Ruff, Pyright, and a package build. [Release Please](https://github.com/googleapis/release-please) manages version bumps, changelog updates, and GitHub releases from Conventional Commits. Distributions are published to PyPI via Trusted Publishing.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "house-lint"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Jessica's opinionated Python house-style linter"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [{ name = "Jessica" }]
|
|
10
|
+
keywords = ["lint", "python", "house-style", "pre-commit"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Development Status :: 3 - Alpha",
|
|
13
|
+
"Environment :: Console",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Topic :: Software Development :: Quality Assurance",
|
|
21
|
+
]
|
|
22
|
+
dependencies = ["cyclopts>=3.0", "pathspec>=0.12"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
house-lint = "house_lint.cli:main"
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"pytest>=8.0",
|
|
30
|
+
"ruff>=0.9",
|
|
31
|
+
"pyright>=1.1",
|
|
32
|
+
"flake8>=7.0",
|
|
33
|
+
"flake8-async>=24.0",
|
|
34
|
+
"Flake8-pyproject>=1.2",
|
|
35
|
+
"pyyaml>=6.0",
|
|
36
|
+
"typos>=1.0",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[build-system]
|
|
40
|
+
requires = ["uv_build>=0.8.0,<0.9"]
|
|
41
|
+
build-backend = "uv_build"
|
|
42
|
+
|
|
43
|
+
[tool.pytest.ini_options]
|
|
44
|
+
testpaths = ["tests"]
|
|
45
|
+
|
|
46
|
+
[tool.ruff]
|
|
47
|
+
line-length = 100
|
|
48
|
+
target-version = "py311"
|
|
49
|
+
exclude = ["design"]
|
|
50
|
+
|
|
51
|
+
[tool.ruff.format]
|
|
52
|
+
exclude = ["design"]
|
|
53
|
+
|
|
54
|
+
[tool.pyright]
|
|
55
|
+
include = ["src"]
|
|
56
|
+
typeCheckingMode = "strict"
|
|
57
|
+
|
|
58
|
+
[tool.flake8]
|
|
59
|
+
exclude = [".venv", ".git"]
|
|
60
|
+
max-line-length = 100
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Public package boundary for house-lint."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("house-lint")
|
|
7
|
+
except PackageNotFoundError:
|
|
8
|
+
# Source checkouts have no installed distribution metadata.
|
|
9
|
+
__version__ = "0.1.1"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Private detector provenance models.
|
|
2
|
+
|
|
3
|
+
These types intentionally do not form part of the reporter/configuration API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import ast
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import Enum
|
|
9
|
+
|
|
10
|
+
from .source import SourceFile
|
|
11
|
+
|
|
12
|
+
MAX_CANDIDATES_PER_FILE = 10_000
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SourceKind(Enum):
|
|
16
|
+
STATEMENT = "statement"
|
|
17
|
+
FILE = "file"
|
|
18
|
+
FILENAME = "filename"
|
|
19
|
+
NO_OWNER = "no-owner"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class StatementKey:
|
|
24
|
+
start_line: int
|
|
25
|
+
start_column: int
|
|
26
|
+
end_line: int
|
|
27
|
+
end_column: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class CandidateFinding:
|
|
32
|
+
rule_id: str
|
|
33
|
+
path: str
|
|
34
|
+
message: str
|
|
35
|
+
line: int | None
|
|
36
|
+
column: int | None
|
|
37
|
+
end_line: int | None
|
|
38
|
+
end_column: int | None
|
|
39
|
+
source_kind: SourceKind
|
|
40
|
+
owner: StatementKey | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class CandidateBudgetExceeded(RuntimeError):
|
|
44
|
+
"""Raised when one file produces more candidates than the fixed safety limit."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, path: str, *, candidates: tuple[CandidateFinding, ...] = ()) -> None:
|
|
47
|
+
self.path = path
|
|
48
|
+
self.limit = MAX_CANDIDATES_PER_FILE
|
|
49
|
+
self.candidates = candidates
|
|
50
|
+
super().__init__(f"candidate limit exceeded for {path}: {self.limit}")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def append_candidate(
|
|
54
|
+
candidates: list[CandidateFinding],
|
|
55
|
+
candidate: CandidateFinding,
|
|
56
|
+
source: SourceFile,
|
|
57
|
+
limit: int | None,
|
|
58
|
+
) -> None:
|
|
59
|
+
"""Append a candidate without exceeding the per-detector capacity."""
|
|
60
|
+
if limit is not None and len(candidates) >= limit:
|
|
61
|
+
raise CandidateBudgetExceeded(source.relative_path, candidates=tuple(candidates))
|
|
62
|
+
candidates.append(candidate)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def statement_key(statement: ast.stmt) -> StatementKey:
|
|
66
|
+
return StatementKey(
|
|
67
|
+
statement.lineno,
|
|
68
|
+
statement.col_offset + 1,
|
|
69
|
+
statement.end_lineno or statement.lineno,
|
|
70
|
+
(statement.end_col_offset or statement.col_offset) + 1,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def candidate_for_statement(
|
|
75
|
+
source: SourceFile, rule_id: str, message: str, statement: ast.stmt
|
|
76
|
+
) -> CandidateFinding:
|
|
77
|
+
"""Build a candidate owned by an AST statement."""
|
|
78
|
+
owner = statement_key(statement)
|
|
79
|
+
return CandidateFinding(
|
|
80
|
+
rule_id,
|
|
81
|
+
source.relative_path,
|
|
82
|
+
message,
|
|
83
|
+
owner.start_line,
|
|
84
|
+
owner.start_column,
|
|
85
|
+
owner.end_line,
|
|
86
|
+
owner.end_column,
|
|
87
|
+
SourceKind.STATEMENT,
|
|
88
|
+
owner,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def candidate_for_line(
|
|
93
|
+
source: SourceFile,
|
|
94
|
+
rule_id: str,
|
|
95
|
+
message: str,
|
|
96
|
+
line: int,
|
|
97
|
+
owner: ast.stmt | None = None,
|
|
98
|
+
) -> CandidateFinding:
|
|
99
|
+
"""Build a line candidate, retaining no-owner provenance when appropriate."""
|
|
100
|
+
statement_owner = statement_key(owner) if owner is not None else None
|
|
101
|
+
return CandidateFinding(
|
|
102
|
+
rule_id,
|
|
103
|
+
source.relative_path,
|
|
104
|
+
message,
|
|
105
|
+
line,
|
|
106
|
+
1,
|
|
107
|
+
line,
|
|
108
|
+
len(source.lines[line - 1]) + 1,
|
|
109
|
+
SourceKind.STATEMENT if statement_owner is not None else SourceKind.NO_OWNER,
|
|
110
|
+
statement_owner,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def statement_owner_for_line(
|
|
115
|
+
source: SourceFile, line: int, column: int | None = None
|
|
116
|
+
) -> ast.stmt | None:
|
|
117
|
+
"""Return a comment's owner: innermost for trailing text, outermost at line ends."""
|
|
118
|
+
if column is not None and source.lines[line - 1][:column].strip():
|
|
119
|
+
candidates = [
|
|
120
|
+
statement
|
|
121
|
+
for statement in source.statements
|
|
122
|
+
if statement.lineno <= line <= (statement.end_lineno or statement.lineno)
|
|
123
|
+
]
|
|
124
|
+
else:
|
|
125
|
+
candidates = [
|
|
126
|
+
statement
|
|
127
|
+
for statement in source.statements
|
|
128
|
+
if statement.lineno == line or (statement.end_lineno or statement.lineno) == line
|
|
129
|
+
]
|
|
130
|
+
if not candidates:
|
|
131
|
+
return None
|
|
132
|
+
if column is not None:
|
|
133
|
+
return max(candidates, key=lambda statement: (statement.lineno, statement.col_offset))
|
|
134
|
+
return min(candidates, key=statement_span)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def comment_owner_for_line(source: SourceFile, line: int, comment: str) -> ast.stmt | None:
|
|
138
|
+
"""Return the statement syntactically attached to this comment token."""
|
|
139
|
+
return statement_owner_for_line(source, line, source.lines[line - 1].index(comment))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def docstring_owner_for_line(source: SourceFile, line: int) -> ast.stmt:
|
|
143
|
+
"""Return the string-expression statement containing a docstring line."""
|
|
144
|
+
for statement in source.statements:
|
|
145
|
+
if (
|
|
146
|
+
isinstance(statement, ast.Expr)
|
|
147
|
+
and isinstance(statement.value, ast.Constant)
|
|
148
|
+
and isinstance(statement.value.value, str)
|
|
149
|
+
and statement.lineno <= line <= (statement.end_lineno or statement.lineno)
|
|
150
|
+
):
|
|
151
|
+
return statement
|
|
152
|
+
raise RuntimeError("docstring line has no string-expression statement")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def statement_span(statement: ast.stmt) -> tuple[int, int, int, int]:
|
|
156
|
+
return (
|
|
157
|
+
(statement.end_lineno or statement.lineno) - statement.lineno,
|
|
158
|
+
(statement.end_col_offset or statement.col_offset) - statement.col_offset,
|
|
159
|
+
statement.lineno,
|
|
160
|
+
statement.col_offset,
|
|
161
|
+
)
|