pycodegate 0.1.2__tar.gz → 0.1.3__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 (73) hide show
  1. {pycodegate-0.1.2 → pycodegate-0.1.3}/AGENTS.md +1 -1
  2. {pycodegate-0.1.2 → pycodegate-0.1.3}/PKG-INFO +8 -4
  3. {pycodegate-0.1.2 → pycodegate-0.1.3}/README.md +6 -2
  4. {pycodegate-0.1.2 → pycodegate-0.1.3}/pyproject.toml +7 -3
  5. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/__init__.py +1 -1
  6. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/cli.py +110 -43
  7. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/output.py +42 -26
  8. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/profile.py +56 -48
  9. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/dependencies.py +27 -17
  10. pycodegate-0.1.3/src/pycodegate/rules/imports.py +109 -0
  11. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/structure.py +34 -37
  12. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/score.py +32 -24
  13. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/fixer.py +1 -1
  14. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_output.py +2 -4
  15. {pycodegate-0.1.2 → pycodegate-0.1.3}/uv.lock +1 -1
  16. pycodegate-0.1.3/whitelist.py +14 -0
  17. pycodegate-0.1.2/src/pycodegate/rules/imports.py +0 -96
  18. pycodegate-0.1.2/whitelist.py +0 -14
  19. {pycodegate-0.1.2 → pycodegate-0.1.3}/.github/workflows/ci.yml +0 -0
  20. {pycodegate-0.1.2 → pycodegate-0.1.3}/.github/workflows/publish.yml +0 -0
  21. {pycodegate-0.1.2 → pycodegate-0.1.3}/.gitignore +0 -0
  22. {pycodegate-0.1.2 → pycodegate-0.1.3}/LICENSE +0 -0
  23. {pycodegate-0.1.2 → pycodegate-0.1.3}/docs/superpowers/plans/2026-03-22-python-doctor.md +0 -0
  24. {pycodegate-0.1.2 → pycodegate-0.1.3}/skills/pycodegate/SKILL.md +0 -0
  25. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/_compat.py +0 -0
  26. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/api.py +0 -0
  27. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/config.py +0 -0
  28. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/constants.py +0 -0
  29. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/discover.py +0 -0
  30. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/__init__.py +0 -0
  31. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/architecture.py +0 -0
  32. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/base.py +0 -0
  33. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/complexity.py +0 -0
  34. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/correctness.py +0 -0
  35. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/dead_code.py +0 -0
  36. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/django.py +0 -0
  37. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/fastapi.py +0 -0
  38. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/flask.py +0 -0
  39. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/performance.py +0 -0
  40. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/rules/security.py +0 -0
  41. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/scan.py +0 -0
  42. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/types.py +0 -0
  43. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/__init__.py +0 -0
  44. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/ast_helpers.py +0 -0
  45. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/badge.py +0 -0
  46. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/diff.py +0 -0
  47. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/file_discovery.py +0 -0
  48. {pycodegate-0.1.2 → pycodegate-0.1.3}/src/pycodegate/utils/precommit.py +0 -0
  49. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/conftest.py +0 -0
  50. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/fixtures/basic_python/app.py +0 -0
  51. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/fixtures/clean_project/app.py +0 -0
  52. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/__init__.py +0 -0
  53. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_architecture.py +0 -0
  54. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_complexity.py +0 -0
  55. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_correctness.py +0 -0
  56. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_dead_code.py +0 -0
  57. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_dependencies.py +0 -0
  58. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_django.py +0 -0
  59. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_fastapi.py +0 -0
  60. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_flask.py +0 -0
  61. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_imports.py +0 -0
  62. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_performance.py +0 -0
  63. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_security.py +0 -0
  64. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/rules/test_structure.py +0 -0
  65. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_cli.py +0 -0
  66. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_config.py +0 -0
  67. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_discover.py +0 -0
  68. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_fixer.py +0 -0
  69. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_integration.py +0 -0
  70. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_profile.py +0 -0
  71. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_scan.py +0 -0
  72. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_score.py +0 -0
  73. {pycodegate-0.1.2 → pycodegate-0.1.3}/tests/test_types.py +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- pycodegate is a Python code quality gate that produces a 0–100 health score. It detects your framework, runs parallel lint and dead-code analysis across 40+ rules, and reports findings by severity so you always know what to fix first.
5
+ PyCodeGate is the quality gate for AI-generated Python code. It produces a 0–100 health score by detecting your framework, running parallel lint and dead-code analysis across 40+ rules, and reporting findings by severity — so you can trust what your AI agent just wrote.
6
6
 
7
7
  ## Quick Start
8
8
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycodegate
3
- Version: 0.1.2
4
- Summary: One command. One score. Your Python quality gate.
3
+ Version: 0.1.3
4
+ Summary: Trust, but verify. The quality gate for AI-generated Python code.
5
5
  Project-URL: Homepage, https://github.com/themohitkhare/pycodegate
6
6
  Project-URL: Repository, https://github.com/themohitkhare/pycodegate
7
7
  Project-URL: Issues, https://github.com/themohitkhare/pycodegate/issues
@@ -34,7 +34,7 @@ Description-Content-Type: text/markdown
34
34
 
35
35
  <h1 align="center">PyCodeGate</h1>
36
36
  <p align="center">
37
- <strong>One command. One score. Your Python quality gate.</strong>
37
+ <strong>Trust, but verify. The quality gate for AI-generated Python code.</strong>
38
38
  </p>
39
39
  <p align="center">
40
40
  <a href="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml"><img src="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
@@ -45,9 +45,13 @@ Description-Content-Type: text/markdown
45
45
 
46
46
  ---
47
47
 
48
+ ## Why PyCodeGate?
49
+
50
+ AI coding agents ship code fast — but fast doesn't mean safe. Every `eval()` an LLM drops in, every mutable default it forgets, every circular import it creates is a landmine waiting to go off. PyCodeGate is the trust layer between AI-generated code and your production codebase: one command, one score, zero ambiguity.
51
+
48
52
  ## How it works
49
53
 
50
- PyCodeGate starts by detecting your project's context: framework (Django, FastAPI, Flask), Python version, package manager (uv, poetry, pip), and test framework. That context drives which rules are active — Django projects get SQL injection checks, FastAPI projects get async-correctness checks, and so on.
54
+ PyCodeGate detects your project's context: framework (Django, FastAPI, Flask), Python version, package manager (uv, poetry, pip), and test framework. That context drives which rules are active — Django projects get SQL injection checks, FastAPI projects get async-correctness checks, and so on.
51
55
 
52
56
  It then runs two analysis passes **in parallel**: a lint pass that evaluates 40+ rules across 8 categories (Security, Correctness, Complexity, Architecture, Performance, Structure, Imports, Dead Code), and a dead-code pass powered by [Vulture](https://github.com/jendrikseipp/vulture) that finds unused functions, classes, imports, and variables.
53
57
 
@@ -1,6 +1,6 @@
1
1
  <h1 align="center">PyCodeGate</h1>
2
2
  <p align="center">
3
- <strong>One command. One score. Your Python quality gate.</strong>
3
+ <strong>Trust, but verify. The quality gate for AI-generated Python code.</strong>
4
4
  </p>
5
5
  <p align="center">
6
6
  <a href="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml"><img src="https://github.com/themohitkhare/pycodegate/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
@@ -11,9 +11,13 @@
11
11
 
12
12
  ---
13
13
 
14
+ ## Why PyCodeGate?
15
+
16
+ AI coding agents ship code fast — but fast doesn't mean safe. Every `eval()` an LLM drops in, every mutable default it forgets, every circular import it creates is a landmine waiting to go off. PyCodeGate is the trust layer between AI-generated code and your production codebase: one command, one score, zero ambiguity.
17
+
14
18
  ## How it works
15
19
 
16
- PyCodeGate starts by detecting your project's context: framework (Django, FastAPI, Flask), Python version, package manager (uv, poetry, pip), and test framework. That context drives which rules are active — Django projects get SQL injection checks, FastAPI projects get async-correctness checks, and so on.
20
+ PyCodeGate detects your project's context: framework (Django, FastAPI, Flask), Python version, package manager (uv, poetry, pip), and test framework. That context drives which rules are active — Django projects get SQL injection checks, FastAPI projects get async-correctness checks, and so on.
17
21
 
18
22
  It then runs two analysis passes **in parallel**: a lint pass that evaluates 40+ rules across 8 categories (Security, Correctness, Complexity, Architecture, Performance, Structure, Imports, Dead Code), and a dead-code pass powered by [Vulture](https://github.com/jendrikseipp/vulture) that finds unused functions, classes, imports, and variables.
19
23
 
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "pycodegate"
3
- version = "0.1.2"
4
- description = "One command. One score. Your Python quality gate."
3
+ version = "0.1.3"
4
+ description = "Trust, but verify. The quality gate for AI-generated Python code."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  license = "MIT"
@@ -72,4 +72,8 @@ ignore = ["SIM102"] # nested ifs in AST walking are clearer than combined condi
72
72
 
73
73
  [tool.pycodegate]
74
74
  [tool.pycodegate.ignore]
75
- files = ["tests/fixtures/**"]
75
+ files = ["tests/fixtures/**", "tests/**"]
76
+
77
+ [tool.pycodegate.per-file-suppress]
78
+ # Click CLI entry-points must declare all options as individual parameters
79
+ "src/pycodegate/cli.py" = ["too-many-arguments"]
@@ -1,3 +1,3 @@
1
1
  """Python Doctor - Diagnose your Python project's health."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.1.3"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import sys
6
+ from dataclasses import dataclass
6
7
 
7
8
  import click
8
9
 
@@ -16,6 +17,94 @@ from pycodegate.utils.fixer import run_ruff_fix
16
17
  from pycodegate.utils.precommit import install_precommit_hook
17
18
 
18
19
 
20
+ @dataclass
21
+ class CliOptions:
22
+ """Bundle of all CLI flags passed to main()."""
23
+
24
+ directory: str
25
+ lint: bool
26
+ dead_code: bool
27
+ verbose: bool
28
+ score_only: bool
29
+ json_output: bool
30
+ diff_base: str | None
31
+ fail_on: str
32
+ fix: bool
33
+ profile_override: str | None
34
+ badge: bool
35
+ ci_workflow: bool
36
+ precommit: bool
37
+ min_score: int | None
38
+
39
+
40
+ def _handle_early_exits(opts: CliOptions) -> bool:
41
+ """Handle flags that exit before scanning. Returns True if handled."""
42
+ if opts.ci_workflow:
43
+ click.echo(generate_ci_workflow(), nl=False)
44
+ return True
45
+
46
+ if opts.precommit:
47
+ effective_min = opts.min_score if opts.min_score is not None else 50
48
+ msg = install_precommit_hook(opts.directory, min_score=effective_min)
49
+ click.echo(msg)
50
+ return True
51
+
52
+ return False
53
+
54
+
55
+ def _maybe_run_fix(opts: CliOptions) -> None:
56
+ """Apply ruff auto-fix if --fix was passed."""
57
+ if not opts.fix:
58
+ return
59
+ fixes = run_ruff_fix(opts.directory)
60
+ if fixes == -1:
61
+ click.echo("ruff not found, skipping auto-fix")
62
+ else:
63
+ click.echo(f"Fixed {fixes} issues via ruff")
64
+
65
+
66
+ def _build_config(opts: CliOptions):
67
+ """Load config and apply CLI overrides."""
68
+ config = load_config(opts.directory)
69
+ config.lint = opts.lint
70
+ config.dead_code = opts.dead_code
71
+ config.verbose = opts.verbose
72
+ config.fail_on = opts.fail_on
73
+ if opts.profile_override is not None:
74
+ config.profile = opts.profile_override
75
+ return config
76
+
77
+
78
+ def _output_result(opts: CliOptions, result) -> None:
79
+ """Write scan result to stdout in the requested format."""
80
+ if opts.badge:
81
+ click.echo(generate_badge(result.score.value, result.score.label))
82
+ return
83
+
84
+ if opts.json_output:
85
+ output_json(result)
86
+ elif opts.score_only:
87
+ click.echo(str(result.score.value))
88
+ else:
89
+ print_scan_result(result, verbose=opts.verbose)
90
+
91
+
92
+ def _apply_exit_codes(opts: CliOptions, result) -> None:
93
+ """Exit with non-zero code based on --fail-on and --min-score."""
94
+ has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
95
+ if (opts.fail_on == "error" and has_errors) or (
96
+ opts.fail_on == "warning" and result.diagnostics
97
+ ):
98
+ sys.exit(1)
99
+
100
+ if opts.min_score is not None and result.score.value < opts.min_score:
101
+ click.echo(
102
+ f"pycodegate: score {result.score.value} is below minimum {opts.min_score}",
103
+ err=True,
104
+ )
105
+ sys.exit(1)
106
+
107
+
19
108
  @click.command(context_settings={"help_option_names": ["-h", "--help"]})
20
109
  @click.version_option(__version__, "-v", "--version", prog_name="PyCodeGate")
21
110
  @click.argument("directory", default=".", type=click.Path(exists=True))
@@ -80,52 +169,30 @@ def main(
80
169
  min_score: int | None,
81
170
  ) -> None:
82
171
  """PyCodeGate — Diagnose your Python project's health."""
83
- if ci_workflow:
84
- click.echo(generate_ci_workflow(), nl=False)
85
- return
172
+ opts = CliOptions(
173
+ directory=directory,
174
+ lint=lint,
175
+ dead_code=dead_code,
176
+ verbose=verbose,
177
+ score_only=score_only,
178
+ json_output=json_output,
179
+ diff_base=diff_base,
180
+ fail_on=fail_on,
181
+ fix=fix,
182
+ profile_override=profile_override,
183
+ badge=badge,
184
+ ci_workflow=ci_workflow,
185
+ precommit=precommit,
186
+ min_score=min_score,
187
+ )
86
188
 
87
- if precommit:
88
- effective_min = min_score if min_score is not None else 50
89
- msg = install_precommit_hook(directory, min_score=effective_min)
90
- click.echo(msg)
189
+ if _handle_early_exits(opts):
91
190
  return
92
191
 
93
- if fix:
94
- fixes = run_ruff_fix(directory)
95
- if fixes == -1:
96
- click.echo("ruff not found, skipping auto-fix")
97
- else:
98
- click.echo(f"Fixed {fixes} issues via ruff")
99
-
100
- config = load_config(directory)
101
- config.lint = lint
102
- config.dead_code = dead_code
103
- config.verbose = verbose
104
- config.fail_on = fail_on
105
- if profile_override is not None:
106
- config.profile = profile_override
192
+ _maybe_run_fix(opts)
107
193
 
194
+ config = _build_config(opts)
108
195
  result = scan_project(directory, config, diff_base=diff_base)
109
196
 
110
- if badge:
111
- click.echo(generate_badge(result.score.value, result.score.label))
112
- return
113
-
114
- if json_output:
115
- output_json(result)
116
- elif score_only:
117
- click.echo(str(result.score.value))
118
- else:
119
- print_scan_result(result, verbose=verbose)
120
-
121
- # Exit code based on --fail-on
122
- has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
123
- if (fail_on == "error" and has_errors) or (fail_on == "warning" and result.diagnostics):
124
- sys.exit(1)
125
-
126
- if min_score is not None and result.score.value < min_score:
127
- click.echo(
128
- f"pycodegate: score {result.score.value} is below minimum {min_score}",
129
- err=True,
130
- )
131
- sys.exit(1)
197
+ _output_result(opts, result)
198
+ _apply_exit_codes(opts, result)
@@ -138,8 +138,6 @@ def print_scan_result(result: ScanResult, verbose: bool = False) -> None:
138
138
  # Project info header
139
139
  p = result.project
140
140
  console.print()
141
- from pycodegate import __version__
142
-
143
141
  console.print(f" [bold]PyCodeGate[/bold] — v{__version__}")
144
142
  console.print()
145
143
  console.print(f" [dim]Path:[/dim] {p.path}")
@@ -215,6 +213,47 @@ def output_json(result: ScanResult) -> None:
215
213
  sys.stdout.write(json.dumps(payload, indent=2) + "\n")
216
214
 
217
215
 
216
+ def _print_rule_details(
217
+ console: Console,
218
+ rule_diags: list[Diagnostic],
219
+ verbose: bool,
220
+ ) -> None:
221
+ """Print a single rule line and, in verbose mode, the file locations."""
222
+ rule = rule_diags[0].rule
223
+ count = len(rule_diags)
224
+ sev = rule_diags[0].severity
225
+ icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
226
+ console.print(f" {icon} {rule} × {count}")
227
+ if not verbose:
228
+ return
229
+ for d in rule_diags[:10]:
230
+ console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
231
+ if count > 10:
232
+ console.print(f" [dim]... and {count - 10} more[/dim]")
233
+
234
+
235
+ def _print_category_with_issues(
236
+ console: Console,
237
+ emoji: str,
238
+ name: str,
239
+ earned: int,
240
+ maximum: int,
241
+ cat_diags: list[Diagnostic],
242
+ verbose: bool,
243
+ ) -> None:
244
+ """Print a category header and its grouped rule findings."""
245
+ console.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
246
+ by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
247
+ for d in cat_diags:
248
+ by_rule[d.rule].append(d)
249
+ sorted_rules = sorted(
250
+ by_rule.keys(),
251
+ key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
252
+ )
253
+ for rule in sorted_rules:
254
+ _print_rule_details(console, by_rule[rule], verbose)
255
+
256
+
218
257
  def _print_category_groups(
219
258
  console: Console,
220
259
  diagnostics: list[Diagnostic],
@@ -235,38 +274,15 @@ def _print_category_groups(
235
274
  continue
236
275
  emoji, name = CATEGORY_DISPLAY[cat]
237
276
  earned, maximum = sub_scores.get(cat, (CATEGORY_WEIGHTS[cat], CATEGORY_WEIGHTS[cat]))
238
- # Recompute max from sub_scores (already normalised to 100-point scale)
239
277
  cat_diags = by_category.get(cat, [])
240
278
 
241
279
  if earned == maximum:
242
- # Perfect score for this category
243
280
  console.print(
244
281
  f" [bold]{emoji} {name}[/bold] "
245
282
  f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
246
283
  )
247
284
  console.print(" [green]✓ All clear.[/green]")
248
285
  else:
249
- console.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
250
- # Group by rule within this category
251
- by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
252
- for d in cat_diags:
253
- by_rule[d.rule].append(d)
254
-
255
- # Sort: errors first, then by rule name
256
- sorted_rules = sorted(
257
- by_rule.keys(),
258
- key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
259
- )
260
- for rule in sorted_rules:
261
- rule_diags = by_rule[rule]
262
- count = len(rule_diags)
263
- sev = rule_diags[0].severity
264
- icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
265
- console.print(f" {icon} {rule} × {count}")
266
- if verbose:
267
- for d in rule_diags[:10]:
268
- console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
269
- if count > 10:
270
- console.print(f" [dim]... and {count - 10} more[/dim]")
286
+ _print_category_with_issues(console, emoji, name, earned, maximum, cat_diags, verbose)
271
287
 
272
288
  console.print()
@@ -32,6 +32,56 @@ PROFILES = {
32
32
  }
33
33
 
34
34
 
35
+ def _normalise_dep(dep: str) -> str:
36
+ """Strip version specifiers and extras from a dependency string."""
37
+ return dep.split("[")[0].split(">")[0].split("<")[0].split("=")[0].split("!")[0].strip().lower()
38
+
39
+
40
+ def _deps_from_pyproject(pyproject: Path) -> tuple[set[str], bool, bool]:
41
+ """Parse pyproject.toml and return (deps, has_build_system, has_scripts)."""
42
+ deps: set[str] = set()
43
+ has_build_system = False
44
+ has_scripts = False
45
+ try:
46
+ with open(pyproject, "rb") as f:
47
+ data = tomllib.load(f)
48
+ for dep in data.get("project", {}).get("dependencies", []):
49
+ deps.add(_normalise_dep(dep))
50
+ has_build_system = "build-system" in data
51
+ has_scripts = bool(data.get("project", {}).get("scripts"))
52
+ except (OSError, ValueError, KeyError):
53
+ pass
54
+ return deps, has_build_system, has_scripts
55
+
56
+
57
+ def _deps_from_requirements(path: Path) -> set[str]:
58
+ """Parse requirements files and return the set of normalised dep names."""
59
+ deps: set[str] = set()
60
+ for req_file in ["requirements.txt", "requirements-dev.txt"]:
61
+ req_path = path / req_file
62
+ if not req_path.exists():
63
+ continue
64
+ try:
65
+ for line in req_path.read_text().splitlines():
66
+ line = line.strip()
67
+ if line and not line.startswith("#") and not line.startswith("-"):
68
+ deps.add(_normalise_dep(line))
69
+ except (OSError, ValueError):
70
+ pass
71
+ return deps
72
+
73
+
74
+ def _classify_by_deps(deps: set[str], has_scripts: bool) -> str | None:
75
+ """Return a profile name based on dependencies, or None if undetermined."""
76
+ web_deps = {"flask", "django", "fastapi", "starlette", "tornado", "aiohttp", "sanic", "bottle"}
77
+ if deps & web_deps:
78
+ return "web"
79
+ cli_deps = {"click", "typer", "fire", "cement", "cliff", "argparse"}
80
+ if (deps & cli_deps) or has_scripts:
81
+ return "cli"
82
+ return None
83
+
84
+
35
85
  def detect_profile(project_path: str) -> Profile:
36
86
  """Auto-detect project profile from project structure and dependencies."""
37
87
  path = Path(project_path)
@@ -42,56 +92,15 @@ def detect_profile(project_path: str) -> Profile:
42
92
  has_scripts = False
43
93
 
44
94
  if pyproject.exists():
45
- try:
46
- with open(pyproject, "rb") as f:
47
- data = tomllib.load(f)
48
- # Collect dependency names
49
- for dep in data.get("project", {}).get("dependencies", []):
50
- deps.add(
51
- dep.split("[")[0]
52
- .split(">")[0]
53
- .split("<")[0]
54
- .split("=")[0]
55
- .split("!")[0]
56
- .strip()
57
- .lower()
58
- )
59
- has_build_system = "build-system" in data
60
- has_scripts = bool(data.get("project", {}).get("scripts"))
61
- except Exception:
62
- pass
95
+ pyproject_deps, has_build_system, has_scripts = _deps_from_pyproject(pyproject)
96
+ deps |= pyproject_deps
63
97
 
64
- # Also check requirements.txt
65
- for req_file in ["requirements.txt", "requirements-dev.txt"]:
66
- req_path = path / req_file
67
- if req_path.exists():
68
- try:
69
- for line in req_path.read_text().splitlines():
70
- line = line.strip()
71
- if line and not line.startswith("#") and not line.startswith("-"):
72
- deps.add(
73
- line.split("[")[0]
74
- .split(">")[0]
75
- .split("<")[0]
76
- .split("=")[0]
77
- .split("!")[0]
78
- .strip()
79
- .lower()
80
- )
81
- except Exception:
82
- pass
83
-
84
- # Web frameworks
85
- web_deps = {"flask", "django", "fastapi", "starlette", "tornado", "aiohttp", "sanic", "bottle"}
86
- if deps & web_deps:
87
- return PROFILES["web"]
98
+ deps |= _deps_from_requirements(path)
88
99
 
89
- # CLI tools
90
- cli_deps = {"click", "typer", "fire", "cement", "cliff", "argparse"}
91
- if (deps & cli_deps) or has_scripts:
92
- return PROFILES["cli"]
100
+ profile_name = _classify_by_deps(deps, has_scripts)
101
+ if profile_name is not None:
102
+ return PROFILES[profile_name]
93
103
 
94
- # Library
95
104
  if has_build_system and not has_scripts:
96
105
  return PROFILES["library"]
97
106
 
@@ -103,5 +112,4 @@ def detect_profile(project_path: str) -> Profile:
103
112
  if not has_init and len(py_files) <= 5:
104
113
  return PROFILES["script"]
105
114
 
106
- # Default to library
107
115
  return PROFILES["library"]
@@ -14,15 +14,8 @@ from pycodegate.types import Category, Diagnostic, Severity
14
14
  class DependencyRules:
15
15
  """Dependency vulnerability checks using pip-audit (optional)."""
16
16
 
17
- def check_project(self, project_path: str) -> list[Diagnostic]:
18
- if not shutil.which("pip-audit"):
19
- return []
20
-
21
- path = Path(project_path)
22
- req_file = self._find_or_export_requirements(path)
23
- if not req_file:
24
- return []
25
-
17
+ def _run_pip_audit(self, req_file: Path) -> dict | None:
18
+ """Run pip-audit against *req_file* and return parsed JSON, or None on failure."""
26
19
  try:
27
20
  result = subprocess.run(
28
21
  [
@@ -38,17 +31,14 @@ class DependencyRules:
38
31
  text=True,
39
32
  timeout=60,
40
33
  )
41
- # pip-audit exits non-zero when vulnerabilities found
42
34
  if not result.stdout.strip():
43
- return []
44
- data = json.loads(result.stdout)
35
+ return None
36
+ return json.loads(result.stdout)
45
37
  except (subprocess.TimeoutExpired, json.JSONDecodeError, OSError):
46
- return []
47
- finally:
48
- # Clean up temp file if we created one
49
- if req_file and str(req_file).startswith(tempfile.gettempdir()):
50
- req_file.unlink(missing_ok=True)
38
+ return None
51
39
 
40
+ def _build_vuln_diagnostics(self, data: dict) -> list[Diagnostic]:
41
+ """Convert pip-audit JSON output into Diagnostic objects."""
52
42
  diags: list[Diagnostic] = []
53
43
  for vuln in data.get("dependencies", []):
54
44
  pkg_name = vuln.get("name", "unknown")
@@ -71,6 +61,26 @@ class DependencyRules:
71
61
  )
72
62
  return diags
73
63
 
64
+ def check_project(self, project_path: str) -> list[Diagnostic]:
65
+ if not shutil.which("pip-audit"):
66
+ return []
67
+
68
+ path = Path(project_path)
69
+ req_file = self._find_or_export_requirements(path)
70
+ if not req_file:
71
+ return []
72
+
73
+ try:
74
+ data = self._run_pip_audit(req_file)
75
+ finally:
76
+ # Clean up temp file if we created one
77
+ if req_file and str(req_file).startswith(tempfile.gettempdir()):
78
+ req_file.unlink(missing_ok=True)
79
+
80
+ if data is None:
81
+ return []
82
+ return self._build_vuln_diagnostics(data)
83
+
74
84
  def _find_or_export_requirements(self, path: Path) -> Path | None:
75
85
  # Try existing requirements.txt
76
86
  for name in ["requirements.txt", "requirements.lock"]:
@@ -0,0 +1,109 @@
1
+ """Import rules: circular dependency detection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import os
7
+ from collections import defaultdict
8
+ from pathlib import Path
9
+
10
+ from pycodegate.types import Category, Diagnostic, Severity
11
+
12
+
13
+ class ImportsRules:
14
+ """Project-level import analysis."""
15
+
16
+ def check_project(self, project_path: str, source_files: list[str]) -> list[Diagnostic]:
17
+ """Detect circular imports across the project."""
18
+ return self._check_circular_imports(project_path, source_files)
19
+
20
+ def _collect_imports_for_module(
21
+ self, filepath: str, root: str, graph: dict[str, set[str]], file_map: dict[str, str]
22
+ ) -> None:
23
+ """Parse *filepath* and populate *graph* and *file_map* in-place."""
24
+ try:
25
+ source = Path(filepath).read_text()
26
+ tree = ast.parse(source)
27
+ except (SyntaxError, OSError, UnicodeDecodeError):
28
+ return
29
+
30
+ module_name = self._file_to_module(filepath, root)
31
+ if not module_name:
32
+ return
33
+ file_map[module_name] = filepath
34
+
35
+ for node in ast.walk(tree):
36
+ if isinstance(node, ast.Import):
37
+ for alias in node.names:
38
+ graph[module_name].add(alias.name)
39
+ elif isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
40
+ graph[module_name].add(node.module)
41
+
42
+ def _detect_cycles(
43
+ self,
44
+ graph: dict[str, set[str]],
45
+ file_map: dict[str, str],
46
+ ) -> list[Diagnostic]:
47
+ """Return diagnostics for every direct A↔B cycle in *graph*."""
48
+ diags: list[Diagnostic] = []
49
+ seen_pairs: set[tuple[str, str]] = set()
50
+
51
+ for mod_a, imports in graph.items():
52
+ for mod_b in imports:
53
+ if mod_b not in graph or mod_a not in graph[mod_b]:
54
+ continue
55
+ pair = tuple(sorted([mod_a, mod_b]))
56
+ if pair in seen_pairs:
57
+ continue
58
+ seen_pairs.add(pair)
59
+ diags.append(
60
+ Diagnostic(
61
+ file_path=file_map.get(mod_a, mod_a),
62
+ rule="imports/circular",
63
+ severity=Severity.ERROR,
64
+ category=Category.IMPORTS,
65
+ message=f"Circular import between '{mod_a}' and '{mod_b}'",
66
+ help="Break the cycle by moving shared code to a third module",
67
+ line=1,
68
+ cost=3.0,
69
+ )
70
+ )
71
+
72
+ return diags
73
+
74
+ def _check_circular_imports(
75
+ self, project_path: str, source_files: list[str]
76
+ ) -> list[Diagnostic]:
77
+ # Build import graph: module_name -> set of imported module_names
78
+ graph: dict[str, set[str]] = defaultdict(set)
79
+ file_map: dict[str, str] = {} # module_name -> file_path
80
+
81
+ root = str(Path(project_path).resolve())
82
+
83
+ for filepath in source_files:
84
+ self._collect_imports_for_module(filepath, root, graph, file_map)
85
+
86
+ return self._detect_cycles(graph, file_map)
87
+
88
+ def _file_to_module(self, filepath: str, root: str) -> str | None:
89
+ """Convert a file path to a dotted module name."""
90
+ try:
91
+ rel = os.path.relpath(filepath, root)
92
+ except ValueError:
93
+ return None
94
+
95
+ # Remove .py extension
96
+ if rel.endswith(".py"):
97
+ rel = rel[:-3]
98
+ else:
99
+ return None
100
+
101
+ # Remove __init__ suffix
102
+ if rel.endswith("__init__"):
103
+ rel = rel[:-9].rstrip(os.sep)
104
+
105
+ if not rel:
106
+ return None
107
+
108
+ # Convert path separators to dots
109
+ return rel.replace(os.sep, ".").replace("/", ".")
@@ -49,6 +49,39 @@ class StructureRules:
49
49
  pass
50
50
  return diags
51
51
 
52
+ @staticmethod
53
+ def _count_lines(files: list[str]) -> int:
54
+ """Return total line count across *files*, ignoring unreadable files."""
55
+ return sum(len(Path(f).read_text().splitlines()) for f in files if Path(f).exists())
56
+
57
+ def _check_test_ratio(
58
+ self, path: Path, test_files: list[str], src_files: list[str]
59
+ ) -> list[Diagnostic]:
60
+ """Return a diagnostic if the test-to-source line ratio is too low."""
61
+ test_lines = self._count_lines(test_files)
62
+ src_lines = self._count_lines(src_files)
63
+ if src_lines == 0:
64
+ return []
65
+ ratio = test_lines / src_lines
66
+ if ratio < 0.1:
67
+ cost = 2.0
68
+ elif ratio < 0.3:
69
+ cost = 1.0
70
+ else:
71
+ return []
72
+ return [
73
+ Diagnostic(
74
+ file_path=str(path),
75
+ rule="structure/low-test-ratio",
76
+ severity=Severity.WARNING,
77
+ category=Category.STRUCTURE,
78
+ message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
79
+ help="Add more tests to improve coverage",
80
+ line=0,
81
+ cost=cost,
82
+ )
83
+ ]
84
+
52
85
  def _check_tests(self, path: Path, source_files: list[str]) -> list[Diagnostic]:
53
86
  test_files = [
54
87
  f
@@ -71,43 +104,7 @@ class StructureRules:
71
104
  )
72
105
  ]
73
106
 
74
- # Test ratio check
75
- test_lines = sum(
76
- len(Path(f).read_text().splitlines()) for f in test_files if Path(f).exists()
77
- )
78
- src_lines = sum(
79
- len(Path(f).read_text().splitlines()) for f in src_files if Path(f).exists()
80
- )
81
-
82
- if src_lines > 0:
83
- ratio = test_lines / src_lines
84
- if ratio < 0.1:
85
- return [
86
- Diagnostic(
87
- file_path=str(path),
88
- rule="structure/low-test-ratio",
89
- severity=Severity.WARNING,
90
- category=Category.STRUCTURE,
91
- message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
92
- help="Add more tests to improve coverage",
93
- line=0,
94
- cost=2.0,
95
- )
96
- ]
97
- elif ratio < 0.3:
98
- return [
99
- Diagnostic(
100
- file_path=str(path),
101
- rule="structure/low-test-ratio",
102
- severity=Severity.WARNING,
103
- category=Category.STRUCTURE,
104
- message=f"Test:source ratio is {ratio:.1f} (< 0.3)",
105
- help="Add more tests to improve coverage",
106
- line=0,
107
- cost=1.0,
108
- )
109
- ]
110
- return []
107
+ return self._check_test_ratio(path, test_files, src_files)
111
108
 
112
109
  def _check_readme(self, path: Path) -> list[Diagnostic]:
113
110
  for name in ["README.md", "README.rst", "README", "readme.md"]:
@@ -15,16 +15,10 @@ from pycodegate.constants import (
15
15
  from pycodegate.types import Diagnostic, Score
16
16
 
17
17
 
18
- def calculate_score(
19
- diagnostics: list[Diagnostic], max_deduction_overrides: dict | None = None
20
- ) -> Score:
21
- """Calculate a 0-100 health score from diagnostics using category budgets.
22
-
23
- Each category has a weight that determines its maximum deduction budget.
24
- Within a category, the top 3 findings are counted at full cost; additional
25
- findings apply diminishing returns (10% each) to reward fixing top issues.
26
- """
27
- # 1. Compute max_deduction per category from weights
18
+ def _build_budget(
19
+ max_deduction_overrides: dict | None,
20
+ ) -> dict:
21
+ """Return per-category maximum deduction budget normalised to sum to 100."""
28
22
  total_weight = sum(CATEGORY_WEIGHTS.values())
29
23
  max_deductions = {cat: round(w / total_weight * 100) for cat, w in CATEGORY_WEIGHTS.items()}
30
24
 
@@ -34,19 +28,43 @@ def calculate_score(
34
28
  highest = max(CATEGORY_WEIGHTS, key=CATEGORY_WEIGHTS.get)
35
29
  max_deductions[highest] += diff
36
30
 
37
- # Apply overrides
38
31
  if max_deduction_overrides:
39
32
  for cat, val in max_deduction_overrides.items():
40
33
  if cat in max_deductions:
41
34
  max_deductions[cat] = val
42
35
 
43
- # 2. Group diagnostics by resolved category
36
+ return max_deductions
37
+
38
+
39
+ def _score_label(value: int) -> str:
40
+ """Return a human-readable label for a numeric score."""
41
+ if value >= 90:
42
+ return LABEL_EXCELLENT
43
+ if value >= 75:
44
+ return LABEL_GREAT
45
+ if value >= 50:
46
+ return LABEL_NEEDS_WORK
47
+ return LABEL_CRITICAL
48
+
49
+
50
+ def calculate_score(
51
+ diagnostics: list[Diagnostic], max_deduction_overrides: dict | None = None
52
+ ) -> Score:
53
+ """Calculate a 0-100 health score from diagnostics using category budgets.
54
+
55
+ Each category has a weight that determines its maximum deduction budget.
56
+ Within a category, the top 3 findings are counted at full cost; additional
57
+ findings apply diminishing returns (10% each) to reward fixing top issues.
58
+ """
59
+ max_deductions = _build_budget(max_deduction_overrides)
60
+
61
+ # Group diagnostics by resolved category
44
62
  by_category: dict = defaultdict(list)
45
63
  for d in diagnostics:
46
64
  resolved = FRAMEWORK_CATEGORY_MAP.get(d.category, d.category)
47
65
  by_category[resolved].append(d)
48
66
 
49
- # 3. Calculate deduction per category with diminishing returns
67
+ # Calculate deduction per category with diminishing returns
50
68
  total_deduction = 0.0
51
69
  for cat, diags in by_category.items():
52
70
  costs = sorted([d.cost for d in diags], reverse=True)
@@ -56,14 +74,4 @@ def calculate_score(
56
74
  total_deduction += min(cat_total, cap)
57
75
 
58
76
  value = max(0, round(100 - total_deduction))
59
-
60
- if value >= 90:
61
- label = LABEL_EXCELLENT
62
- elif value >= 75:
63
- label = LABEL_GREAT
64
- elif value >= 50:
65
- label = LABEL_NEEDS_WORK
66
- else:
67
- label = LABEL_CRITICAL
68
-
69
- return Score(value=value, label=label)
77
+ return Score(value=value, label=_score_label(value))
@@ -21,7 +21,7 @@ def run_ruff_fix(project_path: str) -> int:
21
21
  except FileNotFoundError:
22
22
  # ruff is not installed
23
23
  return -1
24
- except Exception:
24
+ except (OSError, subprocess.SubprocessError):
25
25
  return 0
26
26
 
27
27
  # ruff prints something like "Fixed 3 errors." to stdout or stderr
@@ -1,7 +1,9 @@
1
+ import re
1
2
  from io import StringIO
2
3
 
3
4
  from rich.console import Console
4
5
 
6
+ import pycodegate.output as output_mod
5
7
  from pycodegate.output import (
6
8
  format_doctor_face,
7
9
  format_score_bar,
@@ -51,8 +53,6 @@ def _capture_output(result: ScanResult, verbose: bool = False) -> str:
51
53
  buf = StringIO()
52
54
  console = Console(file=buf, highlight=False, markup=True, no_color=True)
53
55
  # Patch console into print_scan_result by monkey-patching module
54
- import pycodegate.output as output_mod
55
-
56
56
  original_console_cls = output_mod.Console
57
57
 
58
58
  class _FakeConsole(Console):
@@ -156,8 +156,6 @@ def test_sub_scores_appear():
156
156
  output = _capture_output(result)
157
157
  # With no diagnostics every category earns its max, so earned==max
158
158
  # Check at least one pattern like "(24/24)" or similar parenthesised numbers
159
- import re
160
-
161
159
  matches = re.findall(r"\(\d+/\d+\)", output)
162
160
  assert len(matches) >= len(
163
161
  [
@@ -203,7 +203,7 @@ wheels = [
203
203
 
204
204
  [[package]]
205
205
  name = "pycodegate"
206
- version = "0.1.1"
206
+ version = "0.1.3"
207
207
  source = { editable = "." }
208
208
  dependencies = [
209
209
  { name = "click" },
@@ -0,0 +1,14 @@
1
+ """Vulture whitelist — these symbols are used but vulture can't detect it."""
2
+
3
+ # Public API — called by external users of the pycodegate package
4
+ import pycodegate.api
5
+ import pycodegate.constants
6
+ import pycodegate.types
7
+ import pycodegate.utils.ast_helpers
8
+
9
+ pycodegate.api.diagnose # public API function
10
+ pycodegate.constants.SCORE_GREAT # exported threshold constant
11
+ pycodegate.constants.SCORE_NEEDS_WORK # exported threshold constant
12
+ pycodegate.types.Diagnostic.column # used by rules that track column offsets
13
+ pycodegate.types.ProjectInfo.has_type_hints # accessed in output/templates
14
+ pycodegate.utils.ast_helpers.parse_file # utility used by rule checkers
@@ -1,96 +0,0 @@
1
- """Import rules: circular dependency detection."""
2
-
3
- from __future__ import annotations
4
-
5
- import ast
6
- import os
7
- from collections import defaultdict
8
- from pathlib import Path
9
-
10
- from pycodegate.types import Category, Diagnostic, Severity
11
-
12
-
13
- class ImportsRules:
14
- """Project-level import analysis."""
15
-
16
- def check_project(self, project_path: str, source_files: list[str]) -> list[Diagnostic]:
17
- """Detect circular imports across the project."""
18
- return self._check_circular_imports(project_path, source_files)
19
-
20
- def _check_circular_imports(
21
- self, project_path: str, source_files: list[str]
22
- ) -> list[Diagnostic]:
23
- # Build import graph: module_name -> set of imported module_names
24
- graph: dict[str, set[str]] = defaultdict(set)
25
- file_map: dict[str, str] = {} # module_name -> file_path
26
-
27
- root = Path(project_path).resolve()
28
-
29
- for filepath in source_files:
30
- try:
31
- source = Path(filepath).read_text()
32
- tree = ast.parse(source)
33
- except (SyntaxError, OSError, UnicodeDecodeError):
34
- continue
35
-
36
- module_name = self._file_to_module(filepath, str(root))
37
- if not module_name:
38
- continue
39
- file_map[module_name] = filepath
40
-
41
- for node in ast.walk(tree):
42
- if isinstance(node, ast.Import):
43
- for alias in node.names:
44
- graph[module_name].add(alias.name)
45
- elif isinstance(node, ast.ImportFrom):
46
- if node.module and node.level == 0:
47
- graph[module_name].add(node.module)
48
-
49
- # Detect direct A↔B cycles
50
- diags: list[Diagnostic] = []
51
- seen_pairs: set[tuple[str, str]] = set()
52
-
53
- for mod_a, imports in graph.items():
54
- for mod_b in imports:
55
- if mod_b in graph and mod_a in graph[mod_b]:
56
- pair = tuple(sorted([mod_a, mod_b]))
57
- if pair not in seen_pairs:
58
- seen_pairs.add(pair)
59
- file_a = file_map.get(mod_a, mod_a)
60
- diags.append(
61
- Diagnostic(
62
- file_path=file_a,
63
- rule="imports/circular",
64
- severity=Severity.ERROR,
65
- category=Category.IMPORTS,
66
- message=f"Circular import between '{mod_a}' and '{mod_b}'",
67
- help="Break the cycle by moving shared code to a third module",
68
- line=1,
69
- cost=3.0,
70
- )
71
- )
72
-
73
- return diags
74
-
75
- def _file_to_module(self, filepath: str, root: str) -> str | None:
76
- """Convert a file path to a dotted module name."""
77
- try:
78
- rel = os.path.relpath(filepath, root)
79
- except ValueError:
80
- return None
81
-
82
- # Remove .py extension
83
- if rel.endswith(".py"):
84
- rel = rel[:-3]
85
- else:
86
- return None
87
-
88
- # Remove __init__ suffix
89
- if rel.endswith("__init__"):
90
- rel = rel[:-9].rstrip(os.sep)
91
-
92
- if not rel:
93
- return None
94
-
95
- # Convert path separators to dots
96
- return rel.replace(os.sep, ".").replace("/", ".")
@@ -1,14 +0,0 @@
1
- """Vulture whitelist — these symbols are used but vulture can't detect it."""
2
-
3
- # Public API
4
- from python_doctor.api import diagnose # noqa: F401
5
-
6
- # Dataclass fields accessed dynamically or in templates
7
- from python_doctor.types import ProjectInfo, Score # noqa: F401
8
-
9
- ProjectInfo.has_type_hints # type: ignore[truthy-function]
10
- ProjectInfo.source_file_count # type: ignore[truthy-function]
11
- Score.label # type: ignore[truthy-function]
12
-
13
- # AST helper used by rules
14
- from python_doctor.utils.ast_helpers import parse_file # noqa: F401
File without changes
File without changes
File without changes
File without changes