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.
@@ -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,6 @@
1
+ """Module entry point equivalent to the house-lint console script."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -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
+ )