refaudit 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.
refaudit/__init__.py ADDED
@@ -0,0 +1,39 @@
1
+ """refaudit -- verify bibliography entries against external indexes.
2
+
3
+ Built for the case where a venue runs an automated check for hallucinated or
4
+ malformed references and a false entry costs you a desk reject.
5
+
6
+ from refaudit import Checker, parse_file, default_resolvers
7
+
8
+ entries = parse_file("refs.bib")
9
+ checker = Checker(default_resolvers("you@example.org"))
10
+ for result in checker.check_all(entries):
11
+ print(result.key, result.verdict.value)
12
+ """
13
+
14
+ from .bibtex import cited_keys, find_tex, parse_file, parse_string
15
+ from .cache import Cache
16
+ from .checker import Checker, Thresholds
17
+ from .models import CheckResult, Entry, Found, NotFound, Record, Unavailable, Verdict
18
+ from .resolvers import default_resolvers
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ __all__ = [
23
+ "Cache",
24
+ "CheckResult",
25
+ "Checker",
26
+ "Entry",
27
+ "Found",
28
+ "NotFound",
29
+ "Record",
30
+ "Thresholds",
31
+ "Unavailable",
32
+ "Verdict",
33
+ "__version__",
34
+ "cited_keys",
35
+ "default_resolvers",
36
+ "find_tex",
37
+ "parse_file",
38
+ "parse_string",
39
+ ]
refaudit/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
refaudit/bibtex.py ADDED
@@ -0,0 +1,133 @@
1
+ """A small BibTeX reader.
2
+
3
+ Only what a checker needs: entry type, cite key, and fields as raw strings. It
4
+ does not expand ``@string`` macros or resolve crossrefs, because a checker that
5
+ silently mis-parses is worse than one that reports what it saw.
6
+
7
+ Brace matching is done by walking the source rather than with a regular
8
+ expression, since BibTeX values nest braces freely (``title = {The {LLM} Era}``)
9
+ and a regex will either stop early or run away.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import re
15
+ from pathlib import Path
16
+
17
+ from .models import Entry
18
+
19
+ _ENTRY_START = re.compile(r"@(?P<type>\w+)\s*[{(]\s*(?P<key>[^,\s{}]+)\s*,", re.MULTILINE)
20
+ _FIELD = re.compile(r"(\w+)\s*=\s*", re.MULTILINE)
21
+
22
+ # Entry types that are not published records and have no external index.
23
+ NON_ARCHIVAL_TYPES = frozenset({"misc", "online", "manual", "unpublished", "software"})
24
+
25
+
26
+ def _match_brace(src: str, open_idx: int) -> int:
27
+ """Index of the brace closing the one at ``open_idx``; ``len(src)`` if unbalanced."""
28
+ depth = 0
29
+ i = open_idx
30
+ n = len(src)
31
+ while i < n:
32
+ c = src[i]
33
+ if c == "\\": # skip escaped char
34
+ i += 2
35
+ continue
36
+ if c == "{":
37
+ depth += 1
38
+ elif c == "}":
39
+ depth -= 1
40
+ if depth == 0:
41
+ return i
42
+ i += 1
43
+ return n
44
+
45
+
46
+ def _read_value(src: str, i: int) -> tuple[str, int]:
47
+ """Read a field value starting at ``i``; returns (value, index after it)."""
48
+ n = len(src)
49
+ while i < n and src[i].isspace():
50
+ i += 1
51
+ if i >= n:
52
+ return "", i
53
+ if src[i] == "{":
54
+ end = _match_brace(src, i)
55
+ return src[i + 1:end], min(end + 1, n)
56
+ if src[i] == '"':
57
+ j = i + 1
58
+ while j < n:
59
+ if src[j] == "\\":
60
+ j += 2
61
+ continue
62
+ if src[j] == '"':
63
+ break
64
+ j += 1
65
+ return src[i + 1:j], min(j + 1, n)
66
+ # bare value (number, macro name) up to the next comma or closing brace
67
+ j = i
68
+ while j < n and src[j] not in ",}\n":
69
+ j += 1
70
+ return src[i:j].strip(), j
71
+
72
+
73
+ def _clean(value: str) -> str:
74
+ return re.sub(r"\s+", " ", value).strip()
75
+
76
+
77
+ def parse_string(src: str) -> list[Entry]:
78
+ entries: list[Entry] = []
79
+ for m in _ENTRY_START.finditer(src):
80
+ etype = m.group("type").lower()
81
+ if etype in {"comment", "preamble", "string"}:
82
+ continue
83
+ body_start = src.index("{", m.start()) if "{" in src[m.start():m.end()] else m.end()
84
+ body_end = _match_brace(src, body_start)
85
+ body = src[m.end():body_end]
86
+
87
+ fields: dict[str, str] = {}
88
+ i = 0
89
+ while True:
90
+ fm = _FIELD.search(body, i)
91
+ if not fm:
92
+ break
93
+ value, i = _read_value(body, fm.end())
94
+ fields[fm.group(1).lower()] = _clean(value)
95
+ entries.append(Entry(key=m.group("key").strip(), entry_type=etype, fields=fields))
96
+ return entries
97
+
98
+
99
+ def parse_file(path: str | Path) -> list[Entry]:
100
+ return parse_string(Path(path).read_text(encoding="utf-8", errors="replace"))
101
+
102
+
103
+ _CITE = re.compile(r"\\[a-zA-Z]*cite[a-zA-Z]*\*?\s*(?:\[[^\]]*\]\s*)*\{([^}]*)\}")
104
+
105
+
106
+ def cited_keys(tex_paths: list[Path]) -> set[str]:
107
+ """Cite keys appearing in live (non-commented) LaTeX.
108
+
109
+ Uncited entries never reach the reference list, so checking them is optional
110
+ work; separating them also keeps the report focused on what a reviewer sees.
111
+ """
112
+ keys: set[str] = set()
113
+ for path in tex_paths:
114
+ try:
115
+ text = path.read_text(encoding="utf-8", errors="replace")
116
+ except OSError:
117
+ continue
118
+ for line in text.splitlines():
119
+ stripped = line.lstrip()
120
+ if stripped.startswith("%"):
121
+ continue
122
+ # drop trailing comments, honouring \%
123
+ line = re.sub(r"(?<!\\)%.*$", "", line)
124
+ for m in _CITE.finditer(line):
125
+ keys.update(k.strip() for k in m.group(1).split(",") if k.strip())
126
+ return keys
127
+
128
+
129
+ def find_tex(root: str | Path) -> list[Path]:
130
+ root = Path(root)
131
+ if root.is_file():
132
+ return [root]
133
+ return sorted(p for p in root.rglob("*.tex") if p.is_file())
refaudit/cache.py ADDED
@@ -0,0 +1,86 @@
1
+ """Versioned, on-disk cache of resolver results.
2
+
3
+ A run over a few hundred references takes minutes because it is deliberately
4
+ slow, so it must be resumable. Three properties matter:
5
+
6
+ * **Versioned.** The cache key includes a schema version; changing how records
7
+ are interpreted invalidates old entries instead of silently mixing them.
8
+ * **Only successes are cached.** A failure usually means a service was busy, and
9
+ caching that would bake a transient outage into every later run.
10
+ * **Written atomically.** A run interrupted mid-write must not leave a truncated
11
+ JSON file that breaks the next run.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import tempfile
19
+ import time
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ SCHEMA_VERSION = 2
24
+
25
+
26
+ class Cache:
27
+ def __init__(self, path: str | Path, ttl_days: float = 90.0) -> None:
28
+ self.path = Path(path)
29
+ self.ttl = ttl_days * 86400.0
30
+ self._data: dict[str, dict[str, Any]] = {}
31
+ self._dirty = False
32
+ self._load()
33
+
34
+ def _load(self) -> None:
35
+ if not self.path.exists():
36
+ return
37
+ try:
38
+ blob = json.loads(self.path.read_text(encoding="utf-8"))
39
+ except (OSError, json.JSONDecodeError):
40
+ return # corrupt or unreadable: start clean rather than crash
41
+ if blob.get("schema") != SCHEMA_VERSION:
42
+ return
43
+ self._data = blob.get("entries", {})
44
+
45
+ def get(self, key: str) -> dict[str, Any] | None:
46
+ item = self._data.get(key)
47
+ if not item:
48
+ return None
49
+ if self.ttl and time.time() - item.get("stored_at", 0) > self.ttl:
50
+ self._data.pop(key, None)
51
+ self._dirty = True
52
+ return None
53
+ return item.get("value")
54
+
55
+ def put(self, key: str, value: dict[str, Any]) -> None:
56
+ self._data[key] = {"stored_at": time.time(), "value": value}
57
+ self._dirty = True
58
+
59
+ def flush(self) -> None:
60
+ if not self._dirty:
61
+ return
62
+ self.path.parent.mkdir(parents=True, exist_ok=True)
63
+ payload = json.dumps(
64
+ {"schema": SCHEMA_VERSION, "entries": self._data}, ensure_ascii=False
65
+ )
66
+ fd, tmp = tempfile.mkstemp(dir=str(self.path.parent), suffix=".tmp")
67
+ try:
68
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
69
+ fh.write(payload)
70
+ fh.flush()
71
+ os.fsync(fh.fileno())
72
+ os.replace(tmp, self.path) # atomic on POSIX and Windows
73
+ self._dirty = False
74
+ finally:
75
+ if os.path.exists(tmp):
76
+ try:
77
+ os.unlink(tmp)
78
+ except OSError:
79
+ pass
80
+
81
+ # PYI034 prefers Self, which is 3.11+; this package supports 3.10.
82
+ def __enter__(self) -> Cache: # noqa: PYI034
83
+ return self
84
+
85
+ def __exit__(self, *exc: object) -> None:
86
+ self.flush()
refaudit/checker.py ADDED
@@ -0,0 +1,205 @@
1
+ """Orchestration: try resolvers in order, then judge the match.
2
+
3
+ Two rules govern the whole design.
4
+
5
+ **A resolver that could not be reached produces no verdict.** If every resolver
6
+ that applies to an entry came back ``Unavailable``, the result is ``UNVERIFIED``
7
+ and the report says so separately from real findings. This is what stops the
8
+ tool crying wolf when a service is rate-limiting the network.
9
+
10
+ **Identifier evidence outranks title evidence.** A DOI that Crossref says does
11
+ not exist is a finding. A title search that returns something different is only
12
+ a finding if we had no identifier to go on -- otherwise it is just a weak search
13
+ result, and treating it as a mismatch would flag every arXiv-only workshop paper
14
+ that Crossref happens not to index.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Iterable, Sequence
20
+ from dataclasses import dataclass
21
+
22
+ from .bibtex import NON_ARCHIVAL_TYPES
23
+ from .cache import Cache
24
+ from .models import (
25
+ CheckResult,
26
+ Entry,
27
+ Found,
28
+ NotFound,
29
+ Record,
30
+ Unavailable,
31
+ Verdict,
32
+ )
33
+ from .normalize import clean_arxiv_id, clean_doi, first_surname, similarity, surnames_match
34
+ from .resolvers.base import Resolver
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class Thresholds:
39
+ title_match: float = 0.75 # at or above this, the titles are the same work
40
+ title_suspect: float = 0.45 # below this, a resolved record is clearly a different paper
41
+ year_slack: int = 1 # preprint/publication years legitimately differ by one
42
+
43
+
44
+ DEFAULT_THRESHOLDS = Thresholds()
45
+
46
+
47
+ class Checker:
48
+ def __init__(
49
+ self,
50
+ resolvers: Sequence[Resolver],
51
+ *,
52
+ cache: Cache | None = None,
53
+ thresholds: Thresholds = DEFAULT_THRESHOLDS,
54
+ ) -> None:
55
+ if not resolvers:
56
+ raise ValueError("at least one resolver is required")
57
+ self.resolvers = list(resolvers)
58
+ self.cache = cache
59
+ self.thresholds = thresholds
60
+
61
+ # -- public ------------------------------------------------------------
62
+
63
+ def check(self, entry: Entry, *, cited: bool | None = None) -> CheckResult:
64
+ cached = self.cache.get(self._cache_key(entry)) if self.cache else None
65
+ if cached:
66
+ return CheckResult(
67
+ key=entry.key,
68
+ verdict=Verdict(cached["verdict"]),
69
+ entry_title=entry.title,
70
+ found_title=cached.get("found_title", ""),
71
+ source=cached.get("source", ""),
72
+ similarity=cached.get("similarity"),
73
+ note=cached.get("note", ""),
74
+ cited=cited,
75
+ )
76
+
77
+ result = self._check_uncached(entry, cited)
78
+
79
+ # Only cache outcomes that reflect the entry, not our connectivity.
80
+ if self.cache and result.verdict is not Verdict.UNVERIFIED:
81
+ self.cache.put(
82
+ self._cache_key(entry),
83
+ {
84
+ "verdict": result.verdict.value,
85
+ "found_title": result.found_title,
86
+ "source": result.source,
87
+ "similarity": result.similarity,
88
+ "note": result.note,
89
+ },
90
+ )
91
+ return result
92
+
93
+ def check_all(self, entries: Iterable[Entry], cited: set[str] | None = None):
94
+ for entry in entries:
95
+ yield self.check(entry, cited=(entry.key in cited) if cited is not None else None)
96
+
97
+ # -- internals ---------------------------------------------------------
98
+
99
+ @staticmethod
100
+ def _cache_key(entry: Entry) -> str:
101
+ # Include the fields we compare, so editing an entry re-checks it.
102
+ return "|".join([
103
+ entry.key,
104
+ clean_doi(entry.doi),
105
+ clean_arxiv_id(entry.arxiv_id),
106
+ entry.title.strip().lower()[:200],
107
+ str(entry.year or ""),
108
+ ])
109
+
110
+ def _check_uncached(self, entry: Entry, cited: bool | None) -> CheckResult:
111
+ applicable = [r for r in self.resolvers if r.can_handle(entry)]
112
+
113
+ has_identifier = bool(clean_doi(entry.doi) or clean_arxiv_id(entry.arxiv_id))
114
+ if not applicable:
115
+ if entry.entry_type in NON_ARCHIVAL_TYPES and not has_identifier:
116
+ return CheckResult(entry.key, Verdict.SKIPPED, entry.title,
117
+ note=f"@{entry.entry_type} with no identifier", cited=cited)
118
+ return CheckResult(entry.key, Verdict.NOT_FOUND, entry.title,
119
+ note="no resolver could handle this entry", cited=cited)
120
+
121
+ any_unavailable: list[str] = []
122
+ authoritative_absence: list[str] = []
123
+
124
+ for resolver in applicable:
125
+ outcome = resolver.resolve(entry)
126
+
127
+ if isinstance(outcome, Unavailable):
128
+ any_unavailable.append(f"{outcome.source}: {outcome.reason[:60]}")
129
+ continue
130
+
131
+ if isinstance(outcome, NotFound):
132
+ # A DOI the registrar does not know is a real problem. A failed
133
+ # title search is not, on its own.
134
+ if resolver.name.endswith(":doi") and clean_doi(entry.doi):
135
+ return CheckResult(entry.key, Verdict.DEAD_DOI, entry.title,
136
+ source=resolver.name,
137
+ note=f"DOI {clean_doi(entry.doi)} not registered",
138
+ cited=cited)
139
+ authoritative_absence.append(f"{outcome.source}: {outcome.detail[:50]}")
140
+ continue
141
+
142
+ if isinstance(outcome, Found):
143
+ return self._judge(entry, outcome.record, resolver, has_identifier, cited)
144
+
145
+ if any_unavailable and not authoritative_absence:
146
+ return CheckResult(entry.key, Verdict.UNVERIFIED, entry.title,
147
+ note="; ".join(any_unavailable)[:160], cited=cited)
148
+ if entry.entry_type in NON_ARCHIVAL_TYPES and not has_identifier:
149
+ return CheckResult(entry.key, Verdict.SKIPPED, entry.title,
150
+ note=f"@{entry.entry_type} not indexed", cited=cited)
151
+ note = "; ".join(authoritative_absence + any_unavailable)[:160]
152
+ return CheckResult(entry.key, Verdict.NOT_FOUND, entry.title, note=note, cited=cited)
153
+
154
+ def _judge(
155
+ self,
156
+ entry: Entry,
157
+ record: Record,
158
+ resolver: Resolver,
159
+ has_identifier: bool,
160
+ cited: bool | None,
161
+ ) -> CheckResult:
162
+ score = similarity(entry.title, record.title)
163
+
164
+ def result(verdict: Verdict, note: str = "") -> CheckResult:
165
+ # Built explicitly rather than by unpacking a dict: the shared
166
+ # fields are identical for every branch, but keyword unpacking
167
+ # erases their types and hides genuine mistakes from the checker.
168
+ return CheckResult(
169
+ key=entry.key,
170
+ verdict=verdict,
171
+ entry_title=entry.title,
172
+ found_title=record.title,
173
+ source=resolver.name,
174
+ similarity=score,
175
+ note=note,
176
+ cited=cited,
177
+ )
178
+
179
+ if score < self.thresholds.title_match:
180
+ resolved_by_identifier = resolver.name.endswith(":doi") or "arxiv" in resolver.name
181
+ if resolved_by_identifier:
182
+ # The identifier points at a different paper. This is the
183
+ # signature of a fabricated or mis-copied citation.
184
+ return result(Verdict.TITLE_MISMATCH,
185
+ "identifier resolves to a different title")
186
+ if score < self.thresholds.title_suspect:
187
+ # A dataset or web resource with no identifier was never going
188
+ # to be in a citation index; a stray title hit is not a finding.
189
+ if entry.entry_type in NON_ARCHIVAL_TYPES and not has_identifier:
190
+ return result(Verdict.SKIPPED,
191
+ f"@{entry.entry_type} with no identifier; not indexed")
192
+ return result(Verdict.NOT_FOUND, "no close title match found")
193
+ return result(Verdict.UNVERIFIED,
194
+ "only a weak title match; no identifier to confirm")
195
+
196
+ want = first_surname(entry.get("author"))
197
+ if (want and record.first_author_surname
198
+ and not surnames_match(want, first_surname(record.first_author_surname))):
199
+ return result(Verdict.AUTHOR_MISMATCH,
200
+ f"bib={want} vs {record.first_author_surname.lower()}")
201
+
202
+ if entry.year and record.year and abs(entry.year - record.year) > self.thresholds.year_slack:
203
+ return result(Verdict.YEAR_MISMATCH, f"bib={entry.year} vs {record.year}")
204
+
205
+ return result(Verdict.OK)
refaudit/cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """Command line interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import __version__
12
+ from .bibtex import cited_keys, find_tex, parse_file
13
+ from .cache import Cache
14
+ from .checker import Checker, Thresholds
15
+ from .models import Verdict
16
+ from .resolvers import AVAILABLE, default_resolvers
17
+
18
+ EPILOG = """\
19
+ examples:
20
+ refaudit refs.bib --email you@uni.edu
21
+ refaudit refs.bib --email you@uni.edu --tex paper/sections --only-cited
22
+ refaudit refs.bib --email you@uni.edu --resolvers crossref:doi,openalex
23
+
24
+ exit status:
25
+ 0 no findings
26
+ 1 at least one entry needs a human look
27
+ 2 usage or input error
28
+ """
29
+
30
+
31
+ def build_parser() -> argparse.ArgumentParser:
32
+ p = argparse.ArgumentParser(
33
+ prog="refaudit",
34
+ description="Verify .bib entries against Crossref, arXiv and OpenAlex.",
35
+ epilog=EPILOG,
36
+ formatter_class=argparse.RawDescriptionHelpFormatter,
37
+ )
38
+ p.add_argument("bib", type=Path, help="path to the .bib file")
39
+ p.add_argument("--email", default=os.environ.get("REFAUDIT_EMAIL", ""),
40
+ help="contact address sent to the APIs (or set REFAUDIT_EMAIL). "
41
+ "Crossref and OpenAlex give identified callers a better pool.")
42
+ p.add_argument("--tex", type=Path,
43
+ help="directory or file of LaTeX sources, to determine which keys are cited")
44
+ p.add_argument("--only-cited", action="store_true",
45
+ help="check only keys cited in --tex (uncited entries never reach the PDF)")
46
+ p.add_argument("--resolvers", default="",
47
+ help=f"comma-separated subset of: {', '.join(AVAILABLE)}")
48
+ p.add_argument("--out", type=Path, default=Path("refaudit-out"),
49
+ help="output directory (default: refaudit-out)")
50
+ p.add_argument("--cache", type=Path, help="cache file (default: <out>/cache.json)")
51
+ p.add_argument("--no-cache", action="store_true", help="ignore and do not write the cache")
52
+ p.add_argument("--ttl-days", type=float, default=90.0, help="cache lifetime (default: 90)")
53
+ p.add_argument("--timeout", type=float, default=20.0, help="per-request timeout seconds")
54
+ p.add_argument("--title-match", type=float, default=Thresholds.title_match,
55
+ help="similarity at or above which two titles are the same work")
56
+ p.add_argument("--quiet", action="store_true", help="only print the summary")
57
+ p.add_argument("--version", action="version", version=f"refaudit {__version__}")
58
+ return p
59
+
60
+
61
+ def main(argv: list[str] | None = None) -> int:
62
+ args = build_parser().parse_args(argv)
63
+
64
+ if not args.bib.is_file():
65
+ print(f"error: no such file: {args.bib}", file=sys.stderr)
66
+ return 2
67
+ if not args.email:
68
+ print("error: --email is required (or set REFAUDIT_EMAIL).\n"
69
+ " Crossref and OpenAlex ask callers to identify themselves, and\n"
70
+ " doing so puts you in a more reliable request pool.", file=sys.stderr)
71
+ return 2
72
+ if args.only_cited and not args.tex:
73
+ print("error: --only-cited requires --tex", file=sys.stderr)
74
+ return 2
75
+
76
+ entries = parse_file(args.bib)
77
+ if not entries:
78
+ print(f"error: no entries parsed from {args.bib}", file=sys.stderr)
79
+ return 2
80
+
81
+ cited: set[str] | None = None
82
+ if args.tex:
83
+ cited = cited_keys(find_tex(args.tex))
84
+ if args.only_cited:
85
+ entries = [e for e in entries if e.key in cited]
86
+ if not entries:
87
+ print("error: no cited entries found; is --tex pointing at the right place?",
88
+ file=sys.stderr)
89
+ return 2
90
+
91
+ only = [r.strip() for r in args.resolvers.split(",") if r.strip()] or None
92
+ try:
93
+ resolvers = default_resolvers(args.email, only=only, timeout=args.timeout)
94
+ except ValueError as e:
95
+ print(f"error: {e}", file=sys.stderr)
96
+ return 2
97
+
98
+ args.out.mkdir(parents=True, exist_ok=True)
99
+ cache = None if args.no_cache else Cache(args.cache or (args.out / "cache.json"),
100
+ ttl_days=args.ttl_days)
101
+ checker = Checker(resolvers, cache=cache,
102
+ thresholds=Thresholds(title_match=args.title_match))
103
+
104
+ results = []
105
+ try:
106
+ for i, result in enumerate(checker.check_all(entries, cited=cited), 1):
107
+ results.append(result)
108
+ if not args.quiet:
109
+ print(f"[{i}/{len(entries)}] {result.verdict.value:<15} {result.key}", flush=True)
110
+ if cache and i % 10 == 0:
111
+ cache.flush()
112
+ except KeyboardInterrupt:
113
+ print("\ninterrupted; partial results kept", file=sys.stderr)
114
+ finally:
115
+ if cache:
116
+ cache.flush()
117
+
118
+ return _report(results, args.out, bool(args.only_cited))
119
+
120
+
121
+ def _report(results, out: Path, only_cited: bool) -> int:
122
+ order = list(Verdict)
123
+ results.sort(key=lambda r: (order.index(r.verdict), r.key.lower()))
124
+
125
+ csv_path = out / "reference_check.csv"
126
+ with csv_path.open("w", newline="", encoding="utf-8") as fh:
127
+ rows = [r.as_row() for r in results]
128
+ writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
129
+ writer.writeheader()
130
+ writer.writerows(rows)
131
+
132
+ counts: dict[str, int] = {}
133
+ for r in results:
134
+ counts[r.verdict.value] = counts.get(r.verdict.value, 0) + 1
135
+
136
+ findings = [r for r in results if r.verdict.is_finding]
137
+ unverified = [r for r in results if r.verdict is Verdict.UNVERIFIED]
138
+
139
+ lines = ["refaudit", "=" * 72,
140
+ f"entries checked : {len(results)}" + (" (cited only)" if only_cited else ""),
141
+ "counts : " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())), ""]
142
+
143
+ if unverified:
144
+ lines += [f"{len(unverified)} entries could not be checked (a source was unreachable).",
145
+ "These are not findings. Re-run later or from another network.", ""]
146
+
147
+ if findings:
148
+ lines.append(f"--- {len(findings)} entries need a human look, worst first")
149
+ for r in findings:
150
+ lines.append(f" {r.verdict.value:<15} {r.key}")
151
+ lines.append(f" bib : {r.entry_title[:110]}")
152
+ if r.found_title:
153
+ lines.append(f" found : {r.found_title[:110]} [{r.source}]")
154
+ if r.note:
155
+ lines.append(f" why : {r.note}")
156
+ else:
157
+ lines.append("No findings.")
158
+
159
+ text = "\n".join(lines)
160
+ (out / "reference_check.txt").write_text(text + "\n", encoding="utf-8")
161
+ print("\n" + text)
162
+ print(f"\nwritten to {out}/")
163
+ return 1 if findings else 0
164
+
165
+
166
+ if __name__ == "__main__": # pragma: no cover
167
+ raise SystemExit(main())