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.
Files changed (51) hide show
  1. resint/__init__.py +39 -0
  2. resint/cli.py +246 -0
  3. resint/config.py +191 -0
  4. resint/engine.py +122 -0
  5. resint/ir/__init__.py +0 -0
  6. resint/ir/finding.py +186 -0
  7. resint/ir/paper.py +242 -0
  8. resint/ir/repo.py +207 -0
  9. resint/ir/span.py +75 -0
  10. resint/mathx/__init__.py +0 -0
  11. resint/mathx/special.py +159 -0
  12. resint/parse/__init__.py +0 -0
  13. resint/parse/bibtex.py +222 -0
  14. resint/parse/citations.py +63 -0
  15. resint/parse/code.py +235 -0
  16. resint/parse/configs.py +165 -0
  17. resint/parse/document.py +163 -0
  18. resint/parse/extract.py +359 -0
  19. resint/parse/latex.py +334 -0
  20. resint/parse/repo.py +235 -0
  21. resint/parse/tables.py +288 -0
  22. resint/report/__init__.py +0 -0
  23. resint/report/sarif.py +130 -0
  24. resint/report/terminal.py +151 -0
  25. resint/resolve/__init__.py +19 -0
  26. resint/resolve/base.py +117 -0
  27. resint/resolve/http.py +237 -0
  28. resint/rules/__init__.py +31 -0
  29. resint/rules/bib/__init__.py +0 -0
  30. resint/rules/bib/drift.py +86 -0
  31. resint/rules/bib/orphans.py +74 -0
  32. resint/rules/bib/unresolved.py +74 -0
  33. resint/rules/numbers/__init__.py +0 -0
  34. resint/rules/numbers/internal_mismatch.py +110 -0
  35. resint/rules/numbers/table_arithmetic.py +115 -0
  36. resint/rules/registry.py +244 -0
  37. resint/rules/repro/__init__.py +0 -0
  38. resint/rules/repro/deps.py +61 -0
  39. resint/rules/repro/entrypoint.py +43 -0
  40. resint/rules/repro/ghost.py +90 -0
  41. resint/rules/repro/hparam_drift.py +130 -0
  42. resint/rules/repro/seed_claim.py +97 -0
  43. resint/rules/stats/__init__.py +0 -0
  44. resint/rules/stats/grim.py +118 -0
  45. resint/rules/stats/pvalue.py +163 -0
  46. resint/rules/stats/significance.py +93 -0
  47. resint-0.1.0.dist-info/METADATA +303 -0
  48. resint-0.1.0.dist-info/RECORD +51 -0
  49. resint-0.1.0.dist-info/WHEEL +4 -0
  50. resint-0.1.0.dist-info/entry_points.txt +2 -0
  51. 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