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.
- coding_convention_reviewer-0.1.0.dist-info/METADATA +22 -0
- coding_convention_reviewer-0.1.0.dist-info/RECORD +46 -0
- coding_convention_reviewer-0.1.0.dist-info/WHEEL +4 -0
- coding_convention_reviewer-0.1.0.dist-info/entry_points.txt +2 -0
- reviewer/__init__.py +3 -0
- reviewer/__main__.py +5 -0
- reviewer/cli.py +106 -0
- reviewer/core/__init__.py +1 -0
- reviewer/core/config/__init__.py +4 -0
- reviewer/core/config/loader.py +36 -0
- reviewer/core/config/models.py +43 -0
- reviewer/core/engine/__init__.py +5 -0
- reviewer/core/engine/models.py +39 -0
- reviewer/core/engine/registry.py +31 -0
- reviewer/core/engine/runner.py +62 -0
- reviewer/core/engine/suppression.py +30 -0
- reviewer/core/logging.py +29 -0
- reviewer/core/metrics.py +50 -0
- reviewer/core/parser/__init__.py +5 -0
- reviewer/core/parser/base.py +23 -0
- reviewer/core/parser/cache.py +31 -0
- reviewer/core/parser/python_ast.py +20 -0
- reviewer/core/parser/typescript.py +43 -0
- reviewer/core/reporting/__init__.py +4 -0
- reviewer/core/reporting/models.py +44 -0
- reviewer/core/reporting/reporters.py +88 -0
- reviewer/core/scanner.py +57 -0
- reviewer/core/service.py +109 -0
- reviewer/core/storage/__init__.py +3 -0
- reviewer/core/storage/store.py +116 -0
- reviewer/plugins/__init__.py +1 -0
- reviewer/plugins/defaults.py +16 -0
- reviewer/plugins/django/__init__.py +3 -0
- reviewer/plugins/django/plugin.py +30 -0
- reviewer/plugins/django/rules.py +150 -0
- reviewer/plugins/fastapi/__init__.py +3 -0
- reviewer/plugins/fastapi/plugin.py +36 -0
- reviewer/plugins/fastapi/rules.py +154 -0
- reviewer/plugins/python/__init__.py +3 -0
- reviewer/plugins/python/plugin.py +31 -0
- reviewer/plugins/python/rules.py +129 -0
- reviewer/plugins/react/__init__.py +3 -0
- reviewer/plugins/react/plugin.py +128 -0
- reviewer/plugins/react/rules.py +597 -0
- reviewer/server/__init__.py +1 -0
- 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,,
|
reviewer/__init__.py
ADDED
reviewer/__main__.py
ADDED
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,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,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)
|
reviewer/core/logging.py
ADDED
|
@@ -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)
|
reviewer/core/metrics.py
ADDED
|
@@ -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,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)
|