pycodegate 0.1.2__tar.gz → 0.2.0__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 (74) hide show
  1. {pycodegate-0.1.2 → pycodegate-0.2.0}/.github/workflows/ci.yml +2 -2
  2. {pycodegate-0.1.2 → pycodegate-0.2.0}/AGENTS.md +1 -1
  3. {pycodegate-0.1.2 → pycodegate-0.2.0}/PKG-INFO +8 -4
  4. {pycodegate-0.1.2 → pycodegate-0.2.0}/README.md +6 -2
  5. {pycodegate-0.1.2 → pycodegate-0.2.0}/pyproject.toml +7 -3
  6. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/__init__.py +1 -1
  7. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/cli.py +123 -44
  8. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/output.py +104 -26
  9. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/profile.py +56 -48
  10. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/dependencies.py +27 -17
  11. pycodegate-0.2.0/src/pycodegate/rules/imports.py +109 -0
  12. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/security.py +94 -0
  13. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/structure.py +34 -37
  14. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/score.py +32 -24
  15. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/fixer.py +1 -1
  16. pycodegate-0.2.0/tests/rules/test_security_new.py +32 -0
  17. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_cli.py +11 -0
  18. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_output.py +2 -4
  19. {pycodegate-0.1.2 → pycodegate-0.2.0}/uv.lock +1 -1
  20. pycodegate-0.2.0/whitelist.py +14 -0
  21. pycodegate-0.1.2/src/pycodegate/rules/imports.py +0 -96
  22. pycodegate-0.1.2/whitelist.py +0 -14
  23. {pycodegate-0.1.2 → pycodegate-0.2.0}/.github/workflows/publish.yml +0 -0
  24. {pycodegate-0.1.2 → pycodegate-0.2.0}/.gitignore +0 -0
  25. {pycodegate-0.1.2 → pycodegate-0.2.0}/LICENSE +0 -0
  26. {pycodegate-0.1.2 → pycodegate-0.2.0}/docs/superpowers/plans/2026-03-22-python-doctor.md +0 -0
  27. {pycodegate-0.1.2 → pycodegate-0.2.0}/skills/pycodegate/SKILL.md +0 -0
  28. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/_compat.py +0 -0
  29. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/api.py +0 -0
  30. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/config.py +0 -0
  31. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/constants.py +0 -0
  32. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/discover.py +0 -0
  33. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/__init__.py +0 -0
  34. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/architecture.py +0 -0
  35. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/base.py +0 -0
  36. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/complexity.py +0 -0
  37. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/correctness.py +0 -0
  38. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/dead_code.py +0 -0
  39. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/django.py +0 -0
  40. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/fastapi.py +0 -0
  41. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/flask.py +0 -0
  42. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/rules/performance.py +0 -0
  43. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/scan.py +0 -0
  44. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/types.py +0 -0
  45. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/__init__.py +0 -0
  46. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/ast_helpers.py +0 -0
  47. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/badge.py +0 -0
  48. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/diff.py +0 -0
  49. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/file_discovery.py +0 -0
  50. {pycodegate-0.1.2 → pycodegate-0.2.0}/src/pycodegate/utils/precommit.py +0 -0
  51. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/conftest.py +0 -0
  52. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/fixtures/basic_python/app.py +0 -0
  53. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/fixtures/clean_project/app.py +0 -0
  54. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/__init__.py +0 -0
  55. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_architecture.py +0 -0
  56. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_complexity.py +0 -0
  57. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_correctness.py +0 -0
  58. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_dead_code.py +0 -0
  59. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_dependencies.py +0 -0
  60. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_django.py +0 -0
  61. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_fastapi.py +0 -0
  62. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_flask.py +0 -0
  63. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_imports.py +0 -0
  64. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_performance.py +0 -0
  65. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_security.py +0 -0
  66. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/rules/test_structure.py +0 -0
  67. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_config.py +0 -0
  68. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_discover.py +0 -0
  69. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_fixer.py +0 -0
  70. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_integration.py +0 -0
  71. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_profile.py +0 -0
  72. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_scan.py +0 -0
  73. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_score.py +0 -0
  74. {pycodegate-0.1.2 → pycodegate-0.2.0}/tests/test_types.py +0 -0
@@ -26,8 +26,8 @@ jobs:
26
26
  - name: Run tests
27
27
  run: uv run pytest --tb=short -q
28
28
 
29
- - name: Run py-gate on itself
30
- run: uv run py-gate . --fail-on error
29
+ - name: Run pycodegate on itself
30
+ run: uv run pycodegate . --fail-on error
31
31
 
32
32
  lint:
33
33
  runs-on: ubuntu-latest
@@ -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.2.0
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.2.0"
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.2.0"
@@ -3,12 +3,13 @@
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
 
9
10
  from pycodegate import __version__
10
11
  from pycodegate.config import load_config
11
- from pycodegate.output import output_json, print_scan_result
12
+ from pycodegate.output import output_json, output_sarif, print_scan_result
12
13
  from pycodegate.scan import scan_project
13
14
  from pycodegate.types import Severity
14
15
  from pycodegate.utils.badge import generate_badge, generate_ci_workflow
@@ -16,6 +17,97 @@ 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
+ sarif_output: bool
39
+
40
+
41
+ def _handle_early_exits(opts: CliOptions) -> bool:
42
+ """Handle flags that exit before scanning. Returns True if handled."""
43
+ if opts.ci_workflow:
44
+ click.echo(generate_ci_workflow(), nl=False)
45
+ return True
46
+
47
+ if opts.precommit:
48
+ effective_min = opts.min_score if opts.min_score is not None else 50
49
+ msg = install_precommit_hook(opts.directory, min_score=effective_min)
50
+ click.echo(msg)
51
+ return True
52
+
53
+ return False
54
+
55
+
56
+ def _maybe_run_fix(opts: CliOptions) -> None:
57
+ """Apply ruff auto-fix if --fix was passed."""
58
+ if not opts.fix:
59
+ return
60
+ fixes = run_ruff_fix(opts.directory)
61
+ if fixes == -1:
62
+ click.echo("ruff not found, skipping auto-fix")
63
+ else:
64
+ click.echo(f"Fixed {fixes} issues via ruff")
65
+
66
+
67
+ def _build_config(opts: CliOptions):
68
+ """Load config and apply CLI overrides."""
69
+ config = load_config(opts.directory)
70
+ config.lint = opts.lint
71
+ config.dead_code = opts.dead_code
72
+ config.verbose = opts.verbose
73
+ config.fail_on = opts.fail_on
74
+ if opts.profile_override is not None:
75
+ config.profile = opts.profile_override
76
+ return config
77
+
78
+
79
+ def _output_result(opts: CliOptions, result) -> None:
80
+ """Write scan result to stdout in the requested format."""
81
+ if opts.badge:
82
+ click.echo(generate_badge(result.score.value, result.score.label))
83
+ return
84
+
85
+ if opts.sarif_output:
86
+ output_sarif(result)
87
+ elif opts.json_output:
88
+ output_json(result)
89
+ elif opts.score_only:
90
+ click.echo(str(result.score.value))
91
+ else:
92
+ print_scan_result(result, verbose=opts.verbose)
93
+
94
+
95
+ def _apply_exit_codes(opts: CliOptions, result) -> None:
96
+ """Exit with non-zero code based on --fail-on and --min-score."""
97
+ has_errors = any(d.severity == Severity.ERROR for d in result.diagnostics)
98
+ if (opts.fail_on == "error" and has_errors) or (
99
+ opts.fail_on == "warning" and result.diagnostics
100
+ ):
101
+ sys.exit(1)
102
+
103
+ if opts.min_score is not None and result.score.value < opts.min_score:
104
+ click.echo(
105
+ f"pycodegate: score {result.score.value} is below minimum {opts.min_score}",
106
+ err=True,
107
+ )
108
+ sys.exit(1)
109
+
110
+
19
111
  @click.command(context_settings={"help_option_names": ["-h", "--help"]})
20
112
  @click.version_option(__version__, "-v", "--version", prog_name="PyCodeGate")
21
113
  @click.argument("directory", default=".", type=click.Path(exists=True))
@@ -63,6 +155,13 @@ from pycodegate.utils.precommit import install_precommit_hook
63
155
  default=None,
64
156
  help="Minimum score threshold (exit 1 if below).",
65
157
  )
158
+ @click.option(
159
+ "--sarif",
160
+ "sarif_output",
161
+ is_flag=True,
162
+ default=False,
163
+ help="Output in SARIF format for GitHub Code Scanning.",
164
+ )
66
165
  def main(
67
166
  directory: str,
68
167
  lint: bool,
@@ -78,54 +177,34 @@ def main(
78
177
  ci_workflow: bool,
79
178
  precommit: bool,
80
179
  min_score: int | None,
180
+ sarif_output: bool,
81
181
  ) -> None:
82
182
  """PyCodeGate — Diagnose your Python project's health."""
83
- if ci_workflow:
84
- click.echo(generate_ci_workflow(), nl=False)
85
- return
183
+ opts = CliOptions(
184
+ directory=directory,
185
+ lint=lint,
186
+ dead_code=dead_code,
187
+ verbose=verbose,
188
+ score_only=score_only,
189
+ json_output=json_output,
190
+ diff_base=diff_base,
191
+ fail_on=fail_on,
192
+ fix=fix,
193
+ profile_override=profile_override,
194
+ badge=badge,
195
+ ci_workflow=ci_workflow,
196
+ precommit=precommit,
197
+ min_score=min_score,
198
+ sarif_output=sarif_output,
199
+ )
86
200
 
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)
201
+ if _handle_early_exits(opts):
91
202
  return
92
203
 
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
204
+ _maybe_run_fix(opts)
107
205
 
206
+ config = _build_config(opts)
108
207
  result = scan_project(directory, config, diff_base=diff_base)
109
208
 
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)
209
+ _output_result(opts, result)
210
+ _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,109 @@ def output_json(result: ScanResult) -> None:
215
213
  sys.stdout.write(json.dumps(payload, indent=2) + "\n")
216
214
 
217
215
 
216
+ def _sarif_rules(diagnostics: list[Diagnostic]) -> list[dict]:
217
+ """Build SARIF rules registry from unique diagnostic rules."""
218
+ seen: dict[str, Diagnostic] = {}
219
+ for d in diagnostics:
220
+ if d.rule not in seen:
221
+ seen[d.rule] = d
222
+ return [
223
+ {
224
+ "id": rule_id,
225
+ "name": rule_id,
226
+ "shortDescription": {"text": diag.message},
227
+ "helpUri": "",
228
+ "help": {"text": diag.help},
229
+ }
230
+ for rule_id, diag in seen.items()
231
+ ]
232
+
233
+
234
+ def _sarif_results(diagnostics: list[Diagnostic]) -> list[dict]:
235
+ """Build SARIF results array from diagnostics."""
236
+ return [
237
+ {
238
+ "ruleId": d.rule,
239
+ "level": "error" if d.severity == Severity.ERROR else "warning",
240
+ "message": {"text": d.message},
241
+ "locations": [
242
+ {
243
+ "physicalLocation": {
244
+ "artifactLocation": {"uri": d.file_path},
245
+ "region": {
246
+ "startLine": d.line,
247
+ "startColumn": (d.column or 0) + 1,
248
+ },
249
+ }
250
+ }
251
+ ],
252
+ }
253
+ for d in diagnostics
254
+ ]
255
+
256
+
257
+ def output_sarif(result: ScanResult) -> None:
258
+ """Output the scan result in SARIF 2.1.0 format for GitHub Code Scanning."""
259
+ sarif = {
260
+ "version": "2.1.0",
261
+ "$schema": "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json",
262
+ "runs": [
263
+ {
264
+ "tool": {
265
+ "driver": {
266
+ "name": "PyCodeGate",
267
+ "version": __version__,
268
+ "rules": _sarif_rules(result.diagnostics),
269
+ }
270
+ },
271
+ "results": _sarif_results(result.diagnostics),
272
+ }
273
+ ],
274
+ }
275
+ sys.stdout.write(json.dumps(sarif, indent=2) + "\n")
276
+
277
+
278
+ def _print_rule_details(
279
+ console: Console,
280
+ rule_diags: list[Diagnostic],
281
+ verbose: bool,
282
+ ) -> None:
283
+ """Print a single rule line and, in verbose mode, the file locations."""
284
+ rule = rule_diags[0].rule
285
+ count = len(rule_diags)
286
+ sev = rule_diags[0].severity
287
+ icon = "[red]✗[/red]" if sev == Severity.ERROR else "[yellow]⚠[/yellow]"
288
+ console.print(f" {icon} {rule} × {count}")
289
+ if not verbose:
290
+ return
291
+ for d in rule_diags[:10]:
292
+ console.print(f" [dim]{d.file_path}:{d.line}[/dim]")
293
+ if count > 10:
294
+ console.print(f" [dim]... and {count - 10} more[/dim]")
295
+
296
+
297
+ def _print_category_with_issues(
298
+ console: Console,
299
+ emoji: str,
300
+ name: str,
301
+ earned: int,
302
+ maximum: int,
303
+ cat_diags: list[Diagnostic],
304
+ verbose: bool,
305
+ ) -> None:
306
+ """Print a category header and its grouped rule findings."""
307
+ console.print(f" [bold]{emoji} {name}[/bold] [yellow]({earned}/{maximum})[/yellow]")
308
+ by_rule: dict[str, list[Diagnostic]] = defaultdict(list)
309
+ for d in cat_diags:
310
+ by_rule[d.rule].append(d)
311
+ sorted_rules = sorted(
312
+ by_rule.keys(),
313
+ key=lambda r: (0 if by_rule[r][0].severity == Severity.ERROR else 1, r),
314
+ )
315
+ for rule in sorted_rules:
316
+ _print_rule_details(console, by_rule[rule], verbose)
317
+
318
+
218
319
  def _print_category_groups(
219
320
  console: Console,
220
321
  diagnostics: list[Diagnostic],
@@ -235,38 +336,15 @@ def _print_category_groups(
235
336
  continue
236
337
  emoji, name = CATEGORY_DISPLAY[cat]
237
338
  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
339
  cat_diags = by_category.get(cat, [])
240
340
 
241
341
  if earned == maximum:
242
- # Perfect score for this category
243
342
  console.print(
244
343
  f" [bold]{emoji} {name}[/bold] "
245
344
  f"[green]({earned}/{maximum})[/green] [green]✓[/green]"
246
345
  )
247
346
  console.print(" [green]✓ All clear.[/green]")
248
347
  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]")
348
+ _print_category_with_issues(console, emoji, name, earned, maximum, cat_diags, verbose)
271
349
 
272
350
  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"]: