coding-convention-reviewer 0.1.0__py3-none-any.whl

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 (46) hide show
  1. coding_convention_reviewer-0.1.0.dist-info/METADATA +22 -0
  2. coding_convention_reviewer-0.1.0.dist-info/RECORD +46 -0
  3. coding_convention_reviewer-0.1.0.dist-info/WHEEL +4 -0
  4. coding_convention_reviewer-0.1.0.dist-info/entry_points.txt +2 -0
  5. reviewer/__init__.py +3 -0
  6. reviewer/__main__.py +5 -0
  7. reviewer/cli.py +106 -0
  8. reviewer/core/__init__.py +1 -0
  9. reviewer/core/config/__init__.py +4 -0
  10. reviewer/core/config/loader.py +36 -0
  11. reviewer/core/config/models.py +43 -0
  12. reviewer/core/engine/__init__.py +5 -0
  13. reviewer/core/engine/models.py +39 -0
  14. reviewer/core/engine/registry.py +31 -0
  15. reviewer/core/engine/runner.py +62 -0
  16. reviewer/core/engine/suppression.py +30 -0
  17. reviewer/core/logging.py +29 -0
  18. reviewer/core/metrics.py +50 -0
  19. reviewer/core/parser/__init__.py +5 -0
  20. reviewer/core/parser/base.py +23 -0
  21. reviewer/core/parser/cache.py +31 -0
  22. reviewer/core/parser/python_ast.py +20 -0
  23. reviewer/core/parser/typescript.py +43 -0
  24. reviewer/core/reporting/__init__.py +4 -0
  25. reviewer/core/reporting/models.py +44 -0
  26. reviewer/core/reporting/reporters.py +88 -0
  27. reviewer/core/scanner.py +57 -0
  28. reviewer/core/service.py +109 -0
  29. reviewer/core/storage/__init__.py +3 -0
  30. reviewer/core/storage/store.py +116 -0
  31. reviewer/plugins/__init__.py +1 -0
  32. reviewer/plugins/defaults.py +16 -0
  33. reviewer/plugins/django/__init__.py +3 -0
  34. reviewer/plugins/django/plugin.py +30 -0
  35. reviewer/plugins/django/rules.py +150 -0
  36. reviewer/plugins/fastapi/__init__.py +3 -0
  37. reviewer/plugins/fastapi/plugin.py +36 -0
  38. reviewer/plugins/fastapi/rules.py +154 -0
  39. reviewer/plugins/python/__init__.py +3 -0
  40. reviewer/plugins/python/plugin.py +31 -0
  41. reviewer/plugins/python/rules.py +129 -0
  42. reviewer/plugins/react/__init__.py +3 -0
  43. reviewer/plugins/react/plugin.py +128 -0
  44. reviewer/plugins/react/rules.py +597 -0
  45. reviewer/server/__init__.py +1 -0
  46. reviewer/server/app.py +416 -0
@@ -0,0 +1,22 @@
1
+ Metadata-Version: 2.4
2
+ Name: coding-convention-reviewer
3
+ Version: 0.1.0
4
+ Summary: Deterministic, plugin-based coding convention reviewer.
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: cachetools<6,>=5.3
7
+ Requires-Dist: fastapi<1,>=0.115
8
+ Requires-Dist: prometheus-client<1,>=0.20
9
+ Requires-Dist: pydantic<3,>=2.7
10
+ Requires-Dist: python-dotenv<2,>=1.0
11
+ Requires-Dist: pyyaml<7,>=6.0
12
+ Requires-Dist: rich<15,>=13.7
13
+ Requires-Dist: slowapi<1,>=0.1
14
+ Requires-Dist: structlog<26,>=24.0
15
+ Requires-Dist: tree-sitter-typescript<0.24,>=0.20
16
+ Requires-Dist: tree-sitter<0.26,>=0.22
17
+ Requires-Dist: typer<1,>=0.12
18
+ Requires-Dist: uvicorn<1,>=0.30
19
+ Requires-Dist: websockets<15,>=12.0
20
+ Provides-Extra: test
21
+ Requires-Dist: httpx<1,>=0.27; extra == 'test'
22
+ Requires-Dist: pytest<9,>=8.2; extra == 'test'
@@ -0,0 +1,46 @@
1
+ reviewer/__init__.py,sha256=JfD0oiKF_AvIvbQePxznINztpBfIQaCoaO0j9An8EcM,65
2
+ reviewer/__main__.py,sha256=r2ZWgN-PH7PDvutkbq1V-EC8aRPcFNxmIdYyDTUKssU,68
3
+ reviewer/cli.py,sha256=aW-hKbYe7r0bO_rmOVjO3iMCes3lKofIjtcMiqTOLdc,4243
4
+ reviewer/core/__init__.py,sha256=2vKGcQOJarEvL7d36BeS3pzYUNieApvW2WKTq0Q-Dgw,40
5
+ reviewer/core/logging.py,sha256=dpQ03MdK30Q6WpUMjId50sHh7bahLH-kAREscO1nf44,916
6
+ reviewer/core/metrics.py,sha256=2S6mFmYzVyL0xPbdcTjIov-uBnw5lmZB3f5P-2I8l3A,1089
7
+ reviewer/core/scanner.py,sha256=15htLVB5KA0GdNWiNZNGpZrtq-u0fqWtTw9mJhNbuh0,1697
8
+ reviewer/core/service.py,sha256=ofyR-idK1Sh7FM5UX3lNcHZ36jzKpnzow6RxlazdPd0,3486
9
+ reviewer/core/config/__init__.py,sha256=cwsMA5CQDPnhXcQWBMLsjsOqreLcCwYqbBSU8iQ308o,204
10
+ reviewer/core/config/loader.py,sha256=hpphaFSJhMPpIcs4U2XotdLbnQKJGJ7BFAiVBAxorBI,1051
11
+ reviewer/core/config/models.py,sha256=9xxNjIALsT32i4H98_IDojXmL4XD9ZhWql-iKeq1oG0,1146
12
+ reviewer/core/engine/__init__.py,sha256=37kkpAcMSJhewQEvLz_7Tkj_ZquhYkLq4R75ju6N2jU,251
13
+ reviewer/core/engine/models.py,sha256=v81dvKLB_8KyxKme9-BGS4kO71_2jKSIORnml-CAH50,839
14
+ reviewer/core/engine/registry.py,sha256=8lkWtqUdnYftDHteXrQ72Q8VskxBLME9gh0ZCzVjUMo,948
15
+ reviewer/core/engine/runner.py,sha256=GqnxtBJ2En1Iu9dppNPCHinL9wTccwjXUa8PqmAEjrk,2287
16
+ reviewer/core/engine/suppression.py,sha256=7rDhWu4rdzeSawTKkIEOrj5Z_ynsver25KyJ8UMU1Xk,1013
17
+ reviewer/core/parser/__init__.py,sha256=YpF97w7hsxuMMtAGQMhhrg4GxmQWtSJvlCkeHgV4OKg,255
18
+ reviewer/core/parser/base.py,sha256=4IGKZV7FrGCGduQBhKDejrgZSnfRmo_Q68Zn7Q-jszY,424
19
+ reviewer/core/parser/cache.py,sha256=pO0-2S0PbjGZe3CXs-pftkKfUdw3asRVX27LZVBEJ8s,698
20
+ reviewer/core/parser/python_ast.py,sha256=8PETtH8OI1CJe062Lidb88VpBPlk34ZXmhkdizXfJZ4,476
21
+ reviewer/core/parser/typescript.py,sha256=h96VvkF-0tSuXqCSF5NS0ujqzBpvSi8yTwFQNHdOMRk,1312
22
+ reviewer/core/reporting/__init__.py,sha256=bk2icz3G2urgAsJMGqpTsTfa6HsdcTv8hv4ZdaeRFAA,265
23
+ reviewer/core/reporting/models.py,sha256=b9iaz9-Zbw13v9CDxvQ9HUP6Ubgl47u8k-yv_qQyaTk,916
24
+ reviewer/core/reporting/reporters.py,sha256=IkOSM0SUvTh3dRf_dCT9bCq5Nmtsno2_yxPBbFJlNCI,2869
25
+ reviewer/core/storage/__init__.py,sha256=D6KNz291urHI68hXf1DMvPgdf7qT5FkB0bXVhGSbfVM,75
26
+ reviewer/core/storage/store.py,sha256=J4OXg-Ff4J6NRXzPSQ6-qspvzDjfAfWdrxlz6AUOEC0,4281
27
+ reviewer/plugins/__init__.py,sha256=I_L4YzsMHMFCKJPoSw3_R_3-p0DPtOwFNL7WK-aRgO8,37
28
+ reviewer/plugins/defaults.py,sha256=nBewnb4wxdGbDR0hDgaTLhR_0ou-01ixH5wBt4Whfo4,667
29
+ reviewer/plugins/django/__init__.py,sha256=Ne5ZmuCPu3ovGmhgn6TjlGr1ZkjMASVyCW_Qq1C8PcI,86
30
+ reviewer/plugins/django/plugin.py,sha256=yBmetLbUxDd9bzwlwIJJDiL1bAzxi_Xs6gFkybbTLVk,1266
31
+ reviewer/plugins/django/rules.py,sha256=16_OKUzXNaSnzcgBUFoaXAHAjNIFtlXA8S2LWexR0Zk,6138
32
+ reviewer/plugins/fastapi/__init__.py,sha256=oFqvqYMQ2oW8daGni9uuvv6sm_RCLedU_BDFoKGp0Ps,87
33
+ reviewer/plugins/fastapi/plugin.py,sha256=jJATXYlfaTEPykswOGZECuoYuMHp7Ib5dLWjgZp2urA,1760
34
+ reviewer/plugins/fastapi/rules.py,sha256=3aImWAsbHsMEwopfeSxjjrCuGbjWAuROKAq-MgzndSU,6067
35
+ reviewer/plugins/python/__init__.py,sha256=ILNyG3VHMnmujYjkkylwmKI9XCCIZMMhXLvgP3FVRBo,86
36
+ reviewer/plugins/python/plugin.py,sha256=hI3d9wTPwV89HTyoQnDiKzlaljAb-js7xAYZpuiuJB4,1317
37
+ reviewer/plugins/python/rules.py,sha256=BPuiaXn15iHvbmv9eX-J8uGhXZQWOUMvxlDtIWrkL4M,5161
38
+ reviewer/plugins/react/__init__.py,sha256=Kg7wf6YuzFOWvb3oP-bBOqTiwzj1QjGky1MN2Wi_VGE,85
39
+ reviewer/plugins/react/plugin.py,sha256=5Oi__GhQLO648-_qzWS_63SgvUcNQ6wsIoLo0Gqgxsk,4561
40
+ reviewer/plugins/react/rules.py,sha256=9Jr_DbZ0YCC-C8t9O4r9gWazzxsl-cNvOXG8lDIiIhI,23359
41
+ reviewer/server/__init__.py,sha256=Rqy8v4XzxCvTPQfWzCfALkaCJ1sml82a1K5ZLsRqNtw,44
42
+ reviewer/server/app.py,sha256=xC2x90tD5g1Gk2jLfLh38_N89-P2wJXIvdvvJoW1wH0,15231
43
+ coding_convention_reviewer-0.1.0.dist-info/METADATA,sha256=XE5fEHQPbxyhjOHJzHAQn7PmFfXyVqSDoccJXXQ_kiI,765
44
+ coding_convention_reviewer-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
45
+ coding_convention_reviewer-0.1.0.dist-info/entry_points.txt,sha256=GWn2Qn3T74NOhzOieyzZRjYNI5fV7Mprb6RLDJCPP5g,46
46
+ coding_convention_reviewer-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ reviewer = reviewer.cli:app
reviewer/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Coding Convention Reviewer package."""
2
+
3
+ __version__ = "0.1.0"
reviewer/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from reviewer.cli import app
2
+
3
+
4
+ if __name__ == "__main__":
5
+ app()
reviewer/cli.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from reviewer.core.engine import PluginRegistry
10
+ from reviewer.core.logging import configure_logging, get_logger
11
+ from reviewer.core.reporting import console_report, github_report, json_report, sarif_report
12
+ from reviewer.core.reporting.models import Severity
13
+ from reviewer.core.service import scan_project
14
+ from reviewer.plugins.defaults import create_default_registry
15
+
16
+ app = typer.Typer(help="Deterministic coding convention reviewer.")
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ @app.callback()
21
+ def main() -> None:
22
+ """Deterministic coding convention reviewer."""
23
+
24
+
25
+ def default_registry() -> PluginRegistry:
26
+ return create_default_registry()
27
+
28
+
29
+ def _has_failing_findings(severities: list[Severity]) -> bool:
30
+ return any(severity in {Severity.WARNING, Severity.ERROR} for severity in severities)
31
+
32
+
33
+ @app.command()
34
+ def scan(
35
+ target: Annotated[Path, typer.Argument(help="File or directory to scan.")] = Path("."),
36
+ config: Annotated[Path | None, typer.Option("--config", "-c", help="Path to reviewer.yaml.")] = None,
37
+ json_output: Annotated[bool, typer.Option("--json", help="Print machine-readable JSON output.")] = False,
38
+ output_format: Annotated[str, typer.Option("--format", help="Output format: console, json, github.")] = "console",
39
+ changed_files: Annotated[Path | None, typer.Option("--changed-files", help="Newline-delimited list of changed files.")] = None,
40
+ ) -> None:
41
+ configure_logging()
42
+ logger.info("scan_started", target=str(target))
43
+ _t0 = time.perf_counter()
44
+ findings = scan_project(target, config, changed_files, default_registry())
45
+ logger.info("scan_completed", finding_count=len(findings), duration_ms=round((time.perf_counter() - _t0) * 1000, 1))
46
+ selected_format = "json" if json_output else output_format
47
+
48
+ if selected_format == "json":
49
+ typer.echo(json_report(findings))
50
+ elif selected_format == "github":
51
+ output = github_report(findings)
52
+ if output:
53
+ typer.echo(output)
54
+ elif selected_format == "sarif":
55
+ typer.echo(sarif_report(findings))
56
+ elif selected_format == "console":
57
+ console_report(findings)
58
+ else:
59
+ raise typer.BadParameter("format must be one of: console, json, github, sarif")
60
+
61
+ raise typer.Exit(code=1 if _has_failing_findings([finding.severity for finding in findings]) else 0)
62
+
63
+
64
+ @app.command()
65
+ def init(
66
+ output: Annotated[Path, typer.Option("--output", "-o", help="Output path for config file.")] = Path("reviewer.yaml"),
67
+ ) -> None:
68
+ """Scaffold a reviewer.yaml with all known rules and documentation."""
69
+ registry = default_registry()
70
+ plugins: dict[str, list] = {}
71
+ for rule in registry.rules:
72
+ plugins.setdefault(rule.plugin, []).append(rule)
73
+
74
+ lines: list[str] = ["# reviewer.yaml — generated by `reviewer init`", "rules:"]
75
+ for plugin_name, rules in plugins.items():
76
+ lines.append(f" # ── {plugin_name} ──────────────────────────────")
77
+ for rule in rules:
78
+ doc = (rule.check.__doc__ or "").strip().splitlines()[0] if rule.check.__doc__ else ""
79
+ if doc:
80
+ lines.append(f" # {doc}")
81
+ lines.append(f" {rule.id}:")
82
+ lines.append(f" enabled: true")
83
+ lines.append(f" severity: {rule.severity.value}")
84
+ lines.append("")
85
+
86
+ content = "\n".join(lines)
87
+ output.write_text(content, encoding="utf-8")
88
+ rule_count = sum(len(r) for r in plugins.values())
89
+ typer.echo(f"Created {output} with {rule_count} rules across {len(plugins)} plugins.")
90
+
91
+
92
+ @app.command()
93
+ def api(
94
+ host: Annotated[str, typer.Option("--host", help="Host for the local API server.")] = "127.0.0.1",
95
+ port: Annotated[int, typer.Option("--port", "-p", help="Port for the local API server.")] = 8766,
96
+ ) -> None:
97
+ """Start the local API server."""
98
+ import uvicorn
99
+
100
+ configure_logging()
101
+ typer.echo(f"Starting Coding Convention Reviewer API at http://{host}:{port}")
102
+ uvicorn.run("reviewer.server.app:create_app", factory=True, host=host, port=port, reload=True)
103
+
104
+
105
+ if __name__ == "__main__":
106
+ app()
@@ -0,0 +1 @@
1
+ """Framework-agnostic reviewer core."""
@@ -0,0 +1,4 @@
1
+ from reviewer.core.config.loader import load_config
2
+ from reviewer.core.config.models import ReviewerConfig, RuleConfig, ScanConfig
3
+
4
+ __all__ = ["ReviewerConfig", "RuleConfig", "ScanConfig", "load_config"]
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import structlog
6
+ import yaml
7
+
8
+ from reviewer.core.config.models import ReviewerConfig
9
+
10
+ logger = structlog.get_logger(__name__)
11
+
12
+
13
+ def load_config(config_path: Path | None = None, start_dir: Path | None = None) -> ReviewerConfig:
14
+ start_dir = (start_dir or Path.cwd()).resolve()
15
+ path = config_path
16
+ if path is None:
17
+ candidate = start_dir / "reviewer.yaml"
18
+ path = candidate if candidate.exists() else None
19
+
20
+ if path is None:
21
+ return ReviewerConfig()
22
+
23
+ with path.open("r", encoding="utf-8") as handle:
24
+ data = yaml.safe_load(handle) or {}
25
+ config = ReviewerConfig.model_validate(data)
26
+
27
+ try:
28
+ from reviewer.plugins.defaults import create_default_registry
29
+ known_ids = {rule.id for rule in create_default_registry().rules}
30
+ for rule_id in config.rules:
31
+ if rule_id not in known_ids:
32
+ logger.warning("unknown_rule_in_config", rule_id=rule_id)
33
+ except Exception:
34
+ pass
35
+
36
+ return config
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class ScanConfig(BaseModel):
9
+ include: list[str] = Field(default_factory=lambda: ["**/*"])
10
+ exclude: list[str] = Field(
11
+ default_factory=lambda: [
12
+ ".git/**",
13
+ ".venv/**",
14
+ "__pycache__/**",
15
+ "node_modules/**",
16
+ "dist/**",
17
+ "build/**",
18
+ ]
19
+ )
20
+
21
+
22
+ class RuleConfig(BaseModel):
23
+ enabled: bool = True
24
+ severity: str | None = None
25
+ limit: int | None = None
26
+ options: dict[str, Any] = Field(default_factory=dict)
27
+
28
+ model_config = {"extra": "allow"}
29
+
30
+ def option(self, key: str, default: Any = None) -> Any:
31
+ if hasattr(self, key):
32
+ value = getattr(self, key)
33
+ if value is not None:
34
+ return value
35
+ return self.options.get(key, default)
36
+
37
+
38
+ class ReviewerConfig(BaseModel):
39
+ reviewer: ScanConfig = Field(default_factory=ScanConfig)
40
+ rules: dict[str, RuleConfig] = Field(default_factory=dict)
41
+
42
+ def rule(self, rule_id: str) -> RuleConfig:
43
+ return self.rules.get(rule_id, RuleConfig())
@@ -0,0 +1,5 @@
1
+ from reviewer.core.engine.models import Plugin, Rule, RuleContext
2
+ from reviewer.core.engine.registry import PluginRegistry
3
+ from reviewer.core.engine.runner import run_review
4
+
5
+ __all__ = ["Plugin", "PluginRegistry", "Rule", "RuleContext", "run_review"]
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+ from reviewer.core.config.models import ReviewerConfig, RuleConfig
8
+ from reviewer.core.parser.base import ParseResult, Parser
9
+ from reviewer.core.reporting.models import Finding, Severity
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class RuleContext:
14
+ path: Path
15
+ root: Path
16
+ parse_result: ParseResult
17
+ config: ReviewerConfig
18
+ rule_config: RuleConfig
19
+
20
+
21
+ RuleCheck = Callable[[RuleContext], list[Finding]]
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Rule:
26
+ id: str
27
+ plugin: str
28
+ languages: set[str]
29
+ check: RuleCheck
30
+ severity: Severity = Severity.WARNING
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Plugin:
35
+ name: str
36
+ version: str
37
+ supported_extensions: set[str]
38
+ parsers: list[Parser]
39
+ rules: list[Rule]
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from reviewer.core.engine.models import Plugin, Rule
4
+ from reviewer.core.parser.base import Parser
5
+
6
+
7
+ class PluginRegistry:
8
+ def __init__(self) -> None:
9
+ self._plugins: list[Plugin] = []
10
+
11
+ def register(self, plugin: Plugin) -> None:
12
+ self._plugins.append(plugin)
13
+
14
+ @property
15
+ def plugins(self) -> list[Plugin]:
16
+ return list(self._plugins)
17
+
18
+ @property
19
+ def supported_extensions(self) -> set[str]:
20
+ return {extension for plugin in self._plugins for extension in plugin.supported_extensions}
21
+
22
+ @property
23
+ def rules(self) -> list[Rule]:
24
+ return [rule for plugin in self._plugins for rule in plugin.rules]
25
+
26
+ def parser_for_extension(self, extension: str) -> Parser | None:
27
+ for plugin in self._plugins:
28
+ for parser in plugin.parsers:
29
+ if extension in parser.extensions:
30
+ return parser
31
+ return None
@@ -0,0 +1,62 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Callable
4
+ from pathlib import Path
5
+
6
+ from reviewer.core.config.models import ReviewerConfig
7
+ from reviewer.core.engine.models import RuleContext
8
+ from reviewer.core.engine.registry import PluginRegistry
9
+ from reviewer.core.engine.suppression import parse_suppressions
10
+ from reviewer.core.parser.base import ParseResult
11
+ from reviewer.core.reporting.models import Finding, Severity
12
+
13
+
14
+ def run_review(
15
+ paths: list[Path],
16
+ root: Path,
17
+ config: ReviewerConfig,
18
+ registry: PluginRegistry,
19
+ *,
20
+ pre_parsed: dict[Path, ParseResult] | None = None,
21
+ progress_callback: Callable[[str, int, int], None] | None = None,
22
+ ) -> list[Finding]:
23
+ findings: list[Finding] = []
24
+ total = len(paths)
25
+ for idx, path in enumerate(paths):
26
+ if pre_parsed is not None and path in pre_parsed:
27
+ parse_result = pre_parsed[path]
28
+ else:
29
+ parser = registry.parser_for_extension(path.suffix)
30
+ if parser is None:
31
+ continue
32
+ parse_result = parser.parse(path)
33
+
34
+ suppressions = parse_suppressions(parse_result.source)
35
+ if suppressions.ignore_file:
36
+ continue
37
+
38
+ for rule in sorted(registry.rules, key=lambda item: item.id):
39
+ if parse_result.language not in rule.languages:
40
+ continue
41
+ rule_config = config.rule(rule.id)
42
+ if not rule_config.enabled:
43
+ continue
44
+ severity = Severity(rule_config.severity) if rule_config.severity else rule.severity
45
+ context = RuleContext(
46
+ path=path,
47
+ root=root,
48
+ parse_result=parse_result,
49
+ config=config,
50
+ rule_config=rule_config,
51
+ )
52
+ for finding in rule.check(context):
53
+ if suppressions.is_ignored(finding.line, finding.rule):
54
+ continue
55
+ if finding.severity != severity:
56
+ finding = finding.model_copy(update={"severity": severity})
57
+ findings.append(finding)
58
+
59
+ if progress_callback is not None:
60
+ progress_callback(str(path), idx + 1, total)
61
+
62
+ return sorted(findings, key=lambda item: (item.file, item.line, item.rule))
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Suppressions:
8
+ ignore_file: bool = False
9
+ lines: dict[int, set[str]] = field(default_factory=dict)
10
+
11
+ def is_ignored(self, line: int, rule_id: str) -> bool:
12
+ if self.ignore_file:
13
+ return True
14
+ ignored = self.lines.get(line, set())
15
+ return "*" in ignored or rule_id in ignored
16
+
17
+
18
+ def parse_suppressions(source: str) -> Suppressions:
19
+ lines: dict[int, set[str]] = {}
20
+ ignore_file = False
21
+ for index, line in enumerate(source.splitlines(), start=1):
22
+ if "reviewer: ignore-file" in line:
23
+ ignore_file = True
24
+ marker = "reviewer: ignore"
25
+ if marker not in line:
26
+ continue
27
+ after = line.split(marker, 1)[1].strip()
28
+ rule_ids = {part.strip() for part in after.replace(",", " ").split() if part.strip()}
29
+ lines[index] = rule_ids or {"*"}
30
+ return Suppressions(ignore_file=ignore_file, lines=lines)
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+
6
+ import structlog
7
+
8
+
9
+ def configure_logging() -> None:
10
+ structlog.configure(
11
+ processors=[
12
+ structlog.stdlib.filter_by_level,
13
+ structlog.stdlib.add_logger_name,
14
+ structlog.stdlib.add_log_level,
15
+ structlog.stdlib.PositionalArgumentsFormatter(),
16
+ structlog.processors.TimeStamper(fmt="iso"),
17
+ structlog.processors.StackInfoRenderer(),
18
+ structlog.processors.format_exc_info,
19
+ structlog.processors.UnicodeDecoder(),
20
+ structlog.dev.ConsoleRenderer() if os.isatty(1) else structlog.processors.JSONRenderer(),
21
+ ],
22
+ context_class=dict,
23
+ logger_factory=structlog.stdlib.LoggerFactory(),
24
+ cache_logger_on_first_use=True,
25
+ )
26
+
27
+
28
+ def get_logger(name: str = __name__) -> structlog.BoundLogger:
29
+ return structlog.get_logger(name)
@@ -0,0 +1,50 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from contextlib import contextmanager
5
+ from typing import Generator
6
+
7
+ from prometheus_client import Counter, Gauge, Histogram
8
+
9
+ scan_total = Counter(
10
+ "reviewer_scans_total",
11
+ "Total scans run",
12
+ ["status"],
13
+ )
14
+
15
+ scan_duration = Histogram(
16
+ "reviewer_scan_duration_seconds",
17
+ "Scan duration in seconds",
18
+ buckets=[1, 5, 10, 30, 60, 120, 300],
19
+ )
20
+
21
+ findings_total = Counter(
22
+ "reviewer_findings_total",
23
+ "Findings by severity",
24
+ ["severity"],
25
+ )
26
+
27
+ files_scanned = Histogram(
28
+ "reviewer_files_scanned",
29
+ "Number of files per scan",
30
+ )
31
+
32
+ scan_in_progress = Gauge(
33
+ "reviewer_scan_in_progress",
34
+ "Whether a scan is currently running",
35
+ )
36
+
37
+
38
+ @contextmanager
39
+ def track_scan() -> Generator[None, None, None]:
40
+ scan_in_progress.inc()
41
+ start = time.monotonic()
42
+ try:
43
+ yield
44
+ scan_total.labels(status="success").inc()
45
+ except Exception:
46
+ scan_total.labels(status="error").inc()
47
+ raise
48
+ finally:
49
+ scan_duration.observe(time.monotonic() - start)
50
+ scan_in_progress.dec()
@@ -0,0 +1,5 @@
1
+ from reviewer.core.parser.base import ParseResult, Parser
2
+ from reviewer.core.parser.python_ast import PythonAstParser
3
+ from reviewer.core.parser.typescript import TypeScriptParser
4
+
5
+ __all__ = ["ParseResult", "Parser", "PythonAstParser", "TypeScriptParser"]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any, Protocol
5
+
6
+ from pydantic import BaseModel, ConfigDict
7
+
8
+
9
+ class ParseResult(BaseModel):
10
+ path: Path
11
+ source: str
12
+ tree: Any
13
+ language: str
14
+
15
+ model_config = ConfigDict(arbitrary_types_allowed=True)
16
+
17
+
18
+ class Parser(Protocol):
19
+ language: str
20
+ extensions: set[str]
21
+
22
+ def parse(self, path: Path) -> ParseResult:
23
+ ...
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from pathlib import Path
5
+
6
+ from cachetools import LRUCache
7
+
8
+ from reviewer.core.parser.base import ParseResult, Parser
9
+
10
+ _CACHE: LRUCache[str, tuple[float, ParseResult]] = LRUCache(maxsize=256)
11
+ _LOCK = threading.Lock()
12
+
13
+
14
+ def cached_parse(path: Path, parser: Parser) -> ParseResult:
15
+ key = str(path.resolve())
16
+ try:
17
+ mtime = path.stat().st_mtime
18
+ except OSError:
19
+ return parser.parse(path)
20
+
21
+ with _LOCK:
22
+ entry = _CACHE.get(key)
23
+ if entry is not None and entry[0] == mtime:
24
+ return entry[1]
25
+
26
+ result = parser.parse(path)
27
+
28
+ with _LOCK:
29
+ _CACHE[key] = (mtime, result)
30
+
31
+ return result
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from pathlib import Path
5
+
6
+ from reviewer.core.parser.base import ParseResult
7
+
8
+
9
+ class PythonAstParser:
10
+ language = "python"
11
+ extensions = {".py"}
12
+
13
+ def parse(self, path: Path) -> ParseResult:
14
+ source = path.read_text(encoding="utf-8")
15
+ return ParseResult(
16
+ path=path,
17
+ source=source,
18
+ tree=ast.parse(source, filename=str(path)),
19
+ language=self.language,
20
+ )
@@ -0,0 +1,43 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from reviewer.core.parser.base import ParseResult
8
+
9
+ _local = threading.local()
10
+
11
+
12
+ class TypeScriptParser:
13
+ language = "typescript"
14
+ extensions = {".ts", ".tsx"}
15
+
16
+ def _load_parser(self) -> Any:
17
+ parser = getattr(_local, "ts_parser", None)
18
+ if parser is not None:
19
+ return parser
20
+
21
+ try:
22
+ from tree_sitter import Language, Parser
23
+ import tree_sitter_typescript as tstypescript
24
+ except ImportError as exc:
25
+ raise RuntimeError(
26
+ "TypeScript parsing requires tree-sitter and tree-sitter-typescript."
27
+ ) from exc
28
+
29
+ parser = Parser()
30
+ language_factory = tstypescript.language_tsx
31
+ language = Language(language_factory())
32
+ if hasattr(parser, "set_language"):
33
+ parser.set_language(language)
34
+ else:
35
+ parser.language = language
36
+ _local.ts_parser = parser
37
+ return parser
38
+
39
+ def parse(self, path: Path) -> ParseResult:
40
+ source = path.read_text(encoding="utf-8")
41
+ parser = self._load_parser()
42
+ tree = parser.parse(source.encode("utf-8"))
43
+ return ParseResult(path=path, source=source, tree=tree, language=self.language)