resint 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.
- resint/__init__.py +39 -0
- resint/cli.py +246 -0
- resint/config.py +191 -0
- resint/engine.py +122 -0
- resint/ir/__init__.py +0 -0
- resint/ir/finding.py +186 -0
- resint/ir/paper.py +242 -0
- resint/ir/repo.py +207 -0
- resint/ir/span.py +75 -0
- resint/mathx/__init__.py +0 -0
- resint/mathx/special.py +159 -0
- resint/parse/__init__.py +0 -0
- resint/parse/bibtex.py +222 -0
- resint/parse/citations.py +63 -0
- resint/parse/code.py +235 -0
- resint/parse/configs.py +165 -0
- resint/parse/document.py +163 -0
- resint/parse/extract.py +359 -0
- resint/parse/latex.py +334 -0
- resint/parse/repo.py +235 -0
- resint/parse/tables.py +288 -0
- resint/report/__init__.py +0 -0
- resint/report/sarif.py +130 -0
- resint/report/terminal.py +151 -0
- resint/resolve/__init__.py +19 -0
- resint/resolve/base.py +117 -0
- resint/resolve/http.py +237 -0
- resint/rules/__init__.py +31 -0
- resint/rules/bib/__init__.py +0 -0
- resint/rules/bib/drift.py +86 -0
- resint/rules/bib/orphans.py +74 -0
- resint/rules/bib/unresolved.py +74 -0
- resint/rules/numbers/__init__.py +0 -0
- resint/rules/numbers/internal_mismatch.py +110 -0
- resint/rules/numbers/table_arithmetic.py +115 -0
- resint/rules/registry.py +244 -0
- resint/rules/repro/__init__.py +0 -0
- resint/rules/repro/deps.py +61 -0
- resint/rules/repro/entrypoint.py +43 -0
- resint/rules/repro/ghost.py +90 -0
- resint/rules/repro/hparam_drift.py +130 -0
- resint/rules/repro/seed_claim.py +97 -0
- resint/rules/stats/__init__.py +0 -0
- resint/rules/stats/grim.py +118 -0
- resint/rules/stats/pvalue.py +163 -0
- resint/rules/stats/significance.py +93 -0
- resint-0.1.0.dist-info/METADATA +303 -0
- resint-0.1.0.dist-info/RECORD +51 -0
- resint-0.1.0.dist-info/WHEEL +4 -0
- resint-0.1.0.dist-info/entry_points.txt +2 -0
- resint-0.1.0.dist-info/licenses/LICENSE +202 -0
resint/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""resint -- a linter for research papers.
|
|
2
|
+
|
|
3
|
+
The public surface is deliberately small. Everything a wrapper needs is here:
|
|
4
|
+
build a Paper, run rules over it, format the result. The CLI is one consumer
|
|
5
|
+
of this API and holds no analysis of its own, which is what keeps the next
|
|
6
|
+
surface -- Action, MCP server, editor extension -- a thin file rather than a
|
|
7
|
+
rewrite.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .config import Config, discover, parse as parse_config
|
|
11
|
+
from .engine import Report, run
|
|
12
|
+
from .ir.finding import Finding, Severity, Tier
|
|
13
|
+
from .ir.span import Source, Span
|
|
14
|
+
from .parse.document import paper_from_latex, paper_from_path
|
|
15
|
+
from .rules import REGISTRY, Context, Registry, Rule, load_all, rule
|
|
16
|
+
|
|
17
|
+
__version__ = "0.1.0"
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Config",
|
|
21
|
+
"Context",
|
|
22
|
+
"Finding",
|
|
23
|
+
"REGISTRY",
|
|
24
|
+
"Registry",
|
|
25
|
+
"Report",
|
|
26
|
+
"Rule",
|
|
27
|
+
"Severity",
|
|
28
|
+
"Source",
|
|
29
|
+
"Span",
|
|
30
|
+
"Tier",
|
|
31
|
+
"__version__",
|
|
32
|
+
"discover",
|
|
33
|
+
"load_all",
|
|
34
|
+
"paper_from_latex",
|
|
35
|
+
"paper_from_path",
|
|
36
|
+
"parse_config",
|
|
37
|
+
"rule",
|
|
38
|
+
"run",
|
|
39
|
+
]
|
resint/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
Deliberately thin. Every surface after this one -- GitHub Action, MCP server,
|
|
4
|
+
editor extension -- wraps the same library, so any analysis that leaks into
|
|
5
|
+
here is analysis that has to be rewritten for surface two. Nothing in this
|
|
6
|
+
module decides anything about a paper; it parses arguments, calls the
|
|
7
|
+
library, and formats what comes back.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .config import Config, ConfigError, discover, parse as parse_config
|
|
20
|
+
from .engine import run
|
|
21
|
+
from .ir.finding import Severity
|
|
22
|
+
from .parse.document import paper_from_path
|
|
23
|
+
from .parse.repo import read_repo
|
|
24
|
+
from .report.sarif import render as render_sarif
|
|
25
|
+
from .report.terminal import render
|
|
26
|
+
from .resolve import CachingResolver, NullResolver
|
|
27
|
+
from .resolve.http import HttpResolver
|
|
28
|
+
from .rules import load_all
|
|
29
|
+
|
|
30
|
+
EXIT_OK = 0
|
|
31
|
+
EXIT_FINDINGS = 1
|
|
32
|
+
EXIT_USAGE = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _load_config(args: argparse.Namespace, target: Path) -> Config:
|
|
36
|
+
if args.no_config:
|
|
37
|
+
return Config()
|
|
38
|
+
if args.config:
|
|
39
|
+
path = Path(args.config)
|
|
40
|
+
if not path.is_file():
|
|
41
|
+
raise ConfigError(f"no such config file: {path}")
|
|
42
|
+
return parse_config(path.read_text(encoding="utf-8"), path)
|
|
43
|
+
return discover(target)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cmd_check(args: argparse.Namespace) -> int:
|
|
47
|
+
target = Path(args.target)
|
|
48
|
+
if not target.exists():
|
|
49
|
+
print(f"resint: no such file: {target}", file=sys.stderr)
|
|
50
|
+
return EXIT_USAGE
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
config = _load_config(args, target)
|
|
54
|
+
except ConfigError as exc:
|
|
55
|
+
print(f"resint: {exc}", file=sys.stderr)
|
|
56
|
+
return EXIT_USAGE
|
|
57
|
+
|
|
58
|
+
registry = load_all()
|
|
59
|
+
|
|
60
|
+
# Only bibliographic metadata leaves the machine -- titles and DOIs of
|
|
61
|
+
# works the paper already cites publicly. The manuscript itself is never
|
|
62
|
+
# transmitted. --offline skips it entirely, at the cost of the reference
|
|
63
|
+
# rules abstaining rather than reporting.
|
|
64
|
+
resolver = (
|
|
65
|
+
NullResolver()
|
|
66
|
+
if args.offline
|
|
67
|
+
else CachingResolver(HttpResolver(mailto=args.mailto))
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
started = time.perf_counter()
|
|
71
|
+
paper = paper_from_path(target, bib=args.bib, resolver=resolver)
|
|
72
|
+
|
|
73
|
+
# Only build the repository IR when a repository was actually given.
|
|
74
|
+
# Walking a tree nobody asked about is the kind of cost that makes a
|
|
75
|
+
# linter feel slow for no benefit.
|
|
76
|
+
repo = None
|
|
77
|
+
if args.repo:
|
|
78
|
+
needed = {
|
|
79
|
+
req
|
|
80
|
+
for r in registry.all()
|
|
81
|
+
for req in r.requires
|
|
82
|
+
if req.startswith("repo.")
|
|
83
|
+
}
|
|
84
|
+
repo = read_repo(args.repo, needs=needed)
|
|
85
|
+
|
|
86
|
+
report = run(
|
|
87
|
+
paper,
|
|
88
|
+
repo=repo,
|
|
89
|
+
registry=registry,
|
|
90
|
+
has_provider=False,
|
|
91
|
+
min_severity=Severity(args.min_severity) if args.min_severity else None,
|
|
92
|
+
config=config,
|
|
93
|
+
)
|
|
94
|
+
elapsed = time.perf_counter() - started
|
|
95
|
+
|
|
96
|
+
if args.format == "json":
|
|
97
|
+
print(
|
|
98
|
+
json.dumps(
|
|
99
|
+
{
|
|
100
|
+
"version": __version__,
|
|
101
|
+
"target": str(target),
|
|
102
|
+
"findings": [f.to_dict() for f in report.findings],
|
|
103
|
+
"unchecked": report.unchecked,
|
|
104
|
+
"notes": report.notes,
|
|
105
|
+
"skipped": report.skipped,
|
|
106
|
+
"counts": report.counts(),
|
|
107
|
+
},
|
|
108
|
+
indent=2,
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
elif args.format == "sarif":
|
|
112
|
+
print(render_sarif(report, registry, version=__version__))
|
|
113
|
+
else:
|
|
114
|
+
print(render(report, target.name, elapsed))
|
|
115
|
+
|
|
116
|
+
if args.fail_on == "none":
|
|
117
|
+
return EXIT_OK
|
|
118
|
+
counts = report.counts()
|
|
119
|
+
floor = {"high": ("high",), "med": ("high", "med"), "low": ("high", "med", "low")}
|
|
120
|
+
triggered = any(counts[level] for level in floor[args.fail_on])
|
|
121
|
+
return EXIT_FINDINGS if triggered else EXIT_OK
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cmd_rules(args: argparse.Namespace) -> int:
|
|
125
|
+
registry = load_all()
|
|
126
|
+
rules = registry.all()
|
|
127
|
+
if args.tier:
|
|
128
|
+
rules = [r for r in rules if r.tier.value == args.tier]
|
|
129
|
+
if args.family:
|
|
130
|
+
rules = [r for r in rules if r.family == args.family]
|
|
131
|
+
|
|
132
|
+
if args.format == "json":
|
|
133
|
+
print(
|
|
134
|
+
json.dumps(
|
|
135
|
+
[
|
|
136
|
+
{
|
|
137
|
+
"id": r.id,
|
|
138
|
+
"severity": r.severity.value,
|
|
139
|
+
"tier": r.tier.value,
|
|
140
|
+
"requires": list(r.requires),
|
|
141
|
+
"needs_repo": r.needs_repo,
|
|
142
|
+
"cannot_detect": r.cannot_detect,
|
|
143
|
+
}
|
|
144
|
+
for r in rules
|
|
145
|
+
],
|
|
146
|
+
indent=2,
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
return EXIT_OK
|
|
150
|
+
|
|
151
|
+
for r in rules:
|
|
152
|
+
extra = " --repo" if r.needs_repo else ""
|
|
153
|
+
print(f"{r.id:<30} {r.severity.value:<5} {r.tier.value}{extra}")
|
|
154
|
+
print(f" cannot detect: {r.cannot_detect}")
|
|
155
|
+
print()
|
|
156
|
+
print(f"{len(rules)} rules")
|
|
157
|
+
return EXIT_OK
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
161
|
+
path = Path(args.path) / ".resint.yml"
|
|
162
|
+
if path.exists() and not args.force:
|
|
163
|
+
print(f"resint: {path} already exists (use --force to overwrite)", file=sys.stderr)
|
|
164
|
+
return EXIT_USAGE
|
|
165
|
+
|
|
166
|
+
path.write_text(TEMPLATE, encoding="utf-8")
|
|
167
|
+
print(f"wrote {path}")
|
|
168
|
+
return EXIT_OK
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
TEMPLATE = """\
|
|
172
|
+
# resint configuration. Commit this alongside the paper.
|
|
173
|
+
version: 1
|
|
174
|
+
|
|
175
|
+
# Every suppression states a reason. That is deliberate: this file is the
|
|
176
|
+
# record of each judgement made about the work, and a silenced finding with
|
|
177
|
+
# no explanation is unauditable six months later.
|
|
178
|
+
#
|
|
179
|
+
# Suppressed findings still appear in JSON and SARIF output marked with their
|
|
180
|
+
# reason, so a suppression can never hide a regression.
|
|
181
|
+
suppress:
|
|
182
|
+
# - rule: bib/metadata-drift
|
|
183
|
+
# match: "[vaswani2017]"
|
|
184
|
+
# reason: "Cites the proceedings version deliberately."
|
|
185
|
+
# expires: "2027-01-01"
|
|
186
|
+
|
|
187
|
+
# Turn a rule off entirely when it does not apply to this work.
|
|
188
|
+
rules:
|
|
189
|
+
# stats/grim: off # no integer-scale response data here
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def main(argv: list[str] | None = None) -> int:
|
|
194
|
+
parser = argparse.ArgumentParser(
|
|
195
|
+
prog="resint", description="A linter for research papers."
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument("--version", action="version", version=f"resint {__version__}")
|
|
198
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
199
|
+
|
|
200
|
+
check = sub.add_parser("check", help="check a paper")
|
|
201
|
+
check.add_argument("target", help="path to a .tex source file")
|
|
202
|
+
check.add_argument("--repo", default=None, help="path to the paper's repository")
|
|
203
|
+
check.add_argument(
|
|
204
|
+
"--bib", default=None, help="bibliography (default: the .bib beside the source)"
|
|
205
|
+
)
|
|
206
|
+
check.add_argument(
|
|
207
|
+
"--format", choices=["term", "json", "sarif"], default="term"
|
|
208
|
+
)
|
|
209
|
+
check.add_argument("--min-severity", choices=["low", "med", "high"], default=None)
|
|
210
|
+
check.add_argument(
|
|
211
|
+
"--fail-on",
|
|
212
|
+
choices=["high", "med", "low", "none"],
|
|
213
|
+
default="high",
|
|
214
|
+
help="lowest severity that exits non-zero (default: high)",
|
|
215
|
+
)
|
|
216
|
+
check.add_argument(
|
|
217
|
+
"--offline",
|
|
218
|
+
action="store_true",
|
|
219
|
+
help="skip reference lookups; the bib rules abstain rather than guess",
|
|
220
|
+
)
|
|
221
|
+
check.add_argument(
|
|
222
|
+
"--mailto", default=None, help="contact address for the Crossref polite pool"
|
|
223
|
+
)
|
|
224
|
+
check.add_argument("--config", default=None, help="path to a .resint.yml")
|
|
225
|
+
check.add_argument(
|
|
226
|
+
"--no-config", action="store_true", help="ignore any .resint.yml"
|
|
227
|
+
)
|
|
228
|
+
check.set_defaults(fn=_cmd_check)
|
|
229
|
+
|
|
230
|
+
rules = sub.add_parser("rules", help="list rules and their blind spots")
|
|
231
|
+
rules.add_argument("--tier", choices=["deterministic", "model-assisted"], default=None)
|
|
232
|
+
rules.add_argument("--family", default=None, help="e.g. stats, bib, numbers")
|
|
233
|
+
rules.add_argument("--format", choices=["term", "json"], default="term")
|
|
234
|
+
rules.set_defaults(fn=_cmd_rules)
|
|
235
|
+
|
|
236
|
+
init = sub.add_parser("init", help="write a .resint.yml")
|
|
237
|
+
init.add_argument("path", nargs="?", default=".", help="directory (default: .)")
|
|
238
|
+
init.add_argument("--force", action="store_true")
|
|
239
|
+
init.set_defaults(fn=_cmd_init)
|
|
240
|
+
|
|
241
|
+
args = parser.parse_args(argv)
|
|
242
|
+
return args.fn(args)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
raise SystemExit(main())
|
resint/config.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""Project configuration and suppression.
|
|
2
|
+
|
|
3
|
+
A suppression requires a stated reason. That is the whole design: a linter
|
|
4
|
+
that lets findings be silenced without explanation accumulates a config file
|
|
5
|
+
nobody can audit, and in this domain the config file is the record of every
|
|
6
|
+
judgement an author made about their own work.
|
|
7
|
+
|
|
8
|
+
Suppression happens at the reporting layer, never during evaluation. The
|
|
9
|
+
finding is still produced, still counted, and still present in JSON output
|
|
10
|
+
marked with its reason. A suppression can therefore never hide a regression
|
|
11
|
+
from the corpus, and every suppression doubles as a labelled false positive
|
|
12
|
+
for the rule that produced it.
|
|
13
|
+
|
|
14
|
+
The parser is a small YAML subset rather than a dependency. resint installs
|
|
15
|
+
with nothing but the standard library, and a config format that needs a
|
|
16
|
+
third-party parser would trade that away for two levels of nesting.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from datetime import date
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
CONFIG_NAMES = (".resint.yml", ".resint.yaml")
|
|
27
|
+
|
|
28
|
+
_KEY = re.compile(r"^(?P<indent>\s*)(?P<dash>- )?(?P<key>[A-Za-z_][\w./-]*)\s*:\s*(?P<value>.*)$")
|
|
29
|
+
_ITEM = re.compile(r"^(?P<indent>\s*)- (?P<value>.+)$")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConfigError(ValueError):
|
|
33
|
+
"""Raised when a config file cannot be honoured as written."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class Suppression:
|
|
38
|
+
rule: str
|
|
39
|
+
reason: str
|
|
40
|
+
match: str | None = None
|
|
41
|
+
expires: date | None = None
|
|
42
|
+
|
|
43
|
+
def expired(self, today: date | None = None) -> bool:
|
|
44
|
+
if self.expires is None:
|
|
45
|
+
return False
|
|
46
|
+
return (today or date.today()) > self.expires
|
|
47
|
+
|
|
48
|
+
def applies_to(self, finding) -> bool:
|
|
49
|
+
if finding.rule_id != self.rule:
|
|
50
|
+
return False
|
|
51
|
+
if self.match and self.match not in finding.message:
|
|
52
|
+
return False
|
|
53
|
+
return True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class Config:
|
|
58
|
+
suppressions: list[Suppression] = field(default_factory=list)
|
|
59
|
+
disabled: set[str] = field(default_factory=set)
|
|
60
|
+
path: Path | None = None
|
|
61
|
+
|
|
62
|
+
def apply(self, findings, today: date | None = None) -> tuple[list, list[str]]:
|
|
63
|
+
"""Return (findings with suppressions marked, notes about the config)."""
|
|
64
|
+
notes: list[str] = []
|
|
65
|
+
live = [s for s in self.suppressions if not s.expired(today)]
|
|
66
|
+
|
|
67
|
+
for stale in (s for s in self.suppressions if s.expired(today)):
|
|
68
|
+
notes.append(
|
|
69
|
+
f"suppression for {stale.rule} expired on {stale.expires}; "
|
|
70
|
+
"the finding is reported again"
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
out = []
|
|
74
|
+
for finding in findings:
|
|
75
|
+
match = next((s for s in live if s.applies_to(finding)), None)
|
|
76
|
+
out.append(finding.suppress(match.reason) if match else finding)
|
|
77
|
+
|
|
78
|
+
used = {s.rule for f in out if f.suppressed for s in live if s.applies_to(f)}
|
|
79
|
+
for unused in {s.rule for s in live} - used:
|
|
80
|
+
notes.append(
|
|
81
|
+
f"suppression for {unused} matched nothing; the rule may have "
|
|
82
|
+
"changed or the finding may already be fixed"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return out, notes
|
|
86
|
+
|
|
87
|
+
def enabled(self, rule_id: str) -> bool:
|
|
88
|
+
return rule_id not in self.disabled
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _strip_comment(line: str) -> str:
|
|
92
|
+
out, quote = [], None
|
|
93
|
+
for ch in line:
|
|
94
|
+
if quote:
|
|
95
|
+
if ch == quote:
|
|
96
|
+
quote = None
|
|
97
|
+
elif ch in "\"'":
|
|
98
|
+
quote = ch
|
|
99
|
+
elif ch == "#":
|
|
100
|
+
break
|
|
101
|
+
out.append(ch)
|
|
102
|
+
return "".join(out)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _unquote(value: str) -> str:
|
|
106
|
+
value = value.strip()
|
|
107
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
108
|
+
return value[1:-1]
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse(text: str, path: Path | None = None) -> Config:
|
|
113
|
+
"""Parse the .resint.yml subset: top-level keys, one list of mappings."""
|
|
114
|
+
config = Config(path=path)
|
|
115
|
+
section: str | None = None
|
|
116
|
+
current: dict[str, str] | None = None
|
|
117
|
+
raw_suppressions: list[dict[str, str]] = []
|
|
118
|
+
|
|
119
|
+
for lineno, original in enumerate(text.splitlines(), 1):
|
|
120
|
+
line = _strip_comment(original).rstrip()
|
|
121
|
+
if not line.strip():
|
|
122
|
+
continue
|
|
123
|
+
|
|
124
|
+
top = _KEY.match(line)
|
|
125
|
+
if top and not top.group("indent") and not top.group("dash"):
|
|
126
|
+
key, value = top.group("key"), _unquote(top.group("value"))
|
|
127
|
+
if key in ("suppress", "rules"):
|
|
128
|
+
section = key
|
|
129
|
+
current = None
|
|
130
|
+
continue
|
|
131
|
+
section = None
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
if section == "suppress":
|
|
135
|
+
item = _ITEM.match(line)
|
|
136
|
+
if item:
|
|
137
|
+
current = {}
|
|
138
|
+
raw_suppressions.append(current)
|
|
139
|
+
inner = _KEY.match(" " + item.group("value"))
|
|
140
|
+
if inner:
|
|
141
|
+
current[inner.group("key")] = _unquote(inner.group("value"))
|
|
142
|
+
continue
|
|
143
|
+
nested = _KEY.match(line)
|
|
144
|
+
if nested and current is not None:
|
|
145
|
+
current[nested.group("key")] = _unquote(nested.group("value"))
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if section == "rules":
|
|
149
|
+
nested = _KEY.match(line)
|
|
150
|
+
if nested:
|
|
151
|
+
state = _unquote(nested.group("value")).lower()
|
|
152
|
+
if state in ("off", "false", "no", "disabled"):
|
|
153
|
+
config.disabled.add(nested.group("key"))
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
for index, entry in enumerate(raw_suppressions, 1):
|
|
157
|
+
rule = entry.get("rule")
|
|
158
|
+
if not rule:
|
|
159
|
+
raise ConfigError(f"suppression {index} has no rule")
|
|
160
|
+
reason = entry.get("reason", "").strip()
|
|
161
|
+
if not reason:
|
|
162
|
+
raise ConfigError(
|
|
163
|
+
f"suppression {index} for {rule!r} has no reason. "
|
|
164
|
+
"Every suppression must say why, so the file stays auditable."
|
|
165
|
+
)
|
|
166
|
+
expires = None
|
|
167
|
+
if entry.get("expires"):
|
|
168
|
+
try:
|
|
169
|
+
expires = date.fromisoformat(entry["expires"])
|
|
170
|
+
except ValueError as exc:
|
|
171
|
+
raise ConfigError(
|
|
172
|
+
f"suppression {index} for {rule!r}: {exc}"
|
|
173
|
+
) from exc
|
|
174
|
+
config.suppressions.append(
|
|
175
|
+
Suppression(
|
|
176
|
+
rule=rule, reason=reason, match=entry.get("match"), expires=expires
|
|
177
|
+
)
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return config
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def discover(start: Path) -> Config:
|
|
184
|
+
"""Find the nearest config walking up from ``start``."""
|
|
185
|
+
here = start if start.is_dir() else start.parent
|
|
186
|
+
for directory in (here, *here.parents):
|
|
187
|
+
for name in CONFIG_NAMES:
|
|
188
|
+
candidate = directory / name
|
|
189
|
+
if candidate.is_file():
|
|
190
|
+
return parse(candidate.read_text(encoding="utf-8"), candidate)
|
|
191
|
+
return Config()
|
resint/engine.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Rule selection and execution.
|
|
2
|
+
|
|
3
|
+
Selection is the interesting part. A rule is skipped -- silently, and without
|
|
4
|
+
being counted as passing -- whenever the run cannot satisfy what it declared:
|
|
5
|
+
no repository for a repo rule, no provider for a model-assisted one. The
|
|
6
|
+
report says which rules did not run and why, because "no findings" and "did
|
|
7
|
+
not look" are different statements and conflating them is how a checker
|
|
8
|
+
becomes misleading.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
from .config import Config
|
|
16
|
+
from .ir.finding import Finding, Severity, Tier
|
|
17
|
+
from .rules import REGISTRY, Context, Registry, Rule, load_all
|
|
18
|
+
|
|
19
|
+
_SEVERITY_RANK = {Severity.HIGH: 0, Severity.MED: 1, Severity.LOW: 2}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Report:
|
|
24
|
+
findings: list[Finding] = field(default_factory=list)
|
|
25
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
26
|
+
unchecked: list[str] = field(default_factory=list)
|
|
27
|
+
ran: list[str] = field(default_factory=list)
|
|
28
|
+
notes: list[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def used_model(self) -> bool:
|
|
32
|
+
return any(f.tier is Tier.MODEL_ASSISTED for f in self.findings)
|
|
33
|
+
|
|
34
|
+
def counts(self) -> dict[str, int]:
|
|
35
|
+
out = {"high": 0, "med": 0, "low": 0}
|
|
36
|
+
for f in self.findings:
|
|
37
|
+
if not f.suppressed:
|
|
38
|
+
out[f.severity.value] += 1
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
def summary(self) -> str:
|
|
42
|
+
c = self.counts()
|
|
43
|
+
total = sum(c.values())
|
|
44
|
+
noun = "finding" if total == 1 else "findings"
|
|
45
|
+
return (
|
|
46
|
+
f"{total} {noun} "
|
|
47
|
+
f"({c['high']} high, {c['med']} med, {c['low']} low)"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def selectable(
|
|
52
|
+
rules: list[Rule], *, has_repo: bool, has_provider: bool
|
|
53
|
+
) -> tuple[list[Rule], dict[str, str]]:
|
|
54
|
+
"""Split rules into those this run can honestly execute, and why not."""
|
|
55
|
+
runnable, skipped = [], {}
|
|
56
|
+
for r in rules:
|
|
57
|
+
if r.needs_repo and not has_repo:
|
|
58
|
+
skipped[r.id] = "no repository supplied"
|
|
59
|
+
elif r.tier is Tier.MODEL_ASSISTED and not has_provider:
|
|
60
|
+
skipped[r.id] = "no model provider configured"
|
|
61
|
+
else:
|
|
62
|
+
runnable.append(r)
|
|
63
|
+
return runnable, skipped
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def required_slices(rules: list[Rule]) -> set[str]:
|
|
67
|
+
return {req for r in rules for req in r.requires}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run(
|
|
71
|
+
paper,
|
|
72
|
+
repo=None,
|
|
73
|
+
*,
|
|
74
|
+
registry: Registry | None = None,
|
|
75
|
+
has_provider: bool = False,
|
|
76
|
+
min_severity: Severity | None = None,
|
|
77
|
+
config: Config | None = None,
|
|
78
|
+
) -> Report:
|
|
79
|
+
reg = registry or load_all()
|
|
80
|
+
settings = config or Config()
|
|
81
|
+
candidates = [r for r in reg.all() if settings.enabled(r.id)]
|
|
82
|
+
runnable, skipped = selectable(
|
|
83
|
+
candidates, has_repo=repo is not None, has_provider=has_provider
|
|
84
|
+
)
|
|
85
|
+
for disabled in sorted(settings.disabled):
|
|
86
|
+
if disabled in reg:
|
|
87
|
+
skipped[disabled] = "disabled in .resint.yml"
|
|
88
|
+
|
|
89
|
+
report = Report(skipped=skipped, ran=[r.id for r in runnable])
|
|
90
|
+
report.unchecked = list(getattr(paper, "unchecked", []))
|
|
91
|
+
|
|
92
|
+
if repo is not None:
|
|
93
|
+
report.unchecked.extend(getattr(repo, "unchecked", []))
|
|
94
|
+
|
|
95
|
+
ctx = Context(paper=paper, repo=repo)
|
|
96
|
+
for rule in runnable:
|
|
97
|
+
report.findings.extend(rule.run(ctx))
|
|
98
|
+
report.unchecked.extend(ctx.abstentions)
|
|
99
|
+
|
|
100
|
+
# Rules may discover during evaluation that an input was ambiguous.
|
|
101
|
+
# Those notes are collected after the run rather than before, since a
|
|
102
|
+
# rule abstaining is exactly the moment the reason becomes known.
|
|
103
|
+
for note in getattr(paper, "unchecked", []):
|
|
104
|
+
if note not in report.unchecked:
|
|
105
|
+
report.unchecked.append(note)
|
|
106
|
+
|
|
107
|
+
# Suppression is a reporting concern, applied after every rule has run:
|
|
108
|
+
# a suppressed finding still exists, is still counted in JSON, and can
|
|
109
|
+
# therefore never hide a regression from the corpus.
|
|
110
|
+
report.findings, notes = settings.apply(report.findings)
|
|
111
|
+
report.notes.extend(notes)
|
|
112
|
+
|
|
113
|
+
if min_severity is not None:
|
|
114
|
+
floor = _SEVERITY_RANK[min_severity]
|
|
115
|
+
report.findings = [
|
|
116
|
+
f for f in report.findings if _SEVERITY_RANK[f.severity] <= floor
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
report.findings.sort(
|
|
120
|
+
key=lambda f: (_SEVERITY_RANK[f.severity], f.rule_id, f.anchors[0].start)
|
|
121
|
+
)
|
|
122
|
+
return report
|
resint/ir/__init__.py
ADDED
|
File without changes
|