python-surveyor 0.4.0__tar.gz → 0.4.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.
Files changed (33) hide show
  1. {python_surveyor-0.4.0/python_surveyor.egg-info → python_surveyor-0.4.1}/PKG-INFO +1 -1
  2. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/pyproject.toml +2 -2
  3. python_surveyor-0.4.1/python_surveyor/checks/__init__.py +152 -0
  4. python_surveyor-0.4.1/python_surveyor/checks/_util.py +73 -0
  5. python_surveyor-0.4.1/python_surveyor/checks/broad_except.py +168 -0
  6. python_surveyor-0.4.1/python_surveyor/checks/fixture_naming.py +134 -0
  7. python_surveyor-0.4.1/python_surveyor/checks/future_annotations.py +46 -0
  8. python_surveyor-0.4.1/python_surveyor/checks/nontoplevel_imports.py +125 -0
  9. python_surveyor-0.4.1/python_surveyor/checks/optional_params.py +311 -0
  10. python_surveyor-0.4.1/python_surveyor/checks/suppressions.py +96 -0
  11. {python_surveyor-0.4.0 → python_surveyor-0.4.1/python_surveyor.egg-info}/PKG-INFO +1 -1
  12. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/SOURCES.txt +8 -0
  13. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/LICENSE +0 -0
  14. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/README.md +0 -0
  15. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/__init__.py +0 -0
  16. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/__main__.py +0 -0
  17. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/cli.py +0 -0
  18. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/model.py +0 -0
  19. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/report.py +0 -0
  20. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor/scanner.py +0 -0
  21. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/dependency_links.txt +0 -0
  22. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/entry_points.txt +0 -0
  23. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/requires.txt +0 -0
  24. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/python_surveyor.egg-info/top_level.txt +0 -0
  25. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/setup.cfg +0 -0
  26. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_broad_except.py +0 -0
  27. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_cli.py +0 -0
  28. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_fixture_naming.py +0 -0
  29. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_future_annotations.py +0 -0
  30. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_nontoplevel_imports.py +0 -0
  31. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_optional_params.py +0 -0
  32. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_scanner.py +0 -0
  33. {python_surveyor-0.4.0 → python_surveyor-0.4.1}/tests/test_suppressions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-surveyor
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Use python introspection to survey source code for final LLM judgement
5
5
  Author: Dave Cunningham
6
6
  License-Expression: MIT
@@ -4,11 +4,11 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [tool.setuptools.packages.find]
6
6
  where = ["."]
7
- include = ["python_surveyor"]
7
+ include = ["python_surveyor*"]
8
8
 
9
9
  [project]
10
10
  name = "python-surveyor"
11
- version = "0.4.0"
11
+ version = "0.4.1"
12
12
  description = "Use python introspection to survey source code for final LLM judgement"
13
13
  requires-python = ">=3.12"
14
14
  license = "MIT"
@@ -0,0 +1,152 @@
1
+ """Check registry used by dispatch and the ``list-checks`` command.
2
+
3
+ Every check is a ``CheckSpec(check_id, description, run)`` where ``run`` has
4
+ the signature ``run(source: SourceFile, corpus: Corpus) -> list[Finding]``.
5
+ Checks that ignore ``corpus`` still take it, so the internal check API itself
6
+ has no optional parameters.
7
+
8
+ ``SourceFile`` and ``Corpus`` are imported only under ``TYPE_CHECKING`` to
9
+ avoid a circular import with :mod:`python_surveyor.scanner` (which imports
10
+ this registry at module top-level).
11
+ """
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+ from typing import TYPE_CHECKING
16
+
17
+ from python_surveyor.checks.broad_except import run as run_broad_except
18
+ from python_surveyor.checks.fixture_naming import run as run_fixture_naming
19
+ from python_surveyor.checks.future_annotations import run as run_future_annotations
20
+ from python_surveyor.checks.nontoplevel_imports import run as run_nontoplevel_imports
21
+ from python_surveyor.checks.optional_params import run as run_optional_params
22
+ from python_surveyor.checks.suppressions import run as run_suppressions
23
+ from python_surveyor.model import Finding
24
+
25
+ if TYPE_CHECKING:
26
+ from python_surveyor.scanner import Corpus, SourceFile
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class CheckSpec:
31
+ """A single check: id, short description, longer explanation, and runner.
32
+
33
+ ``description`` is the one-liner shown by ``list-checks``.
34
+ ``explanation`` is shown as a header note when the text report has
35
+ findings for this check, so the reader understands *why* the smell
36
+ matters before judging each hit.
37
+ ``pylint_equivalents`` lists pylint check IDs that this check already
38
+ covers, so the ``suppression-comment`` check can skip ``# pylint:
39
+ disable=`` directives that are fully redundant with another check (the
40
+ list is parsed and checked against this set — no regex to maintain).
41
+ """
42
+
43
+ check_id: str
44
+ description: str
45
+ explanation: str
46
+ pylint_equivalents: tuple[str, ...]
47
+ run: "Callable[[SourceFile, Corpus], list[Finding]]"
48
+
49
+
50
+ _ALL_CHECKS_DATA: tuple[
51
+ tuple[
52
+ str, str, str, tuple[str, ...], "Callable[[SourceFile, Corpus], list[Finding]]"
53
+ ],
54
+ ...,
55
+ ] = (
56
+ (
57
+ "broad-except",
58
+ "ExceptHandler with no type, or type Exception/BaseException "
59
+ "(including tuples and except*).",
60
+ "Catching a narrower exception type is usually better — it makes "
61
+ "the intent explicit and avoids swallowing unexpected bugs. When a "
62
+ "broad except is necessary, it should typically re-raise to "
63
+ "preserve the stack trace for actual bugs rather than silently "
64
+ "discarding them.",
65
+ ("broad-exception-caught",),
66
+ run_broad_except,
67
+ ),
68
+ (
69
+ "fixture-naming",
70
+ "@pytest.fixture function whose registered name does not match "
71
+ "its declared name (the redefined-outer-name collision case).",
72
+ "A pytest fixture function should be prefixed with `fixture_` and "
73
+ "register its real name via `name=` so the function parameter in "
74
+ "test functions doesn't shadow the fixture function name — the "
75
+ "`redefined-outer-name` pylint disable that AI code often adds to "
76
+ "silence this is a symptom, not a fix.",
77
+ ("redefined-outer-name",),
78
+ run_fixture_naming,
79
+ ),
80
+ (
81
+ "future-annotations-import",
82
+ "`from __future__ import annotations` (we never use 3.9).",
83
+ "`from __future__ import annotations` makes all type annotations "
84
+ "strings at runtime, which breaks any code that relies on "
85
+ "resolving types at runtime (e.g. `typing.get_type_hints`, "
86
+ "Pydantic models, FastAPI dependency injection). Since the "
87
+ "target is always Python 3.12+, it's unnecessary and should be "
88
+ "removed.",
89
+ (),
90
+ run_future_annotations,
91
+ ),
92
+ (
93
+ "non-toplevel-import",
94
+ "Import/ImportFrom not directly in Module.body, at any nesting depth.",
95
+ "Imports inside functions or blocks are usually a sign of lazy "
96
+ "import resolution — often masking circular dependencies that "
97
+ "should be fixed with proper architecture. There may be a good "
98
+ "reason (e.g. performance, test-harness monkeypatching), but "
99
+ "absent one, imports should be at module top level.",
100
+ ("import-outside-toplevel",),
101
+ run_nontoplevel_imports,
102
+ ),
103
+ (
104
+ "optional-param-default",
105
+ "FunctionDef with >=1 defaulted positional or keyword-only parameter, "
106
+ "excluding FastAPI/Flask route handlers (verified via real imports).",
107
+ "Defaulted parameters are often added to avoid updating existing "
108
+ "call sites, but they make it impossible for pyright to flag "
109
+ "callers that should be passing an explicit value — the type "
110
+ "checker sees the default and moves on. On internal APIs, every "
111
+ "caller should pass an explicit value so pyright can catch "
112
+ "missing or wrong arguments. FastAPI/Flask route handlers are "
113
+ "excluded because their defaults are a framework user interface "
114
+ "(query parameters, headers) and the handler is invoked by the "
115
+ "framework, not by application code — the 'not called from "
116
+ "anywhere' signal doesn't apply.",
117
+ (),
118
+ run_optional_params,
119
+ ),
120
+ (
121
+ "suppression-comment",
122
+ "# pylint: disable=... or # pyright: ignore without a "
123
+ "justifying comment block above.",
124
+ "Suppression comments disable a linter's check for a line or "
125
+ "region. They're sometimes necessary, but each one should have a "
126
+ "comment explaining *why* the suppression is warranted — without "
127
+ "that, the suppression is indistinguishable from silencing a real "
128
+ "problem.",
129
+ (),
130
+ run_suppressions,
131
+ ),
132
+ )
133
+
134
+
135
+ ALL_CHECKS: tuple[CheckSpec, ...] = tuple(
136
+ CheckSpec(
137
+ check_id=cid,
138
+ description=desc,
139
+ explanation=expl,
140
+ pylint_equivalents=equiv,
141
+ run=run,
142
+ )
143
+ for cid, desc, expl, equiv, run in _ALL_CHECKS_DATA
144
+ )
145
+
146
+ CHECKS_BY_ID: dict[str, CheckSpec] = {
147
+ check_spec.check_id: check_spec for check_spec in ALL_CHECKS
148
+ }
149
+
150
+ COVERED_PYLINT_IDS: frozenset[str] = frozenset(
151
+ eq for check in ALL_CHECKS for eq in check.pylint_equivalents
152
+ )
@@ -0,0 +1,73 @@
1
+ """Shared helpers for check implementations.
2
+
3
+ Every finding needs two pieces of context that are shared across all checks:
4
+ the source line(s) at the finding location (so the reader can see the actual
5
+ code) and any justifying comment block immediately above it (so the reader
6
+ can judge whether the smell is intentional). Excerpt-line capping is a
7
+ rendering concern and lives in :mod:`python_surveyor.report`.
8
+
9
+ ``SourceFile`` is imported only under ``TYPE_CHECKING`` to avoid a circular
10
+ import with :mod:`python_surveyor.scanner`.
11
+ """
12
+
13
+ import ast
14
+ from typing import TYPE_CHECKING
15
+
16
+ from python_surveyor.model import SourceExcerpt
17
+
18
+ if TYPE_CHECKING:
19
+ from python_surveyor.scanner import SourceFile
20
+
21
+ CONTROL_FLOW_NODES: tuple[type[ast.AST], ...] = (
22
+ ast.If,
23
+ ast.Try,
24
+ ast.With,
25
+ ast.For,
26
+ ast.While,
27
+ )
28
+
29
+
30
+ def is_pure_comment_line(text: str) -> bool:
31
+ """True if ``text`` is a ``#``-comment line (possibly indented, no code)."""
32
+ return text.strip().startswith("#")
33
+
34
+
35
+ def preceding_comment_block(
36
+ source: "SourceFile", line: int
37
+ ) -> "tuple[int, int] | None":
38
+ """Return ``(start, end)`` inclusive of the contiguous comment block above
39
+ ``line``, or ``None`` if the line immediately above is not a comment.
40
+
41
+ Walks upward while each preceding line is a pure ``#``-comment line; blank
42
+ lines and code lines break the block.
43
+ """
44
+ end = line - 1
45
+ if end < 1:
46
+ return None
47
+ if not is_pure_comment_line(source.line_text(end)):
48
+ return None
49
+ start = end
50
+ while start - 1 >= 1 and is_pure_comment_line(source.line_text(start - 1)):
51
+ start -= 1
52
+ return start, end
53
+
54
+
55
+ def justifying_comment(source: "SourceFile", line: int) -> tuple[SourceExcerpt, ...]:
56
+ """Return a 1-tuple excerpt for the comment block above ``line``, or ``()``.
57
+
58
+ If a preceding ``#``-comment block is found, it is attached as an excerpt
59
+ labeled "justifying comment". If no preceding comment block is found,
60
+ returns ``()`` — the absence of the excerpt is the signal that no
61
+ justifying comment exists, so no note is needed.
62
+ """
63
+ block = preceding_comment_block(source, line)
64
+ if block is None:
65
+ return ()
66
+ start, end = block
67
+ return (
68
+ SourceExcerpt(
69
+ path=source.path,
70
+ start_line=start,
71
+ end_line=end,
72
+ ),
73
+ )
@@ -0,0 +1,168 @@
1
+ """``broad-except`` check.
2
+
3
+ Finds ``ExceptHandler`` nodes with no type, or with type
4
+ ``Exception``/``BaseException`` (including tuples and ``except*`` via
5
+ ``TryStar``). Two excerpts are attached: the ``try`` side (justifying
6
+ comment above, the ``try`` line, and any comment block immediately after
7
+ the ``try`` — but no code from inside the try body) and the ``except``
8
+ side (the ``except`` line plus handler body, capped at ``_WINDOW``
9
+ lines). If the two excerpts overlap, they are merged into one.
10
+
11
+ Handlers that re-raise (bare ``raise``) or call ``logger.exception(...)``
12
+ are not flagged — re-raising preserves the stack trace, and
13
+ ``logger.exception`` captures it (though the error is still swallowed).
14
+ """
15
+
16
+ import ast
17
+ from typing import TYPE_CHECKING
18
+
19
+ from python_surveyor.checks._util import is_pure_comment_line, justifying_comment
20
+ from python_surveyor.model import Finding, SourceExcerpt
21
+
22
+ if TYPE_CHECKING:
23
+ from python_surveyor.scanner import Corpus, SourceFile
24
+
25
+ _BROAD_NAMES = {"Exception", "BaseException"}
26
+ _WINDOW = 5
27
+
28
+
29
+ def _type_contains_broad(node: ast.expr | None) -> bool:
30
+ if node is None:
31
+ return True
32
+ if isinstance(node, ast.Name):
33
+ return node.id in _BROAD_NAMES
34
+ if isinstance(node, ast.Tuple):
35
+ return any(_type_contains_broad(elt) for elt in node.elts)
36
+ return False
37
+
38
+
39
+ def _is_reraise(stmt: ast.stmt) -> bool:
40
+ """True if ``stmt`` is a bare ``raise`` (re-raises the current exception)."""
41
+ return isinstance(stmt, ast.Raise) and stmt.exc is None and stmt.cause is None
42
+
43
+
44
+ def _is_logger_exception(stmt: ast.stmt) -> bool:
45
+ """True if ``stmt`` is ``<something>.exception(...)`` (e.g. ``logger.exception``)."""
46
+ if not isinstance(stmt, ast.Expr) or not isinstance(stmt.value, ast.Call):
47
+ return False
48
+ func = stmt.value.func
49
+ return isinstance(func, ast.Attribute) and func.attr == "exception"
50
+
51
+
52
+ def _handler_re_raises_or_logs(handler: ast.ExceptHandler) -> bool:
53
+ """True if the handler body re-raises or calls ``logger.exception``."""
54
+ for stmt in handler.body:
55
+ for sub in ast.walk(stmt):
56
+ if isinstance(sub, ast.stmt) and (
57
+ _is_reraise(sub) or _is_logger_exception(sub)
58
+ ):
59
+ return True
60
+ return False
61
+
62
+
63
+ def _body_end(handler: ast.ExceptHandler) -> int:
64
+ """Return the last line of the handler body (or the ``except`` line)."""
65
+ body = handler.body
66
+ if not body:
67
+ return handler.lineno
68
+ last = body[-1]
69
+ end = getattr(last, "end_lineno", None)
70
+ if end is None:
71
+ end = last.lineno
72
+ return max(end, handler.lineno)
73
+
74
+
75
+ def _comment_block_end(source: "SourceFile", start: int) -> int:
76
+ """Return the last line of a contiguous comment block starting at ``start``.
77
+
78
+ If ``start`` is not a comment line, returns ``start - 1`` (an empty
79
+ block before ``start``).
80
+ """
81
+ total = len(source.lines)
82
+ end = start
83
+ while end + 1 <= total and is_pure_comment_line(source.line_text(end + 1)):
84
+ end += 1
85
+ return end
86
+
87
+
88
+ def _try_except_excerpts(
89
+ source: "SourceFile",
90
+ jc_start: "int | None",
91
+ try_line: int,
92
+ handler_line: int,
93
+ body_end: int,
94
+ ) -> tuple[SourceExcerpt, ...]:
95
+ """Build excerpts for the try and except sides of a broad except.
96
+
97
+ The try-side excerpt is justifying comment (if any) + ``try`` line +
98
+ any comment block immediately after the ``try`` — never code from
99
+ inside the try body. If there are no comments before or after the
100
+ ``try``, the try-side excerpt is omitted entirely. The except-side
101
+ excerpt is the ``except`` line + handler body, capped at ``_WINDOW``
102
+ lines. If the two overlap, they are merged into one excerpt.
103
+ """
104
+ has_jc = jc_start is not None
105
+ post_end = _comment_block_end(source, try_line)
106
+ has_post = post_end > try_line
107
+ second_start = handler_line
108
+ second_end = min(body_end, handler_line + _WINDOW)
109
+ if not has_jc and not has_post:
110
+ return (
111
+ SourceExcerpt(
112
+ path=source.path,
113
+ start_line=second_start,
114
+ end_line=second_end,
115
+ ),
116
+ )
117
+ first_start = jc_start if jc_start is not None else try_line
118
+ first_end = post_end
119
+ if first_end >= second_start:
120
+ merged_end = min(max(first_end, second_end), body_end)
121
+ return (
122
+ SourceExcerpt(
123
+ path=source.path,
124
+ start_line=first_start,
125
+ end_line=merged_end,
126
+ ),
127
+ )
128
+ return (
129
+ SourceExcerpt(
130
+ path=source.path,
131
+ start_line=first_start,
132
+ end_line=first_end,
133
+ ),
134
+ SourceExcerpt(
135
+ path=source.path,
136
+ start_line=second_start,
137
+ end_line=second_end,
138
+ ),
139
+ )
140
+
141
+
142
+ def run(source: "SourceFile", _corpus: "Corpus") -> list[Finding]:
143
+ """Find broad ``except`` handlers and attach body + justifying-comment context."""
144
+ findings: list[Finding] = []
145
+ for node in ast.walk(source.tree):
146
+ if not isinstance(node, (ast.Try, ast.TryStar)):
147
+ continue
148
+ jc = justifying_comment(source, node.lineno)
149
+ jc_start = jc[0].start_line if jc else None
150
+ for handler in node.handlers:
151
+ if not _type_contains_broad(handler.type):
152
+ continue
153
+ if _handler_re_raises_or_logs(handler):
154
+ continue
155
+ body_end = _body_end(handler)
156
+ excerpts = _try_except_excerpts(
157
+ source, jc_start, node.lineno, handler.lineno, body_end
158
+ )
159
+ findings.append(
160
+ Finding(
161
+ check_id="broad-except",
162
+ path=source.path,
163
+ line=handler.lineno,
164
+ column=handler.col_offset,
165
+ excerpts=excerpts,
166
+ )
167
+ )
168
+ return findings
@@ -0,0 +1,134 @@
1
+ """``fixture-naming`` check.
2
+
3
+ Finds ``@pytest.fixture``/``@fixture``-decorated functions whose registered
4
+ fixture name does not match the convention: the function should be prefixed
5
+ with ``fixture_`` **and** ``name=`` should register the true fixture name.
6
+ The ``def`` line is attached as an excerpt (extended upward to include any
7
+ justifying comment block), and a note reports the effective registered name
8
+ and the corpus lookup of parameter usages of that name elsewhere (the actual
9
+ ``redefined-outer-name`` collision sites).
10
+ """
11
+
12
+ import ast
13
+ from typing import TYPE_CHECKING
14
+
15
+ from python_surveyor.checks._util import justifying_comment
16
+ from python_surveyor.model import Finding, SourceExcerpt
17
+
18
+ if TYPE_CHECKING:
19
+ from python_surveyor.scanner import Corpus, SourceFile
20
+
21
+
22
+ def _decorator_info(
23
+ deco: ast.expr,
24
+ ) -> "tuple[bool, bool, str | None]":
25
+ """Return ``(is_fixture, has_name_kwarg, name_value)`` for a decorator."""
26
+ call: ast.Call | None = None
27
+ expr = deco
28
+ if isinstance(expr, ast.Call):
29
+ call = expr
30
+ expr = expr.func
31
+ is_fixture = False
32
+ if isinstance(expr, ast.Attribute) and expr.attr == "fixture":
33
+ if isinstance(expr.value, ast.Name) and expr.value.id == "pytest":
34
+ is_fixture = True
35
+ elif isinstance(expr, ast.Name) and expr.id == "fixture":
36
+ is_fixture = True
37
+ if not is_fixture or call is None:
38
+ return is_fixture, False, None
39
+ name_value: str | None = None
40
+ has_name = False
41
+ for kw in call.keywords:
42
+ if kw.arg == "name":
43
+ has_name = True
44
+ if isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
45
+ name_value = kw.value.value
46
+ return True, has_name, name_value
47
+
48
+
49
+ def _collision_note(registered_name: str, corpus: "Corpus") -> str:
50
+ sites = corpus.param_locations.get(registered_name, ())
51
+ count = len(sites)
52
+ if count == 0:
53
+ return f"registered as `{registered_name}`; no parameter usages elsewhere"
54
+ cap = corpus.max_call_sites
55
+ samples = sorted(sites, key=lambda pair: (str(pair[0].path), pair[0].line))[:cap]
56
+ rendered = ", ".join(
57
+ f"{pair[0].path.name}:{pair[0].line} (in `{pair[1]}`)" for pair in samples
58
+ )
59
+ remaining = count - cap
60
+ if remaining > 0:
61
+ rendered += f", and {remaining} other(s)"
62
+ return (
63
+ f"registered as `{registered_name}`; parameter used in "
64
+ f"{count} other function(s): {rendered}"
65
+ )
66
+
67
+
68
+ def _is_autouse(deco: ast.expr) -> bool:
69
+ """True if the decorator call has ``autouse=True``."""
70
+ if not isinstance(deco, ast.Call):
71
+ return False
72
+ for kw in deco.keywords:
73
+ if (
74
+ kw.arg == "autouse"
75
+ and isinstance(kw.value, ast.Constant)
76
+ and kw.value.value is True
77
+ ):
78
+ return True
79
+ return False
80
+
81
+
82
+ def run(source: "SourceFile", corpus: "Corpus") -> list[Finding]:
83
+ """Find fixture-naming issues and attach source + collision-site context."""
84
+ if source.path.name == "conftest.py":
85
+ return []
86
+ findings: list[Finding] = []
87
+ for node in ast.walk(source.tree):
88
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
89
+ continue
90
+ fixture_deco: ast.expr | None = None
91
+ has_name_kwarg = False
92
+ name_value: str | None = None
93
+ for deco in node.decorator_list:
94
+ is_fixture, has_name, value = _decorator_info(deco)
95
+ if is_fixture:
96
+ fixture_deco = deco
97
+ has_name_kwarg = has_name
98
+ name_value = value
99
+ break
100
+ if fixture_deco is None:
101
+ continue
102
+ if _is_autouse(fixture_deco):
103
+ continue
104
+ starts_with_fixture_ = node.name.startswith("fixture_")
105
+ if has_name_kwarg and starts_with_fixture_:
106
+ continue
107
+ registered_name = name_value if name_value is not None else node.name
108
+ reasons: list[str] = []
109
+ if not has_name_kwarg:
110
+ reasons.append("no `name=` kwarg")
111
+ if not starts_with_fixture_:
112
+ reasons.append(f"name `{node.name}` lacks `fixture_` prefix")
113
+ jc = justifying_comment(source, node.lineno)
114
+ start = jc[0].start_line if jc else node.lineno
115
+ findings.append(
116
+ Finding(
117
+ check_id="fixture-naming",
118
+ path=source.path,
119
+ line=node.lineno,
120
+ column=node.col_offset,
121
+ excerpts=(
122
+ SourceExcerpt(
123
+ path=source.path,
124
+ start_line=start,
125
+ end_line=node.lineno,
126
+ ),
127
+ ),
128
+ notes=(
129
+ _collision_note(registered_name, corpus),
130
+ f"reason: {'; '.join(reasons)}",
131
+ ),
132
+ )
133
+ )
134
+ return findings
@@ -0,0 +1,46 @@
1
+ """``future-annotations-import`` check.
2
+
3
+ Finds ``from __future__ import annotations``. The import line is attached
4
+ as an excerpt (extended upward to include any justifying comment block),
5
+ but the fix is always "delete the line" so there is little else to judge.
6
+ """
7
+
8
+ import ast
9
+ from typing import TYPE_CHECKING
10
+
11
+ from python_surveyor.checks._util import justifying_comment
12
+ from python_surveyor.model import Finding, SourceExcerpt
13
+
14
+ if TYPE_CHECKING:
15
+ from python_surveyor.scanner import Corpus, SourceFile
16
+
17
+
18
+ def run(source: "SourceFile", _corpus: "Corpus") -> list[Finding]:
19
+ """Find ``from __future__ import annotations`` directives."""
20
+ findings: list[Finding] = []
21
+ for node in source.tree.body:
22
+ if not isinstance(node, ast.ImportFrom):
23
+ continue
24
+ if node.module != "__future__":
25
+ continue
26
+ for alias in node.names:
27
+ if alias.name == "annotations":
28
+ jc = justifying_comment(source, node.lineno)
29
+ start = jc[0].start_line if jc else node.lineno
30
+ findings.append(
31
+ Finding(
32
+ check_id="future-annotations-import",
33
+ path=source.path,
34
+ line=node.lineno,
35
+ column=node.col_offset,
36
+ excerpts=(
37
+ SourceExcerpt(
38
+ path=source.path,
39
+ start_line=start,
40
+ end_line=node.lineno,
41
+ ),
42
+ ),
43
+ notes=(),
44
+ )
45
+ )
46
+ return findings
@@ -0,0 +1,125 @@
1
+ """``non-toplevel-import`` check.
2
+
3
+ Finds ``Import``/``ImportFrom`` nodes that are not direct children of
4
+ ``Module.body`` (i.e. nested inside a function, class, or control-flow block
5
+ at any depth). The import line is attached as an excerpt (extended upward to
6
+ include any justifying comment block), and a note names the nearest
7
+ enclosing scope so the agent can judge whether the late import is justified
8
+ (e.g. test-harness monkeypatching) or just laziness.
9
+ """
10
+
11
+ import ast
12
+ from typing import TYPE_CHECKING
13
+
14
+ from python_surveyor.checks._util import (
15
+ CONTROL_FLOW_NODES,
16
+ justifying_comment,
17
+ )
18
+ from python_surveyor.model import Finding, SourceExcerpt
19
+
20
+ if TYPE_CHECKING:
21
+ from python_surveyor.scanner import Corpus, SourceFile
22
+
23
+ _SCOPE_LABELS = {
24
+ ast.FunctionDef: "function",
25
+ ast.AsyncFunctionDef: "function",
26
+ ast.ClassDef: "class",
27
+ ast.If: "if block",
28
+ ast.Try: "try block",
29
+ ast.With: "with block",
30
+ ast.For: "for block",
31
+ ast.While: "while block",
32
+ ast.ExceptHandler: "except block",
33
+ ast.TryStar: "try* block",
34
+ }
35
+
36
+
37
+ def _is_type_checking_test(test: ast.expr) -> bool:
38
+ """True for ``if TYPE_CHECKING:`` / ``if typing.TYPE_CHECKING:`` guards."""
39
+ if isinstance(test, ast.Name) and test.id == "TYPE_CHECKING":
40
+ return True
41
+ return (
42
+ isinstance(test, ast.Attribute)
43
+ and test.attr == "TYPE_CHECKING"
44
+ and isinstance(test.value, ast.Name)
45
+ and test.value.id == "typing"
46
+ )
47
+
48
+
49
+ def _scope_label(node: ast.AST) -> str:
50
+ kind = _SCOPE_LABELS.get(type(node))
51
+ if kind is None:
52
+ return "nested block"
53
+ name = getattr(node, "name", None)
54
+ if name:
55
+ return f"{kind} `{name}`"
56
+ return kind
57
+
58
+
59
+ def _walk(
60
+ body: list[ast.stmt],
61
+ scope_stack: list[ast.AST],
62
+ source: "SourceFile",
63
+ findings: list[Finding],
64
+ ) -> None:
65
+ for stmt in body:
66
+ new_stack = scope_stack
67
+ if isinstance(
68
+ stmt,
69
+ (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef),
70
+ ):
71
+ new_stack = scope_stack + [stmt]
72
+ elif isinstance(stmt, CONTROL_FLOW_NODES):
73
+ if isinstance(stmt, ast.If) and _is_type_checking_test(stmt.test):
74
+ new_stack = scope_stack
75
+ else:
76
+ new_stack = scope_stack + [stmt]
77
+ if isinstance(stmt, (ast.Import, ast.ImportFrom)) and scope_stack:
78
+ _record(stmt, source, scope_stack, findings)
79
+ for child_field in ("body", "orelse", "finalbody"):
80
+ child_body = getattr(stmt, child_field, None)
81
+ if isinstance(child_body, list):
82
+ _walk(child_body, new_stack, source, findings)
83
+ if isinstance(stmt, ast.Try):
84
+ for handler in stmt.handlers:
85
+ _walk(handler.body, new_stack + [handler], source, findings)
86
+
87
+
88
+ def _record(
89
+ stmt: "ast.Import | ast.ImportFrom",
90
+ source: "SourceFile",
91
+ scope_stack: list[ast.AST],
92
+ findings: list[Finding],
93
+ ) -> None:
94
+ line = stmt.lineno
95
+ jc = justifying_comment(source, line)
96
+ start = jc[0].start_line if jc else line
97
+ nearest = scope_stack[-1] if scope_stack else None
98
+ note = (
99
+ f"inside {_scope_label(nearest)}"
100
+ if nearest is not None
101
+ else "at module top-level"
102
+ )
103
+ findings.append(
104
+ Finding(
105
+ check_id="non-toplevel-import",
106
+ path=source.path,
107
+ line=line,
108
+ column=stmt.col_offset,
109
+ excerpts=(
110
+ SourceExcerpt(
111
+ path=source.path,
112
+ start_line=start,
113
+ end_line=line,
114
+ ),
115
+ ),
116
+ notes=(note,),
117
+ )
118
+ )
119
+
120
+
121
+ def run(source: "SourceFile", _corpus: "Corpus") -> list[Finding]:
122
+ """Find non-top-level imports and attach source + enclosing-scope context."""
123
+ findings: list[Finding] = []
124
+ _walk(source.tree.body, [], source, findings)
125
+ return findings
@@ -0,0 +1,311 @@
1
+ """``optional-param-default`` check.
2
+
3
+ Finds any ``FunctionDef``/``AsyncFunctionDef`` with at least one defaulted
4
+ positional or keyword-only parameter (``*args``/``**kwargs`` names are not
5
+ "parameters" for this purpose). The signature (justifying comment + ``def``
6
+ line, with non-defaulted leading params elided) is attached as an excerpt,
7
+ and notes report the defaulted parameter names plus call sites that rely on
8
+ at least one default value (capped at ``corpus.max_call_sites``, with
9
+ "and N other(s)" when truncated) — call sites that override every default
10
+ are not listed, since they don't exercise the default path.
11
+
12
+ **Framework route handlers are excluded.** FastAPI/Flask route handlers use
13
+ defaulted params as a user interface (query parameters, headers, etc.) and
14
+ are invoked by the framework rather than by application code, so the
15
+ "missing call sites" signal this check looks for doesn't apply. A handler is
16
+ recognised only when its decorator (e.g. ``@app.get(...)``, ``@router.post(
17
+ ...)``, ``@app.route(...)``) is a call on a name that is statically traced,
18
+ via imports in the same file, to an actual ``fastapi.FastAPI`` /
19
+ ``fastapi.APIRouter`` / ``flask.Flask`` / ``flask.Blueprint`` instance. Only
20
+ direct ``name = Ctor()`` assignments are tracked — factory functions that
21
+ build and return an app/router are not (this matches the heuristic, not
22
+ exhaustive, philosophy of the other checks).
23
+ """
24
+
25
+ import ast
26
+ from typing import TYPE_CHECKING
27
+
28
+ from python_surveyor.checks._util import justifying_comment
29
+ from python_surveyor.model import CallSite, Finding, SourceExcerpt
30
+
31
+ if TYPE_CHECKING:
32
+ from python_surveyor.scanner import Corpus, SourceFile
33
+
34
+
35
+ # Framework classes whose instances expose route-decorator methods. Mapped
36
+ # from fully-qualified ``module.ClassName`` to the set of attribute names
37
+ # that route handlers are registered with.
38
+ _FRAMEWORK_CLASSES: dict[str, frozenset[str]] = {
39
+ "fastapi.FastAPI": frozenset(
40
+ {
41
+ "get",
42
+ "post",
43
+ "put",
44
+ "delete",
45
+ "patch",
46
+ "options",
47
+ "head",
48
+ "trace",
49
+ "websocket",
50
+ "api_route",
51
+ "route",
52
+ }
53
+ ),
54
+ "fastapi.APIRouter": frozenset(
55
+ {
56
+ "get",
57
+ "post",
58
+ "put",
59
+ "delete",
60
+ "patch",
61
+ "options",
62
+ "head",
63
+ "trace",
64
+ "websocket",
65
+ "api_route",
66
+ }
67
+ ),
68
+ "flask.Flask": frozenset(
69
+ {"get", "post", "put", "delete", "patch", "options", "head", "route"}
70
+ ),
71
+ "flask.Blueprint": frozenset(
72
+ {"get", "post", "put", "delete", "patch", "options", "head", "route"}
73
+ ),
74
+ }
75
+
76
+
77
+ def _framework_import_bindings(
78
+ tree: ast.Module,
79
+ ) -> dict[str, str]:
80
+ """Map locally-bound names to fully-qualified ``module.Name`` for framework imports.
81
+
82
+ Handles ``from fastapi import FastAPI``, ``from fastapi import APIRouter as
83
+ AR``, and ``import fastapi`` / ``import fastapi as fa``. Only names that
84
+ resolve to a known framework class (``fastapi.FastAPI`` etc.) are kept.
85
+ """
86
+ bindings: dict[str, str] = {}
87
+ for node in ast.walk(tree):
88
+ if isinstance(node, ast.ImportFrom):
89
+ if node.module not in ("fastapi", "flask"):
90
+ continue
91
+ for alias in node.names:
92
+ fq = f"{node.module}.{alias.name}"
93
+ if fq in _FRAMEWORK_CLASSES:
94
+ local = alias.asname if alias.asname else alias.name
95
+ bindings[local] = fq
96
+ elif isinstance(node, ast.Import):
97
+ for alias in node.names:
98
+ if alias.name in ("fastapi", "flask"):
99
+ local = alias.asname if alias.asname else alias.name
100
+ bindings[local] = alias.name
101
+ return bindings
102
+
103
+
104
+ def _resolve_call_qualname(func: ast.expr, bindings: dict[str, str]) -> str | None:
105
+ """Resolve the called function to a fully-qualified name, or ``None``.
106
+
107
+ Handles bare ``FastAPI()`` (``Name`` resolved via bindings) and dotted
108
+ ``fastapi.FastAPI()`` / ``fa.FastAPI()`` (``Attribute`` whose value is a
109
+ module-alias name in bindings).
110
+ """
111
+ if isinstance(func, ast.Name):
112
+ return bindings.get(func.id)
113
+ if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
114
+ module_alias = bindings.get(func.value.id)
115
+ if module_alias is not None:
116
+ return f"{module_alias}.{func.attr}"
117
+ return None
118
+
119
+
120
+ def _framework_object_names(tree: ast.Module) -> set[str]:
121
+ """Return names of variables bound to real FastAPI/Flask app/router instances.
122
+
123
+ Only direct ``name = Ctor(...)`` assignments at any depth are tracked.
124
+ Factory functions that build and return an app/router are not recognised
125
+ — see the module docstring for the rationale.
126
+ """
127
+ bindings = _framework_import_bindings(tree)
128
+ if not bindings:
129
+ return set()
130
+ names: set[str] = set()
131
+ for node in ast.walk(tree):
132
+ target: ast.expr | None = None
133
+ value: ast.expr | None = None
134
+ if isinstance(node, ast.Assign):
135
+ if len(node.targets) != 1:
136
+ continue
137
+ target = node.targets[0]
138
+ value = node.value
139
+ elif isinstance(node, ast.AnnAssign) and node.value is not None:
140
+ target = node.target
141
+ value = node.value
142
+ if not isinstance(target, ast.Name) or value is None:
143
+ continue
144
+ if not isinstance(value, ast.Call):
145
+ continue
146
+ fq = _resolve_call_qualname(value.func, bindings)
147
+ if fq is not None and fq in _FRAMEWORK_CLASSES:
148
+ names.add(target.id)
149
+ return names
150
+
151
+
152
+ def _is_route_decorator(deco: ast.expr, router_names: set[str]) -> bool:
153
+ """True if ``deco`` is a ``@<router>.<verb>(...)`` call on a known router.
154
+
155
+ ``<router>`` must be a name in ``router_names`` (a verified framework
156
+ app/router instance) and ``<verb>`` must be a route-registration method
157
+ for that object's class (e.g. ``get``, ``post``, ``route``).
158
+ """
159
+ if not isinstance(deco, ast.Call):
160
+ return False
161
+ func = deco.func
162
+ if not isinstance(func, ast.Attribute):
163
+ return False
164
+ if not isinstance(func.value, ast.Name):
165
+ return False
166
+ if func.value.id not in router_names:
167
+ return False
168
+ # ``router_names`` only contains verified framework instances, so any
169
+ # attribute on them that matches a known route verb is a route decorator.
170
+ for verbs in _FRAMEWORK_CLASSES.values():
171
+ if func.attr in verbs:
172
+ return True
173
+ return False
174
+
175
+
176
+ def _defaulted_param_names(
177
+ func: "ast.FunctionDef | ast.AsyncFunctionDef",
178
+ ) -> list[str]:
179
+ """Return the names of positional and keyword-only params that have defaults."""
180
+ args = func.args
181
+ names: list[str] = []
182
+ positional_defaults = len(args.defaults)
183
+ for i, arg in enumerate(args.args):
184
+ if i >= len(args.args) - positional_defaults:
185
+ names.append(arg.arg)
186
+ for idx, default in enumerate(args.kw_defaults):
187
+ if default is not None:
188
+ names.append(args.kwonlyargs[idx].arg)
189
+ return names
190
+
191
+
192
+ def _first_defaulted_line(
193
+ func: "ast.FunctionDef | ast.AsyncFunctionDef",
194
+ ) -> int:
195
+ """Return the source line of the first defaulted parameter.
196
+
197
+ Non-defaulted positional params before the first default are elided
198
+ from the excerpt, so the reader sees only the params that matter.
199
+ """
200
+ args = func.args
201
+ positional_defaults = len(args.defaults)
202
+ first_defaulted_idx = len(args.args) - positional_defaults
203
+ if positional_defaults > 0:
204
+ return args.args[first_defaulted_idx].lineno
205
+ for idx, default in enumerate(args.kw_defaults):
206
+ if default is not None:
207
+ return args.kwonlyargs[idx].lineno
208
+ return func.lineno
209
+
210
+
211
+ def _param_uses_default(
212
+ param_name: str,
213
+ param_index: int,
214
+ call: CallSite,
215
+ ) -> bool:
216
+ """True if the call site does not pass this param (uses the default)."""
217
+ if param_name in call.keywords:
218
+ return False
219
+ if param_index < call.positional_count:
220
+ return False
221
+ return True
222
+
223
+
224
+ def _call_site_notes(
225
+ name: str,
226
+ defaulted_params: list[str],
227
+ corpus: "Corpus",
228
+ ) -> tuple[str, ...]:
229
+ """Return notes describing call sites that use at least one default value.
230
+
231
+ Call sites that override every defaulted param are not listed — they
232
+ don't exercise the default path and only add noise. Call sites that
233
+ use at least one default are listed (capped at ``corpus.max_call_sites``)
234
+ with "and N other(s)" when truncated.
235
+ """
236
+ sites = corpus.call_sites.get(name, ())
237
+ total = len(sites)
238
+ if total == 0:
239
+ return (f"`{name}` not called from anywhere scanned",)
240
+ using_default: list[CallSite] = []
241
+ for site in sites:
242
+ for idx, param_name in enumerate(defaulted_params):
243
+ if _param_uses_default(param_name, idx, site):
244
+ using_default.append(site)
245
+ break
246
+ using_default_sorted = sorted(
247
+ using_default, key=lambda s: (str(s.location.path), s.location.line)
248
+ )
249
+ cap = corpus.max_call_sites
250
+ shown = using_default_sorted[:cap]
251
+ if not shown:
252
+ return (f"All {total} call site(s) override every defaulted parameter",)
253
+ parts = [f"{s.location.path.name}:{s.location.line}" for s in shown]
254
+ remaining = len(using_default) - len(shown)
255
+ if remaining > 0:
256
+ parts.append(f"and {remaining} other(s)")
257
+ sites_str = ", ".join(parts)
258
+ return (
259
+ f"Call sites using defaults ({len(using_default)} of {total}): {sites_str}",
260
+ )
261
+
262
+
263
+ def _signature_end_line(
264
+ node: "ast.FunctionDef | ast.AsyncFunctionDef",
265
+ ) -> int:
266
+ """Return the last line of the ``def`` signature (the ``:`` line)."""
267
+ if not node.body:
268
+ return node.lineno
269
+ first_body = node.body[0]
270
+ return max(node.lineno, first_body.lineno - 1)
271
+
272
+
273
+ def run(source: "SourceFile", corpus: "Corpus") -> list[Finding]:
274
+ """Find functions with defaulted params and attach signature + call-site context."""
275
+ router_names = _framework_object_names(source.tree)
276
+ findings: list[Finding] = []
277
+ for node in ast.walk(source.tree):
278
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
279
+ continue
280
+ if router_names and any(
281
+ _is_route_decorator(deco, router_names) for deco in node.decorator_list
282
+ ):
283
+ continue
284
+ defaulted = _defaulted_param_names(node)
285
+ if not defaulted:
286
+ continue
287
+ jc = justifying_comment(source, node.lineno)
288
+ jc_start = jc[0].start_line if jc else None
289
+ sig_start = _first_defaulted_line(node)
290
+ start = jc_start if jc_start is not None else sig_start
291
+ end = _signature_end_line(node)
292
+ findings.append(
293
+ Finding(
294
+ check_id="optional-param-default",
295
+ path=source.path,
296
+ line=node.lineno,
297
+ column=node.col_offset,
298
+ excerpts=(
299
+ SourceExcerpt(
300
+ path=source.path,
301
+ start_line=start,
302
+ end_line=end,
303
+ ),
304
+ ),
305
+ notes=(
306
+ f"Defaulted params: {', '.join(defaulted)}",
307
+ *_call_site_notes(node.name, defaulted, corpus),
308
+ ),
309
+ )
310
+ )
311
+ return findings
@@ -0,0 +1,96 @@
1
+ """``suppression-comment`` check.
2
+
3
+ Finds ``# pylint: disable=...`` / ``# pyright: ignore`` directives via
4
+ ``tokenize`` (not regex) so they aren't matched by strings or multi-line
5
+ constructs. The source line is attached as an excerpt (extended upward to
6
+ include any justifying comment block immediately above) so the reader can see
7
+ both the suppression and its justification in one contiguous block.
8
+
9
+ When a ``# pylint: disable=...`` is a standalone comment (not a trailing
10
+ comment on a code line), it applies to all lines below until a matching
11
+ ``# pylint: enable=...`` or the end of the file. In that case the excerpt is
12
+ extended downward to show what code is being suppressed, capped at
13
+ ``_MAX_SUPPRESSED_LINES`` lines.
14
+ """
15
+
16
+ import re
17
+ import tokenize
18
+ from typing import TYPE_CHECKING
19
+
20
+ from python_surveyor.checks._util import justifying_comment
21
+ from python_surveyor.model import Finding, SourceExcerpt
22
+
23
+ if TYPE_CHECKING:
24
+ from python_surveyor.scanner import Corpus, SourceFile
25
+
26
+ _SUPPRESSION_RE = re.compile(r"pylint:\s*disable[-\w]*\s*=|pyright:\s*ignore")
27
+ _PYLINT_DISABLE_RE = re.compile(r"pylint:\s*disable[-\w]*\s*=\s*(.+)")
28
+ _ENABLE_RE = re.compile(r"pylint:\s*enable[-\w]*\s*=")
29
+ _MAX_SUPPRESSED_LINES = 10
30
+
31
+
32
+ def _all_pylint_ids_covered(comment: str, covered: frozenset[str]) -> bool:
33
+ m = _PYLINT_DISABLE_RE.search(comment)
34
+ if m is None:
35
+ return False
36
+ ids = {part.strip() for part in m.group(1).split(",") if part.strip()}
37
+ return ids <= covered
38
+
39
+
40
+ def _is_standalone_comment(source: "SourceFile", row: int) -> bool:
41
+ """True if the suppression comment is a whole-line comment, not trailing."""
42
+ line = source.line_text(row)
43
+ return line.strip().startswith("#")
44
+
45
+
46
+ def _find_enable_or_end(source: "SourceFile", row: int) -> int:
47
+ """Return the last line covered by a standalone ``disable`` at ``row``.
48
+
49
+ Walks downward from ``row + 1`` looking for ``# pylint: enable=...``.
50
+ If found, returns the line before it. If not found, returns either
51
+ ``row + _MAX_SUPPRESSED_LINES`` or the last line of the file,
52
+ whichever comes first.
53
+ """
54
+ total = len(source.lines)
55
+ limit = min(row + _MAX_SUPPRESSED_LINES, total)
56
+ for candidate in range(row + 1, limit + 1):
57
+ text = source.line_text(candidate)
58
+ if _ENABLE_RE.search(text):
59
+ return candidate - 1
60
+ return limit
61
+
62
+
63
+ def run(source: "SourceFile", corpus: "Corpus") -> list[Finding]:
64
+ """Find suppression comments and attach source + justifying-comment context."""
65
+ findings: list[Finding] = []
66
+ for token in source.tokens:
67
+ if token.type != tokenize.COMMENT:
68
+ continue
69
+ if not _SUPPRESSION_RE.search(token.string):
70
+ continue
71
+ if _all_pylint_ids_covered(token.string, corpus.covered_pylint_ids):
72
+ continue
73
+ row = token.start[0]
74
+ jc = justifying_comment(source, row)
75
+ start = jc[0].start_line if jc else row
76
+ if _is_standalone_comment(source, row):
77
+ end = _find_enable_or_end(source, row)
78
+ else:
79
+ end = row
80
+ findings.append(
81
+ Finding(
82
+ check_id="suppression-comment",
83
+ path=source.path,
84
+ line=row,
85
+ column=token.start[1],
86
+ excerpts=(
87
+ SourceExcerpt(
88
+ path=source.path,
89
+ start_line=start,
90
+ end_line=end,
91
+ ),
92
+ ),
93
+ notes=(),
94
+ )
95
+ )
96
+ return findings
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-surveyor
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Use python introspection to survey source code for final LLM judgement
5
5
  Author: Dave Cunningham
6
6
  License-Expression: MIT
@@ -13,6 +13,14 @@ python_surveyor.egg-info/dependency_links.txt
13
13
  python_surveyor.egg-info/entry_points.txt
14
14
  python_surveyor.egg-info/requires.txt
15
15
  python_surveyor.egg-info/top_level.txt
16
+ python_surveyor/checks/__init__.py
17
+ python_surveyor/checks/_util.py
18
+ python_surveyor/checks/broad_except.py
19
+ python_surveyor/checks/fixture_naming.py
20
+ python_surveyor/checks/future_annotations.py
21
+ python_surveyor/checks/nontoplevel_imports.py
22
+ python_surveyor/checks/optional_params.py
23
+ python_surveyor/checks/suppressions.py
16
24
  tests/test_broad_except.py
17
25
  tests/test_cli.py
18
26
  tests/test_fixture_naming.py
File without changes