sergey-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,122 @@
1
+ Metadata-Version: 2.4
2
+ Name: sergey-lint
3
+ Version: 0.1.1
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: pygls>=2.0.1
8
+ Requires-Dist: typer>=0.24.1
9
+
10
+ # sergey
11
+
12
+ A Python linter with opinionated rules about import style, naming, and code structure. Runs as a CLI tool or as an LSP server for editor integration.
13
+
14
+ The primary intent of Sergey is to enforce my personal stylistic rules upon agentic code.
15
+ However, you may also find these useful in standard development. Simultaneously, it is a testing
16
+ space for me for agentic coding.
17
+
18
+ ## Installation
19
+
20
+ ```bash
21
+ uv add sergey
22
+ ```
23
+
24
+ ## Usage
25
+
26
+ ### CLI
27
+
28
+ ```bash
29
+ sergey check path/to/file.py # check a single file
30
+ sergey check src/ tests/ # check directories (recursive)
31
+ sergey check . # check the whole project
32
+ ```
33
+
34
+ Exits with code `0` if no violations are found, `1` if any are found.
35
+
36
+ ### LSP server
37
+
38
+ ```bash
39
+ sergey serve
40
+ ```
41
+
42
+ Communicates over stdio using the Language Server Protocol. Configure your editor to launch this command as a language server for Python files.
43
+
44
+ ## Rules
45
+
46
+ ### Imports
47
+
48
+ | Rule | Description |
49
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
50
+ | **IMP001** | `from module import name` is disallowed when `name` is not itself a submodule. Use `import module` and reference `module.name` at call sites. Typing modules, `__future__`, and `collections.abc` are exempt (see IMP002 and IMP004). |
51
+ | **IMP002** | `import typing` and `import typing_extensions` are disallowed. Use `from typing import X` and `from typing_extensions import X` to import names directly. |
52
+ | **IMP003** | Dotted plain imports (`import os.path`) are disallowed. Use `from os import path` instead. `collections.abc` is exempt (see IMP004). |
53
+ | **IMP004** | `import collections.abc` is disallowed. Use `from collections.abc import X` to import names directly. |
54
+
55
+ The four rules together enforce a consistent import style: every name you use is either a bare module you imported at the top level, or a submodule you accessed via `from package import submodule`.
56
+
57
+ ### Naming
58
+
59
+ | Rule | Description |
60
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
61
+ | **NAM001** | Functions annotated `-> bool` must start with a predicate prefix: `is_`, `has_`, `can_`, `should_`, `will_`, `did_`, or `was_`. Dunder methods are exempt. Leading underscores on private helpers are ignored (`_is_valid` passes). |
62
+ | **NAM002** | Single-character variable names are disallowed in assignments, for-loops, comprehensions, with-statements, and walrus expressions. The conventional throwaway `_` is exempt. |
63
+ | **NAM003** | Single-character function and method parameter names are disallowed. Covers positional-only, regular, and keyword-only parameters. `_`, `*args`, and `**kwargs` are exempt. Lambda parameters are not checked. |
64
+
65
+ ### Documentation
66
+
67
+ | Rule | Description |
68
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
69
+ | **DOC001** | Functions that contain explicit `raise` statements must include a `Raises` section in their docstring. Bare re-raises (`raise` with no argument) are exempt. Raises inside nested functions or classes belong to those scopes and are not counted against the outer function. Functions with no docstring are not checked. Both Google style (`Raises:`) and NumPy style (`Raises` / `------`) are accepted. |
70
+
71
+ ### Pydantic
72
+
73
+ | Rule | Description |
74
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
75
+ | **PDT001** | Every `BaseModel` subclass must have `model_config = ConfigDict(frozen=...)` with `frozen` explicitly set. This forces a deliberate decision about mutability. Both `frozen=True` and `frozen=False` are accepted; omitting `frozen` or omitting `model_config` entirely is flagged. |
76
+ | **PDT002** | Frozen `BaseModel` subclasses (`frozen=True`) must not have fields annotated with mutable types such as `list`, `dict`, `set`, `deque`, etc. Use immutable alternatives (`tuple`, `frozenset`, …) instead. The check recurses into generic parameters and union syntax, so `Optional[list[str]]` and `str \| list[int]` are both caught. `ClassVar` annotations are exempt. |
77
+
78
+ ### Structure
79
+
80
+ | Rule | Description |
81
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | **STR002** | Control-flow blocks nested deeper than 4 levels are flagged. Counted constructs: `if`/`elif`/`else`, `for`, `while`, `with`, `try`, `match`. `elif` branches count at the same depth as their leading `if`. Function, class, and lambda definitions reset the counter, so nested functions are judged independently. |
83
+ | **STR003** | `try` bodies containing more than 4 statements are flagged. Statements are counted recursively (an `if` with branches contributes 1 plus all contained statements). Only the `try:` body is counted — `except` and `finally` blocks are not subject to this rule. Nested functions and classes reset the count. |
84
+ | **STR004** | List and set literals inside functions that are never mutated and are not part of the function output (`return`/`yield`) should use immutable alternatives: `tuple` instead of `[]` and `frozenset` instead of `{}`. Only plain literals are checked; constructor calls and comprehensions are not covered. |
85
+
86
+ ## Suppression
87
+
88
+ ### Suppress a single line
89
+
90
+ ```python
91
+ x = some_function() # sergey: noqa
92
+ x = some_function() # sergey: noqa: NAM002
93
+ x = some_function() # sergey: noqa: NAM002, IMP001
94
+ ```
95
+
96
+ ### Suppress an entire file
97
+
98
+ Place this comment anywhere in the file (position does not matter):
99
+
100
+ ```python
101
+ # sergey: disable-file
102
+ # sergey: disable-file: IMP001
103
+ # sergey: disable-file: IMP001, IMP002
104
+ ```
105
+
106
+ ## Development
107
+
108
+ ```bash
109
+ uv run ruff check . # lint
110
+ uv run ruff format . # format
111
+ uv run ty check # type check
112
+ uv run pytest # run tests
113
+ uv run sergey check . # run sergey on itself
114
+ ```
115
+
116
+ ### Adding a rule
117
+
118
+ 1. Create or extend a module in `sergey/rules/` with a class that subclasses `base.Rule` and implements `check(tree, source) -> list[Diagnostic]`.
119
+ 2. Register the rule in `sergey/rules/__init__.py` by adding an instance to `ALL_RULES`.
120
+ 3. Add tests in `tests/rules/`.
121
+
122
+ Rule IDs follow the pattern `CAT###` where `CAT` is a short category prefix (`IMP`, `NAM`, `STR`, …) and `###` is a three-digit number.
@@ -0,0 +1,113 @@
1
+ # sergey
2
+
3
+ A Python linter with opinionated rules about import style, naming, and code structure. Runs as a CLI tool or as an LSP server for editor integration.
4
+
5
+ The primary intent of Sergey is to enforce my personal stylistic rules upon agentic code.
6
+ However, you may also find these useful in standard development. Simultaneously, it is a testing
7
+ space for me for agentic coding.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ uv add sergey
13
+ ```
14
+
15
+ ## Usage
16
+
17
+ ### CLI
18
+
19
+ ```bash
20
+ sergey check path/to/file.py # check a single file
21
+ sergey check src/ tests/ # check directories (recursive)
22
+ sergey check . # check the whole project
23
+ ```
24
+
25
+ Exits with code `0` if no violations are found, `1` if any are found.
26
+
27
+ ### LSP server
28
+
29
+ ```bash
30
+ sergey serve
31
+ ```
32
+
33
+ Communicates over stdio using the Language Server Protocol. Configure your editor to launch this command as a language server for Python files.
34
+
35
+ ## Rules
36
+
37
+ ### Imports
38
+
39
+ | Rule | Description |
40
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
41
+ | **IMP001** | `from module import name` is disallowed when `name` is not itself a submodule. Use `import module` and reference `module.name` at call sites. Typing modules, `__future__`, and `collections.abc` are exempt (see IMP002 and IMP004). |
42
+ | **IMP002** | `import typing` and `import typing_extensions` are disallowed. Use `from typing import X` and `from typing_extensions import X` to import names directly. |
43
+ | **IMP003** | Dotted plain imports (`import os.path`) are disallowed. Use `from os import path` instead. `collections.abc` is exempt (see IMP004). |
44
+ | **IMP004** | `import collections.abc` is disallowed. Use `from collections.abc import X` to import names directly. |
45
+
46
+ The four rules together enforce a consistent import style: every name you use is either a bare module you imported at the top level, or a submodule you accessed via `from package import submodule`.
47
+
48
+ ### Naming
49
+
50
+ | Rule | Description |
51
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
52
+ | **NAM001** | Functions annotated `-> bool` must start with a predicate prefix: `is_`, `has_`, `can_`, `should_`, `will_`, `did_`, or `was_`. Dunder methods are exempt. Leading underscores on private helpers are ignored (`_is_valid` passes). |
53
+ | **NAM002** | Single-character variable names are disallowed in assignments, for-loops, comprehensions, with-statements, and walrus expressions. The conventional throwaway `_` is exempt. |
54
+ | **NAM003** | Single-character function and method parameter names are disallowed. Covers positional-only, regular, and keyword-only parameters. `_`, `*args`, and `**kwargs` are exempt. Lambda parameters are not checked. |
55
+
56
+ ### Documentation
57
+
58
+ | Rule | Description |
59
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
60
+ | **DOC001** | Functions that contain explicit `raise` statements must include a `Raises` section in their docstring. Bare re-raises (`raise` with no argument) are exempt. Raises inside nested functions or classes belong to those scopes and are not counted against the outer function. Functions with no docstring are not checked. Both Google style (`Raises:`) and NumPy style (`Raises` / `------`) are accepted. |
61
+
62
+ ### Pydantic
63
+
64
+ | Rule | Description |
65
+ | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
66
+ | **PDT001** | Every `BaseModel` subclass must have `model_config = ConfigDict(frozen=...)` with `frozen` explicitly set. This forces a deliberate decision about mutability. Both `frozen=True` and `frozen=False` are accepted; omitting `frozen` or omitting `model_config` entirely is flagged. |
67
+ | **PDT002** | Frozen `BaseModel` subclasses (`frozen=True`) must not have fields annotated with mutable types such as `list`, `dict`, `set`, `deque`, etc. Use immutable alternatives (`tuple`, `frozenset`, …) instead. The check recurses into generic parameters and union syntax, so `Optional[list[str]]` and `str \| list[int]` are both caught. `ClassVar` annotations are exempt. |
68
+
69
+ ### Structure
70
+
71
+ | Rule | Description |
72
+ | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
73
+ | **STR002** | Control-flow blocks nested deeper than 4 levels are flagged. Counted constructs: `if`/`elif`/`else`, `for`, `while`, `with`, `try`, `match`. `elif` branches count at the same depth as their leading `if`. Function, class, and lambda definitions reset the counter, so nested functions are judged independently. |
74
+ | **STR003** | `try` bodies containing more than 4 statements are flagged. Statements are counted recursively (an `if` with branches contributes 1 plus all contained statements). Only the `try:` body is counted — `except` and `finally` blocks are not subject to this rule. Nested functions and classes reset the count. |
75
+ | **STR004** | List and set literals inside functions that are never mutated and are not part of the function output (`return`/`yield`) should use immutable alternatives: `tuple` instead of `[]` and `frozenset` instead of `{}`. Only plain literals are checked; constructor calls and comprehensions are not covered. |
76
+
77
+ ## Suppression
78
+
79
+ ### Suppress a single line
80
+
81
+ ```python
82
+ x = some_function() # sergey: noqa
83
+ x = some_function() # sergey: noqa: NAM002
84
+ x = some_function() # sergey: noqa: NAM002, IMP001
85
+ ```
86
+
87
+ ### Suppress an entire file
88
+
89
+ Place this comment anywhere in the file (position does not matter):
90
+
91
+ ```python
92
+ # sergey: disable-file
93
+ # sergey: disable-file: IMP001
94
+ # sergey: disable-file: IMP001, IMP002
95
+ ```
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ uv run ruff check . # lint
101
+ uv run ruff format . # format
102
+ uv run ty check # type check
103
+ uv run pytest # run tests
104
+ uv run sergey check . # run sergey on itself
105
+ ```
106
+
107
+ ### Adding a rule
108
+
109
+ 1. Create or extend a module in `sergey/rules/` with a class that subclasses `base.Rule` and implements `check(tree, source) -> list[Diagnostic]`.
110
+ 2. Register the rule in `sergey/rules/__init__.py` by adding an instance to `ALL_RULES`.
111
+ 3. Add tests in `tests/rules/`.
112
+
113
+ Rule IDs follow the pattern `CAT###` where `CAT` is a short category prefix (`IMP`, `NAM`, `STR`, …) and `###` is a three-digit number.
@@ -0,0 +1,81 @@
1
+ [project]
2
+ name = "sergey-lint"
3
+ version = "0.1.1"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ dependencies = [
8
+ "pygls>=2.0.1",
9
+ "typer>=0.24.1",
10
+ ]
11
+
12
+ [project.scripts]
13
+ sergey = "sergey.__main__:main"
14
+
15
+ [tool.uv]
16
+ package = true
17
+
18
+ [tool.setuptools.packages.find]
19
+ include = ["sergey*"]
20
+
21
+ [dependency-groups]
22
+ dev = [
23
+ "prek>=0.3.3",
24
+ "pytest>=9.0.2",
25
+ "ruff>=0.15.2",
26
+ "ty>=0.0.18",
27
+ ]
28
+
29
+ [tool.ruff.lint]
30
+ preview = false
31
+ select = ["ALL"]
32
+ ignore = [
33
+ "PYI063", # Preview rule not correctly ignored.
34
+ "SIM300", # Can cause mypy issues in SQLAlchemy where statements.
35
+ # Recommended ignores by Astral https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules)
36
+ "W191", # Tab indentation
37
+ "E111", # Indentation with invalid multiple
38
+ "E114", # Indentation with invalid multiple comment
39
+ "E117", # Over indented
40
+ "D206", # Docstring tab indentation
41
+ "D300", # Triple single quotes
42
+ "Q000", # Bad quotes inline string
43
+ "Q001", # Bad quotes multiline string
44
+ "Q002", # Bad quotes docstring
45
+ "Q003", # Avoidable escaped quote
46
+ "COM812", # Missing trailing comma
47
+ "COM819", # Prohibited trailing comma
48
+ "ISC002", # Multi-line implicit string concatenation
49
+ ]
50
+ fixable = ["ALL"]
51
+ unfixable = []
52
+ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
53
+
54
+ [tool.ruff.lint.pydocstyle]
55
+ convention = "google"
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "tests/**/*.py" = [
59
+ "S101", # asserts should be used in pytest
60
+ "SLF001", # accessing private members in tests is fine
61
+ "INP001", # tests should not be a module
62
+ "ARG001", # tests can have unused arguments (fixtures with side-effects)
63
+ "D1", # docstrings not required on test classes/methods
64
+ "D2", # docstring formatting rules not relevant in tests
65
+ "PLR2004", # magic values in assertions are fine in tests
66
+ ]
67
+ "sergey/rules/*.py" = [
68
+ "ARG002", # `source` is part of the Rule.check() interface; not all rules use it
69
+ ]
70
+ ".claude/hooks/*.py" = [
71
+ "S603", # subprocess inputs are controlled (file paths from Claude Code)
72
+ "S607", # partial path for `uv` is intentional
73
+ ]
74
+ "local/**/*" = ["ALL"]
75
+
76
+ [tool.ruff.format]
77
+ preview = false
78
+ quote-style = "double"
79
+ indent-style = "space"
80
+ skip-magic-trailing-comma = false
81
+ line-ending = "auto"
@@ -0,0 +1 @@
1
+ """sergey: an opinionated Python LSP for LLM agent loops."""
@@ -0,0 +1,188 @@
1
+ """Entry point: sergey [check <path>... | serve]."""
2
+
3
+ import pathlib
4
+ from typing import Annotated, Final
5
+
6
+ import typer
7
+
8
+ from sergey.rules import base as rules_base
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ def _apply_fixes(source: str, diagnostics: list[rules_base.Diagnostic]) -> str:
14
+ """Return *source* with all fixable diagnostics applied.
15
+
16
+ Fixes are applied from bottom to top so that earlier offsets remain valid
17
+ after each replacement. When multiple diagnostics share the same range
18
+ (e.g. two dotted aliases on one import statement) only the first fix
19
+ encountered is applied — they are identical by construction.
20
+ """
21
+ # Deduplicate by range; preserve insertion order (already sorted top→bottom).
22
+ seen_ranges: set[tuple[int, int, int, int]] = set()
23
+ unique: list[rules_base.Diagnostic] = []
24
+ for diag in diagnostics:
25
+ if diag.fix is None:
26
+ continue
27
+ key = (diag.line, diag.col, diag.end_line, diag.end_col)
28
+ if key not in seen_ranges:
29
+ seen_ranges.add(key)
30
+ unique.append(diag)
31
+
32
+ # Apply bottom→top to keep earlier positions stable.
33
+ for diag in reversed(unique):
34
+ lines = source.splitlines(keepends=True)
35
+ # Build character offsets for start and end of the diagnostic range.
36
+ start = sum(len(lines[idx]) for idx in range(diag.line - 1)) + diag.col
37
+ end = sum(len(lines[idx]) for idx in range(diag.end_line - 1)) + diag.end_col
38
+ if diag.fix is None: # pragma: no cover
39
+ continue
40
+ source = source[:start] + diag.fix.replacement + source[end:]
41
+ return source
42
+
43
+
44
+ # Directories that are never interesting to analyse.
45
+ _SKIP_DIRS: Final[frozenset[str]] = frozenset(
46
+ {".venv", "venv", "__pycache__", ".git", "node_modules", "build", "dist", ".tox"}
47
+ )
48
+
49
+
50
+ def _collect_python_files(root: pathlib.Path) -> list[pathlib.Path]:
51
+ """Recursively find .py files under root, skipping non-source directories."""
52
+ return sorted(
53
+ py_file
54
+ for py_file in root.rglob("*.py")
55
+ if not any(part in _SKIP_DIRS for part in py_file.parts)
56
+ )
57
+
58
+
59
+ def _git_diff_python_files() -> list[pathlib.Path]:
60
+ """Return .py files changed relative to HEAD in the current git repository.
61
+
62
+ Returns an empty list when git is unavailable or the directory is not a
63
+ git repository.
64
+ """
65
+ import subprocess # noqa: PLC0415
66
+
67
+ try:
68
+ root_proc = subprocess.run(
69
+ ["git", "rev-parse", "--show-toplevel"], # noqa: S607
70
+ capture_output=True,
71
+ text=True,
72
+ check=False,
73
+ )
74
+ diff_proc = subprocess.run(
75
+ ["git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD"], # noqa: S607
76
+ capture_output=True,
77
+ text=True,
78
+ check=False,
79
+ )
80
+ except OSError:
81
+ return []
82
+ if root_proc.returncode != 0 or diff_proc.returncode != 0:
83
+ return []
84
+ git_root = pathlib.Path(root_proc.stdout.strip())
85
+ return [
86
+ git_root / line
87
+ for line in diff_proc.stdout.splitlines()
88
+ if line.endswith(".py")
89
+ ]
90
+
91
+
92
+ def _resolve_files(
93
+ paths: list[pathlib.Path] | None,
94
+ *,
95
+ diff: bool,
96
+ ) -> list[pathlib.Path]:
97
+ """Expand paths and optionally the git diff into a deduplicated .py file list."""
98
+ candidates: list[pathlib.Path] = []
99
+ if diff:
100
+ candidates.extend(_git_diff_python_files())
101
+ for raw_path in paths or []:
102
+ if raw_path.is_dir():
103
+ candidates.extend(_collect_python_files(raw_path))
104
+ else:
105
+ candidates.append(raw_path)
106
+ seen: set[pathlib.Path] = set()
107
+ unique: list[pathlib.Path] = []
108
+ for file_path in candidates:
109
+ resolved = file_path.resolve()
110
+ if resolved not in seen:
111
+ seen.add(resolved)
112
+ unique.append(file_path)
113
+ return unique
114
+
115
+
116
+ @app.command(no_args_is_help=True)
117
+ def check(
118
+ paths: Annotated[
119
+ list[pathlib.Path] | None,
120
+ typer.Argument(help="Files or directories to check."),
121
+ ] = None,
122
+ diff: Annotated[ # noqa: FBT002
123
+ bool,
124
+ typer.Option("--diff", help="Check .py files changed in the current git diff."),
125
+ ] = False,
126
+ fix: Annotated[ # noqa: FBT002
127
+ bool,
128
+ typer.Option("--fix", help="Apply auto-fixes for fixable violations."),
129
+ ] = False,
130
+ ) -> None:
131
+ """Check one or more files/directories for rule violations.
132
+
133
+ Raises:
134
+ typer.Exit: With code 1 if any unfixed violations remain.
135
+ """
136
+ from sergey import analyzer as sergey_analyzer # noqa: PLC0415
137
+ from sergey import config as sergey_config # noqa: PLC0415
138
+ from sergey import rules # noqa: PLC0415
139
+
140
+ python_files = _resolve_files(paths, diff=diff)
141
+ cfg = sergey_config.load_config()
142
+ active_rules = sergey_config.filter_rules(rules.ALL_RULES, cfg)
143
+ active_rules = sergey_config.configure_rules(active_rules, cfg)
144
+ analyzer = sergey_analyzer.Analyzer(rules=active_rules)
145
+ found_any = False
146
+
147
+ for file_path in python_files:
148
+ try:
149
+ source = file_path.read_text()
150
+ except OSError as e:
151
+ typer.echo(f"error: {e}", err=True)
152
+ continue
153
+
154
+ diagnostics = analyzer.analyze(source)
155
+
156
+ if fix:
157
+ fixed_source = _apply_fixes(source, diagnostics)
158
+ if fixed_source != source:
159
+ file_path.write_text(fixed_source)
160
+ # Re-analyze to report any remaining violations.
161
+ diagnostics = analyzer.analyze(fixed_source)
162
+
163
+ for diag in diagnostics:
164
+ typer.echo(
165
+ f"{file_path}:{diag.line}:{diag.col}: {diag.rule_id} {diag.message}"
166
+ )
167
+ if diagnostics:
168
+ found_any = True
169
+
170
+ if found_any:
171
+ raise typer.Exit(code=1)
172
+
173
+
174
+ @app.command(no_args_is_help=True)
175
+ def serve() -> None:
176
+ """Run the LSP server over stdio."""
177
+ from sergey import server # noqa: PLC0415
178
+
179
+ server.start()
180
+
181
+
182
+ def main() -> None:
183
+ """Dispatch to CLI check mode or LSP server mode."""
184
+ app()
185
+
186
+
187
+ if __name__ == "__main__":
188
+ main()
@@ -0,0 +1,111 @@
1
+ """Orchestrates rule execution against a parsed AST."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ from typing import TYPE_CHECKING, Final
8
+
9
+ if TYPE_CHECKING:
10
+ from sergey.rules import base
11
+
12
+ # Matches: # sergey: noqa (suppress all rules on this line)
13
+ # # sergey: noqa: IMP001 (suppress specific rules on this line)
14
+ _LINE_NOQA_PAT: Final = re.compile(
15
+ r"#\s*sergey:\s*noqa(?::\s*([A-Z0-9][A-Z0-9,\s]*))?",
16
+ re.IGNORECASE,
17
+ )
18
+
19
+ # Matches: # sergey: disable-file (suppress all rules in this file)
20
+ # # sergey: disable-file: IMP001 (suppress specific rules in this file)
21
+ _FILE_DISABLE_PAT: Final = re.compile(
22
+ r"#\s*sergey:\s*disable-file(?::\s*([A-Z0-9][A-Z0-9,\s]*))?",
23
+ re.IGNORECASE,
24
+ )
25
+
26
+
27
+ def _rule_ids(raw: str | None) -> frozenset[str] | None:
28
+ """Parse rule IDs from a suppression comment capture group.
29
+
30
+ Returns None to indicate all rules are suppressed, or a frozenset of
31
+ specific uppercased rule IDs.
32
+ """
33
+ if not raw or not raw.strip():
34
+ return None
35
+ ids = frozenset(part.strip().upper() for part in raw.split(",") if part.strip())
36
+ return ids or None
37
+
38
+
39
+ def _is_covered(suppressed: frozenset[str] | None, rule_id: str) -> bool:
40
+ """Return True if rule_id falls within the suppression set.
41
+
42
+ None means all rules are suppressed.
43
+ """
44
+ return suppressed is None or rule_id in suppressed
45
+
46
+
47
+ def _apply_suppressions(
48
+ diagnostics: list[base.Diagnostic],
49
+ source: str,
50
+ ) -> list[base.Diagnostic]:
51
+ """Remove diagnostics covered by inline sergey suppression comments."""
52
+ lines = source.splitlines()
53
+
54
+ file_sup_active = False
55
+ file_sup_rules: frozenset[str] | None = None
56
+ line_sups: dict[int, frozenset[str] | None] = {}
57
+
58
+ for lineno, line_text in enumerate(lines, start=1):
59
+ file_match = _FILE_DISABLE_PAT.search(line_text)
60
+ if file_match:
61
+ file_sup_active = True
62
+ file_sup_rules = _rule_ids(file_match.group(1))
63
+
64
+ line_match = _LINE_NOQA_PAT.search(line_text)
65
+ if line_match:
66
+ line_sups[lineno] = _rule_ids(line_match.group(1))
67
+
68
+ return [
69
+ diag
70
+ for diag in diagnostics
71
+ if not (
72
+ (file_sup_active and _is_covered(file_sup_rules, diag.rule_id))
73
+ or (
74
+ diag.line in line_sups
75
+ and _is_covered(line_sups[diag.line], diag.rule_id)
76
+ )
77
+ )
78
+ ]
79
+
80
+
81
+ class Analyzer:
82
+ """Runs all registered rules against a source file."""
83
+
84
+ def __init__(self, rules: list[base.Rule]) -> None:
85
+ """Initialize with a list of rule instances.
86
+
87
+ Args:
88
+ rules: Rule instances to run on every analysis request.
89
+ """
90
+ self.rules = rules
91
+
92
+ def analyze(self, source: str) -> list[base.Diagnostic]:
93
+ """Parse source, run all rules, and apply inline suppressions.
94
+
95
+ Args:
96
+ source: Raw Python source code to analyze.
97
+
98
+ Returns:
99
+ Diagnostics sorted by (line, col) with suppressed entries removed.
100
+ Returns an empty list if the source cannot be parsed.
101
+ """
102
+ try:
103
+ tree = ast.parse(source)
104
+ except SyntaxError:
105
+ return []
106
+
107
+ diagnostics = sorted(
108
+ [diag for rule in self.rules for diag in rule.check(tree, source)],
109
+ key=lambda diag: (diag.line, diag.col),
110
+ )
111
+ return _apply_suppressions(diagnostics, source)