touchneedle 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.
@@ -0,0 +1,146 @@
1
+ Metadata-Version: 2.4
2
+ Name: touchneedle
3
+ Version: 0.1.0
4
+ Summary: Verify that every citation and reference in a document is real, accurately described, and consistently used.
5
+ Author: ncoleman
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/nicoleman0/touchneedle
8
+ Project-URL: Source, https://github.com/nicoleman0/touchneedle
9
+ Project-URL: Changelog, https://github.com/nicoleman0/touchneedle/blob/main/CHANGELOG.md
10
+ Project-URL: Issues, https://github.com/nicoleman0/touchneedle/issues
11
+ Keywords: citations,references,bibliography,academic,fact-checking
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Text Processing :: Markup
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Requires-Python: >=3.11
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Dynamic: license-file
27
+
28
+ # touchneedle
29
+
30
+ Citiation tool that verifies the citations in a document are real,
31
+ accurately described, and consistently used.
32
+
33
+ Useful for both students and examiners who wish to corroborate citations.
34
+
35
+ It works standalone, or as a coding agent skill.
36
+
37
+ Most commercial citation checkers want a `.bib` file and check it against academic
38
+ databases. That covers journal articles but misses standards,
39
+ specifications, vendor documentation, and blog posts. In a lot of real
40
+ bibliographies, this is half the list.
41
+
42
+ So this tool parses a **prose reference list** (Harvard/author-date)
43
+ straight out of Markdown or `.docx`, and routes each entry to whichever
44
+ authority can actually confirm it.
45
+
46
+ ## What it checks
47
+
48
+ **Existence and metadata** — scripted and deterministic:
49
+
50
+ | Entry carries | Checked against |
51
+ |---|---|
52
+ | arXiv id | arXiv API |
53
+ | DOI | Crossref |
54
+ | RFC number | IETF datatracker, falling back to rfc-editor |
55
+ | `draft-*` name | IETF datatracker, **including whether the cited revision is still current** |
56
+ | Quoted title in an academic venue | Crossref, then OpenAlex, by title |
57
+ | A URL and nothing else | Fetched live; page title compared with the cited title |
58
+
59
+ Entries with both an identifier and a URL get both, so a real paper behind a dead
60
+ link is still reported. Detects the fabricated-citation signature — a real title
61
+ carrying the wrong authors — as `MISMATCH`.
62
+
63
+ **Internal consistency** — every in-text citation resolves to a list entry, every
64
+ list entry is cited somewhere, and `2025a`/`2025b` suffixes are used unambiguously.
65
+
66
+ **Claim support** — the pass that needs reading rather than fetching. `claims`
67
+ emits a worklist pairing each in-text citation with the sentence making the claim
68
+ and a locator for the source; the model then reads each source and rules
69
+ SUPPORTED / PARTIAL / UNSUPPORTED / INACCESSIBLE. This catches the failure the
70
+ database checks cannot: a genuine source attached to a claim it does not make.
71
+
72
+ ## Install
73
+
74
+ As a command-line tool:
75
+
76
+ ```bash
77
+ pip install touchneedle
78
+ ```
79
+
80
+ As a Claude Code skill:
81
+
82
+ ```bash
83
+ git clone https://github.com/nicoleman0/touchneedle ~/.claude/skills/touchneedle
84
+ ```
85
+
86
+ Or as a Claude Code plugin:
87
+
88
+ ```
89
+ /plugin marketplace add nicoleman0/touchneedle
90
+ /plugin install touchneedle
91
+ ```
92
+
93
+ There are no dependencies beyond Python 3.11+. `pandoc` is needed only for `.docx` input.
94
+
95
+ Then, in Claude Code: *"check the citations in thesis.docx"*.
96
+
97
+ ## Use directly
98
+
99
+ ```bash
100
+ touchneedle check thesis.docx --out report.md --json data.json
101
+ touchneedle claims thesis.docx --out claims.md
102
+ ```
103
+
104
+ From a clone, without installing, that is `python3 scripts/touchneedle.py …` —
105
+ the same file either way.
106
+
107
+ Options: `--offline` (parse and cross-check only, no network), `--cache DIR`
108
+ (HTTP cache, 7-day TTL, so re-runs are nearly free), `--timeout N`, and
109
+ `--mailto you@example.com` for Crossref and OpenAlex's polite rate-limit pool.
110
+ `--mailto` is off by default and never inferred — it sends an address to third
111
+ parties.
112
+
113
+ `check` exits 2 when something needs attention, 0 when clean, so it drops into CI.
114
+
115
+ ## Statuses
116
+
117
+ `MISMATCH` and `NOT_FOUND` are the ones that damage a submission. `LINK_DEAD` and
118
+ `STALE` need a fix but not a retraction. `PARTIAL`, `LINK_MOVED` and
119
+ `UNVERIFIABLE` are for a glance — notably, PDFs and JS-rendered pages land in
120
+ `PARTIAL` routinely, because no `<title>` can be read from them. A `PARTIAL` is a
121
+ limit of the check, not evidence against the citation.
122
+
123
+ ## Limits
124
+
125
+ Author-date reference lists only — numeric styles (Vancouver, IEEE) are not
126
+ parsed. Page numbers, edition and publisher details are not checked.
127
+
128
+ Sources behind paywalls cannot be verified beyond their metadata record.
129
+
130
+ The list of in-text citations with no matching entry has expected false positives,
131
+ because a regex cannot distinguish `(Smith, 2024)` from `(ICLR 2023)`.
132
+
133
+ ## Development
134
+
135
+ ```bash
136
+ python3 -m unittest discover -s tests -t tests
137
+ ```
138
+
139
+ See [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
140
+
141
+ The short version: standard library only, tests stay offline, and never let a coverage gap
142
+ report itself as a finding.
143
+
144
+ ## Licence
145
+
146
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,7 @@
1
+ touchneedle.py,sha256=jHTBhqSA--fFsGoUg6VMq04u7vQ1j57V2ybncvtRIdw,37593
2
+ touchneedle-0.1.0.dist-info/licenses/LICENSE,sha256=KF0EZ7NOibsbO2Qw9TMOX9qJrs3_WRQgSgoRpIBVUA0,1065
3
+ touchneedle-0.1.0.dist-info/METADATA,sha256=ZthC6oc4EQxuqnvljB1RAliyDeA0vq8FQLbsfYospiU,5347
4
+ touchneedle-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ touchneedle-0.1.0.dist-info/entry_points.txt,sha256=artVA-3Ladu8OAjBzWSw52TEppLMyAG2O85HcOkscRs,49
6
+ touchneedle-0.1.0.dist-info/top_level.txt,sha256=68kjUp0j8L2_Hn0q9hAjbPhmSBeEc5ScFnf3Qncwlw4,12
7
+ touchneedle-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ touchneedle = touchneedle:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ncoleman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ touchneedle
touchneedle.py ADDED
@@ -0,0 +1,911 @@
1
+ #!/usr/bin/env python3
2
+ """Verify that the citations in a document are real, correctly described, and
3
+ consistently used.
4
+
5
+ Reads a prose reference list (Harvard/author-date) out of a Markdown or .docx
6
+ document, routes each entry to whichever authority can actually confirm it --
7
+ arXiv, Crossref, OpenAlex, the IETF datatracker, or the live web -- and reports
8
+ what does not line up. Also cross-checks in-text citations against the list in
9
+ both directions.
10
+
11
+ Standard library only. Python 3.11+.
12
+
13
+ touchneedle.py check DOC [--out report.md] [--json data.json]
14
+ touchneedle.py claims DOC [--out claims.md]
15
+
16
+ Exit status is 2 when the run found problems worth a human look, 0 when clean.
17
+ """
18
+
19
+ import argparse
20
+ import dataclasses
21
+ import difflib
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import re
26
+ import shutil
27
+ import subprocess
28
+ import sys
29
+ import time
30
+ import unicodedata
31
+ import urllib.error
32
+ import urllib.parse
33
+ import urllib.request
34
+ import xml.etree.ElementTree as ET
35
+ from collections.abc import Iterable
36
+ from typing import Any
37
+
38
+ __version__ = "0.1.0"
39
+ # Goes out in the User-Agent below, so it has to point somewhere a rate-limited
40
+ # API operator can actually reach a human. One token, repo-wide -- see RELEASING.md.
41
+ REPO_URL = "https://github.com/nicoleman0/touchneedle"
42
+ UA = f"touchneedle/{__version__} (+{REPO_URL}; academic reference verification)"
43
+ CACHE_TTL = 7 * 24 * 3600
44
+
45
+ # Verification outcomes, worst first -- the report sorts by this order.
46
+ SEVERITY = {
47
+ "MISMATCH": 0,
48
+ "NOT_FOUND": 1,
49
+ "LINK_DEAD": 2,
50
+ "STALE": 3,
51
+ "PARTIAL": 4,
52
+ "LINK_MOVED": 5,
53
+ "UNVERIFIABLE": 6,
54
+ "VERIFIED": 7,
55
+ }
56
+ PROBLEM_STATUSES = {"MISMATCH", "NOT_FOUND", "LINK_DEAD", "STALE"}
57
+
58
+
59
+ # --------------------------------------------------------------------------
60
+ # text loading and cleanup
61
+ # --------------------------------------------------------------------------
62
+
63
+ def load_text(path: str) -> str:
64
+ """Return document text as Markdown, converting .docx through pandoc."""
65
+ if path.lower().endswith((".docx", ".odt", ".rtf")):
66
+ if not shutil.which("pandoc"):
67
+ sys.exit(f"error: {path} needs pandoc to convert, and pandoc is not on PATH")
68
+ out = subprocess.run(
69
+ ["pandoc", path, "-t", "markdown", "--wrap=none"],
70
+ capture_output=True, text=True,
71
+ )
72
+ if out.returncode != 0:
73
+ sys.exit(f"error: pandoc failed on {path}:\n{out.stderr}")
74
+ return out.stdout
75
+ with open(path, encoding="utf-8") as fh:
76
+ return fh.read()
77
+
78
+
79
+ LATEX_NOISE = re.compile(r"\\(?:allowbreak|linebreak|newline|,|;|!)\{?\}?")
80
+ MARKBOTH = re.compile(r"\\markboth\{[^}]*\}\{[^}]*\}")
81
+
82
+
83
+ def clean(s: str) -> str:
84
+ """Strip the LaTeX/pandoc debris that survives a docx -> markdown pass."""
85
+ s = LATEX_NOISE.sub("", s)
86
+ s = MARKBOTH.sub("", s)
87
+ s = re.sub(r"\\([\'\"`^~$&%#_{}])", r"\1", s) # pandoc escapes
88
+ s = s.replace("\u00a0", " ").replace("\u2011", "-")
89
+ s = re.sub(r"[ \t]*\n[ \t]*", " ", s)
90
+ return re.sub(r"\s{2,}", " ", s).strip()
91
+
92
+
93
+ TITLES = r"references|bibliography|works cited|reference list"
94
+ HEADING = re.compile(r"^(#{1,4})\s*(?:\d+[.)]?\s*)?(?:" + TITLES + r")\b.*$", re.I | re.M)
95
+ # A .docx whose Word style never mapped to a heading level leaves the word
96
+ # sitting on a line of its own, sometimes bold or underlined.
97
+ BARE_HEADING = re.compile(r"^[ \t]*[*_]{0,2}(?:" + TITLES + r")[*_:]{0,2}[ \t]*$", re.I | re.M)
98
+
99
+
100
+ def split_document(text: str) -> tuple[str, str]:
101
+ """Split into (body, reference block).
102
+
103
+ Later candidates win -- the word appears in running prose long before the
104
+ list itself -- but a candidate only wins if entries can actually be parsed
105
+ below it, so a stray mention does not swallow the real list.
106
+ """
107
+ candidates = list(HEADING.finditer(text)) or list(BARE_HEADING.finditer(text))
108
+ if not candidates:
109
+ sys.exit("error: no 'References' / 'Bibliography' heading found in the document")
110
+
111
+ for match in reversed(candidates):
112
+ level = len(match.group(1)) if match.re is HEADING else 1
113
+ rest = text[match.end():]
114
+ nxt = re.search(r"^#{1," + str(level) + r"}\s+\S", rest, re.M)
115
+ block = rest[: nxt.start()] if nxt else rest
116
+ if len(split_entries(block)) >= 3:
117
+ return text[: match.start()], block
118
+ sys.exit("error: found a References heading but could not parse any entries under it")
119
+
120
+
121
+ SKIP_ENTRY = re.compile(r"^(\\markboth|\[\^|:::|<!--|!\[|\||\s*$)")
122
+
123
+
124
+ def split_entries(block: str) -> list[str]:
125
+ """Blank-line-separated entries, with a fallback for one-per-line lists."""
126
+ chunks = [c.strip() for c in re.split(r"\n[ \t]*\n", block)]
127
+ entries = [clean(c) for c in chunks if c and not SKIP_ENTRY.match(c)]
128
+ entries = [e for e in entries if len(e) > 25 and re.search(r"\((?:19|20)\d\d[a-z]?\)", e)]
129
+ if len(entries) <= 1 and block.count("\n") > 3:
130
+ # Single-spaced list: start a new entry at each line that opens with a
131
+ # capitalised author or organisation and carries a year.
132
+ entries, current = [], None
133
+ for line in block.splitlines():
134
+ if re.match(r"^[A-Z\u00c0-\u00dd][^\n]*\((?:19|20)\d\d[a-z]?\)", line.strip()):
135
+ if current is not None:
136
+ entries.append(clean(current))
137
+ current = line
138
+ elif current is not None:
139
+ # Anything before the first author-year line is preamble --
140
+ # a table row, a caption, a stray heading -- not an entry.
141
+ current += " " + line
142
+ if current is not None:
143
+ entries.append(clean(current))
144
+ entries = [e for e in entries
145
+ if len(e) > 25 and not SKIP_ENTRY.match(e)]
146
+ return entries
147
+
148
+
149
+ # --------------------------------------------------------------------------
150
+ # reference parsing
151
+ # --------------------------------------------------------------------------
152
+
153
+ @dataclasses.dataclass
154
+ class Reference:
155
+ raw: str
156
+ key: str = ""
157
+ name: str = "" # first-author surname, or organisation name
158
+ is_org: bool = False
159
+ year: str = ""
160
+ suffix: str = "" # the 'a' / 'b' in 2025a
161
+ title: str = ""
162
+ container: str = ""
163
+ url: str = ""
164
+ doi: str = ""
165
+ arxiv: str = ""
166
+ rfc: str = ""
167
+ draft: str = ""
168
+ draft_rev: str = ""
169
+ accessed: str = ""
170
+ kind: str = "unknown"
171
+ status: str = "UNVERIFIABLE"
172
+ notes: list[str] = dataclasses.field(default_factory=list)
173
+ evidence: list[str] = dataclasses.field(default_factory=list)
174
+ cited_by: list[str] = dataclasses.field(default_factory=list)
175
+
176
+
177
+ YEAR = re.compile(r"\((19|20)(\d\d)([a-z]?)\)")
178
+ # A quoted title may contain an apostrophe ("what you've signed up for"), so the
179
+ # closing quote is only the one followed by punctuation or end of entry.
180
+ QUOTED = re.compile(
181
+ r"(?:^|[\s(])[\u2018'\"\u201c](.{8,300}?)[\u2019'\"\u201d](?=\s*[,.;]|\s*$)")
182
+ URL_RE = re.compile(r"<?(https?://[^\s>)\]]+)>?")
183
+ DOI_RE = re.compile(r"\b(10\.\d{4,9}/[^\s,;>\)\]]+)")
184
+ ARXIV_RE = re.compile(r"arXiv[:\s]\s*(\d{4}\.\d{4,5})(v\d+)?", re.I)
185
+ RFC_RE = re.compile(r"\bRFC\s*(\d{3,5})\b", re.I)
186
+ DRAFT_RE = re.compile(r"\b(draft-[a-z0-9][a-z0-9\-]*[a-z0-9])\b", re.I)
187
+ ACCESSED_RE = re.compile(r"\(Accessed:?\s*([^)]+)\)", re.I)
188
+ PERSON_RE = re.compile(r"^[A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]+,\s*[A-Z]\.")
189
+
190
+ ACADEMIC = re.compile(
191
+ r"\b(proceedings|conference|symposium|workshop|journal|transactions|advances in|"
192
+ r"findings of|arxiv|preprint|acm|ieee|usenix|neurips|iclr|icml)\b", re.I)
193
+
194
+
195
+ def parse_entry(raw: str) -> Reference:
196
+ ref = Reference(raw=raw)
197
+
198
+ ym = YEAR.search(raw)
199
+ if ym:
200
+ ref.year = ym.group(1) + ym.group(2)
201
+ ref.suffix = ym.group(3)
202
+ authors = raw[: ym.start()].strip().rstrip(",")
203
+ tail = raw[ym.end():].strip()
204
+ else:
205
+ authors, tail = "", raw
206
+
207
+ ref.is_org = not PERSON_RE.match(authors)
208
+ if ref.is_org:
209
+ ref.name = authors.strip(" .,")
210
+ else:
211
+ ref.name = authors.split(",")[0].strip()
212
+
213
+ ref.key = f"{normalise(ref.name)}|{ref.year}{ref.suffix}"
214
+
215
+ qm = QUOTED.search(tail)
216
+ if qm:
217
+ ref.title = qm.group(1).strip()
218
+ ref.container = tail[qm.end():].lstrip(" ,.").strip()
219
+ else:
220
+ # Unquoted title: everything up to the first sentence break that is not
221
+ # part of an initial or a URL.
222
+ head = re.split(r"\.\s+(?=[A-Z])|\.\s*Available at", tail, maxsplit=1)
223
+ ref.title = head[0].strip(" .,")
224
+ ref.container = (head[1] if len(head) > 1 else "").strip()
225
+
226
+ if m := URL_RE.search(raw):
227
+ ref.url = m.group(1).rstrip(".,;")
228
+ if m := DOI_RE.search(raw):
229
+ ref.doi = m.group(1).rstrip(".")
230
+ if m := ARXIV_RE.search(raw):
231
+ ref.arxiv = m.group(1)
232
+ if m := RFC_RE.search(raw):
233
+ ref.rfc = m.group(1)
234
+ if m := DRAFT_RE.search(raw):
235
+ # Take the whole token greedily, then split a trailing -NN revision off
236
+ # it -- 'draft-ietf-oauth-v2-1-15' is v2-1 at revision 15.
237
+ full = m.group(1)
238
+ rm = re.match(r"^(.*?)-(\d{2})$", full)
239
+ ref.draft, ref.draft_rev = (rm.group(1), rm.group(2)) if rm else (full, "")
240
+ if m := ACCESSED_RE.search(raw):
241
+ ref.accessed = m.group(1).strip()
242
+ ref.title = re.sub(r"\s*\(Accessed:?[^)]*\)", "", ref.title).strip()
243
+ ref.title = re.sub(r"\s*Available at:?.*$", "", ref.title, flags=re.I)
244
+ # An unquoted title often absorbs the series identifier that follows it;
245
+ # trim it so title matching compares like with like.
246
+ ref.title = re.sub(
247
+ r",?\s*(RFC\s*\d+|BCP\s*\d+|Internet-Draft\s+draft-\S+|IETF)\b", "",
248
+ ref.title, flags=re.I).strip(" .,")
249
+
250
+ if ref.arxiv:
251
+ ref.kind = "arxiv"
252
+ elif ref.doi:
253
+ ref.kind = "doi"
254
+ elif ref.rfc:
255
+ ref.kind = "rfc"
256
+ elif ref.draft:
257
+ ref.kind = "ietf-draft"
258
+ elif qm and ACADEMIC.search(ref.container):
259
+ ref.kind = "paper"
260
+ elif ref.url:
261
+ ref.kind = "web"
262
+ return ref
263
+
264
+
265
+ # --------------------------------------------------------------------------
266
+ # in-text citations
267
+ # --------------------------------------------------------------------------
268
+
269
+ PAREN = re.compile(r"\(([^()]{3,120}?(?:19|20)\d\d[a-z]?[^()]{0,40}?)\)")
270
+ NARRATIVE = re.compile(
271
+ r"\b([A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]*"
272
+ r"(?:\s+(?:and|&)\s+[A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]*"
273
+ r"|\s+et\s+al\.?"
274
+ r"|\s+[A-Z\u00c0-\u00dd][\w\u00c0-\u017e'\u2019\-]*){0,3})"
275
+ r"\s+\(((?:19|20)\d\d)([a-z]?)\)")
276
+ NOT_A_CITATION = re.compile(
277
+ r"^(accessed|figure|table|chapter|section|appendix|see|eq|equation|n\.?d\.?)\b", re.I)
278
+
279
+
280
+ @dataclasses.dataclass
281
+ class Citation:
282
+ name: str
283
+ year: str
284
+ suffix: str
285
+ form: str # 'parenthetical' or 'narrative'
286
+ context: str
287
+ key: str = ""
288
+
289
+
290
+ def find_citations(body: str) -> list[Citation]:
291
+ out: list[Citation] = []
292
+ for m in PAREN.finditer(body):
293
+ inner = m.group(1)
294
+ if re.search(r"accessed", inner, re.I):
295
+ continue
296
+ for part in re.split(r";", inner):
297
+ part = part.strip()
298
+ cm = re.match(
299
+ r"^(.{2,80}?)[,\s]+((?:19|20)\d\d)([a-z]?)\s*$", part.replace("et al.", "et al"))
300
+ if not cm:
301
+ continue
302
+ name = cm.group(1).strip(" ,")
303
+ if NOT_A_CITATION.match(name) or not re.match(r"^[A-Z\u00c0-\u00dd]", name):
304
+ continue
305
+ out.append(Citation(name, cm.group(2), cm.group(3), "parenthetical",
306
+ context(body, m.start())))
307
+ for m in NARRATIVE.finditer(body):
308
+ name = m.group(1).strip()
309
+ if NOT_A_CITATION.match(name):
310
+ continue
311
+ out.append(Citation(name, m.group(2), m.group(3), "narrative",
312
+ context(body, m.start())))
313
+ return out
314
+
315
+
316
+ SENT_END = re.compile(r"(?<![A-Z])(?<!\bet al)(?<!\bvol)(?<!\bpp)[.!?](?:\s|$)")
317
+
318
+
319
+ def context(body: str, pos: int, width: int = 420) -> str:
320
+ """Approximate sentence around a position -- good enough for a worklist."""
321
+ start = max(0, pos - width)
322
+ end = min(len(body), pos + width)
323
+ left = body[start:pos]
324
+ right = body[pos:end]
325
+ headings = list(re.finditer(r"(?m)^#{1,6}[^\n]*$", left))
326
+ if headings: # don't drag the section title into the quote
327
+ left = left[headings[-1].end():]
328
+ bounds = list(SENT_END.finditer(left))
329
+ if bounds:
330
+ left = left[bounds[-1].end():]
331
+ fwd = SENT_END.search(right)
332
+ if fwd:
333
+ right = right[: fwd.end()]
334
+ return clean(left + right)
335
+
336
+
337
+ def normalise(s: str) -> str:
338
+ s = unicodedata.normalize("NFKD", s)
339
+ s = "".join(c for c in s if not unicodedata.combining(c))
340
+ s = re.sub(r"\bet\s+al\.?", "", s, flags=re.I)
341
+ s = re.sub(r"[^a-z0-9 ]", " ", s.lower())
342
+ return re.sub(r"\s+", " ", s).strip()
343
+
344
+
345
+ def citation_key(name: str, year: str, suffix: str) -> str:
346
+ # Split the ampersand off before normalise(), which strips punctuation and
347
+ # would otherwise leave 'Smith & Jones' as the single token 'smith jones'.
348
+ n = normalise(name.split("&")[0])
349
+ n = re.split(r"\band\b", n)[0].strip()
350
+ return f"{n}|{year}{suffix}"
351
+
352
+
353
+ def match_citations(refs: list[Reference], cites: list[Citation]) -> list[Citation]:
354
+ """Attach each in-text citation to a reference where one can be found."""
355
+ by_key = {r.key: r for r in refs}
356
+ by_surname: dict[str, list[Reference]] = {}
357
+ for r in refs:
358
+ first = normalise(r.name).split(" ")[0] if not r.is_org else normalise(r.name)
359
+ by_surname.setdefault(first, []).append(r)
360
+
361
+ for c in cites:
362
+ key = citation_key(c.name, c.year, c.suffix)
363
+ if key in by_key:
364
+ c.key = key
365
+ by_key[key].cited_by.append(c.form)
366
+ continue
367
+ # organisation names cite in full; person names cite by surname only
368
+ stem = key.split("|")[0]
369
+ head = stem.split(" ")[0]
370
+ for candidate_stem in (stem, head):
371
+ for r in by_surname.get(candidate_stem, []):
372
+ if r.year == c.year and (not c.suffix or c.suffix == r.suffix):
373
+ c.key = r.key
374
+ r.cited_by.append(c.form)
375
+ break
376
+ if c.key:
377
+ break
378
+ return cites
379
+
380
+
381
+ # --------------------------------------------------------------------------
382
+ # HTTP with an on-disk cache
383
+ # --------------------------------------------------------------------------
384
+
385
+ class Fetcher:
386
+ def __init__(self, cache_dir: str, timeout: int = 25, offline: bool = False,
387
+ mailto: str | None = None, delay: float = 0.4):
388
+ self.cache_dir = cache_dir
389
+ self.timeout = timeout
390
+ self.offline = offline
391
+ self.mailto = mailto
392
+ self.delay = delay
393
+ self.last = 0.0
394
+ os.makedirs(cache_dir, exist_ok=True)
395
+
396
+ def get(self, url: str, accept: str | None = None,
397
+ max_bytes: int = 400_000) -> dict[str, Any]:
398
+ path = os.path.join(self.cache_dir, hashlib.sha256(
399
+ (url + (accept or "")).encode()).hexdigest() + ".json")
400
+ if os.path.exists(path) and time.time() - os.path.getmtime(path) < CACHE_TTL:
401
+ with open(path, encoding="utf-8") as fh:
402
+ cached: dict[str, Any] = json.load(fh)
403
+ return cached
404
+ if self.offline:
405
+ return {"ok": False, "status": None, "error": "offline", "body": "", "final_url": url}
406
+
407
+ gap = self.delay - (time.time() - self.last)
408
+ if gap > 0:
409
+ time.sleep(gap)
410
+ self.last = time.time()
411
+
412
+ ua = UA if not self.mailto else f"{UA} mailto:{self.mailto}"
413
+ req = urllib.request.Request(url, headers={
414
+ "User-Agent": ua,
415
+ "Accept": accept or "text/html,application/json;q=0.9,*/*;q=0.8",
416
+ })
417
+ rec: dict[str, Any]
418
+ try:
419
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
420
+ raw = resp.read(max_bytes)
421
+ charset = resp.headers.get_content_charset() or "utf-8"
422
+ rec = {"ok": True, "status": resp.status,
423
+ "body": raw.decode(charset, "replace"),
424
+ "final_url": resp.geturl(), "error": None}
425
+ except urllib.error.HTTPError as e:
426
+ rec = {"ok": False, "status": e.code, "body": "", "final_url": url,
427
+ "error": f"HTTP {e.code}"}
428
+ except Exception as e: # timeouts, DNS, TLS, redirect loops
429
+ rec = {"ok": False, "status": None, "body": "", "final_url": url,
430
+ "error": f"{type(e).__name__}: {e}"}
431
+ with open(path, "w", encoding="utf-8") as fh:
432
+ json.dump(rec, fh)
433
+ return rec
434
+
435
+ def json(self, url: str) -> Any | None:
436
+ rec = self.get(url, accept="application/json")
437
+ if not rec["ok"]:
438
+ return None
439
+ try:
440
+ return json.loads(rec["body"])
441
+ except json.JSONDecodeError:
442
+ return None
443
+
444
+
445
+ # --------------------------------------------------------------------------
446
+ # similarity
447
+ # --------------------------------------------------------------------------
448
+
449
+ def title_score(a: str, b: str) -> float:
450
+ na, nb = normalise(a), normalise(b)
451
+ if not na or not nb:
452
+ return 0.0
453
+ seq = difflib.SequenceMatcher(None, na, nb).ratio()
454
+ ta, tb = set(na.split()), set(nb.split())
455
+ jac = len(ta & tb) / len(ta | tb) if ta | tb else 0.0
456
+ contain = 1.0 if (na in nb or nb in na) and min(len(na), len(nb)) > 20 else 0.0
457
+ return max(seq, jac, contain)
458
+
459
+
460
+ def author_present(surname: str, authors: Iterable[str]) -> bool:
461
+ s = normalise(surname).split(" ")[-1]
462
+ return any(s and s in normalise(a).split() for a in authors)
463
+
464
+
465
+ # --------------------------------------------------------------------------
466
+ # per-source verification
467
+ # --------------------------------------------------------------------------
468
+
469
+ def check_metadata(ref: Reference, found_title: str, found_authors: list[str],
470
+ found_year: str | None, source: str) -> None:
471
+ """Compare a retrieved record against the reference and set status/notes."""
472
+ score = title_score(ref.title, found_title)
473
+ ref.evidence.append(f"{source}: \u201c{found_title[:140]}\u201d"
474
+ + (f" ({found_year})" if found_year else ""))
475
+ problems = []
476
+ if score < 0.60:
477
+ problems.append(f"title differs from {source} record (similarity {score:.2f})")
478
+ elif score < 0.85:
479
+ ref.notes.append(f"title only partly matches {source} (similarity {score:.2f})")
480
+ if found_authors and not ref.is_org and not author_present(ref.name, found_authors):
481
+ problems.append(f"first author '{ref.name}' not among {source} authors "
482
+ f"({', '.join(found_authors[:4])})")
483
+ if found_year and ref.year and abs(int(found_year) - int(ref.year)) > 1:
484
+ problems.append(f"year {ref.year} vs {found_year} in {source}")
485
+
486
+ if problems:
487
+ ref.status = "MISMATCH"
488
+ ref.notes.extend(problems)
489
+ elif score >= 0.85:
490
+ ref.status = "VERIFIED"
491
+ else:
492
+ ref.status = "PARTIAL"
493
+
494
+
495
+ def verify_arxiv(ref: Reference, f: Fetcher) -> bool:
496
+ url = f"http://export.arxiv.org/api/query?id_list={ref.arxiv}&max_results=1"
497
+ rec = f.get(url, accept="application/atom+xml")
498
+ if not rec["ok"]:
499
+ ref.notes.append(f"arXiv API unreachable ({rec['error']})")
500
+ return False
501
+ try:
502
+ root = ET.fromstring(rec["body"])
503
+ except ET.ParseError:
504
+ return False
505
+ ns = {"a": "http://www.w3.org/2005/Atom"}
506
+ entry = root.find("a:entry", ns)
507
+ if entry is None or entry.findtext("a:title", "", ns).strip() in ("", "Error"):
508
+ ref.status = "NOT_FOUND"
509
+ ref.notes.append(f"arXiv has no paper with id {ref.arxiv}")
510
+ return True
511
+ title = " ".join(entry.findtext("a:title", "", ns).split())
512
+ authors = [a.findtext("a:name", "", ns) for a in entry.findall("a:author", ns)]
513
+ published = entry.findtext("a:published", "", ns)[:4] or None
514
+ check_metadata(ref, title, authors, published, f"arXiv:{ref.arxiv}")
515
+ return True
516
+
517
+
518
+ def crossref_record(item: dict[str, Any]) -> tuple[str, list[str], str | None]:
519
+ title = (item.get("title") or [""])[0]
520
+ authors = [" ".join(filter(None, [a.get("given"), a.get("family")]))
521
+ for a in item.get("author", [])]
522
+ parts = (item.get("issued") or {}).get("date-parts") or [[None]]
523
+ year = str(parts[0][0]) if parts and parts[0] and parts[0][0] else None
524
+ return title, authors, year
525
+
526
+
527
+ def verify_doi(ref: Reference, f: Fetcher) -> bool:
528
+ data = f.json(f"https://api.crossref.org/works/{urllib.parse.quote(ref.doi)}")
529
+ if not data or "message" not in data:
530
+ ref.status = "NOT_FOUND"
531
+ ref.notes.append(f"Crossref has no record for DOI {ref.doi}")
532
+ return True
533
+ title, authors, year = crossref_record(data["message"])
534
+ check_metadata(ref, title, authors, year, f"Crossref {ref.doi}")
535
+ return True
536
+
537
+
538
+ def verify_by_title(ref: Reference, f: Fetcher) -> bool:
539
+ """No identifier: search Crossref then OpenAlex by bibliographic title."""
540
+ if not ref.title:
541
+ return False
542
+ q = urllib.parse.quote(ref.title[:250])
543
+ best: tuple[float, str, list[str], str | None, str] = (0.0, "", [], None, "")
544
+
545
+ data = f.json(f"https://api.crossref.org/works?query.bibliographic={q}&rows=5"
546
+ + (f"&mailto={urllib.parse.quote(f.mailto)}" if f.mailto else ""))
547
+ for item in ((data or {}).get("message", {}) or {}).get("items", []):
548
+ title, authors, year = crossref_record(item)
549
+ s = title_score(ref.title, title)
550
+ if s > best[0]:
551
+ best = (s, title, authors, year, f"Crossref ({item.get('DOI', 'no DOI')})")
552
+
553
+ if best[0] < 0.85:
554
+ data = f.json(f"https://api.openalex.org/works?per-page=5&filter=title.search:{q}"
555
+ + (f"&mailto={urllib.parse.quote(f.mailto)}" if f.mailto else ""))
556
+ for item in (data or {}).get("results", []):
557
+ title = item.get("display_name") or ""
558
+ authors = [a.get("author", {}).get("display_name", "")
559
+ for a in item.get("authorships", [])]
560
+ year = str(item.get("publication_year")) if item.get("publication_year") else None
561
+ s = title_score(ref.title, title)
562
+ if s > best[0]:
563
+ best = (s, title, authors, year, "OpenAlex")
564
+
565
+ if best[0] < 0.55:
566
+ ref.status = "NOT_FOUND"
567
+ ref.notes.append(
568
+ "no close title match in Crossref or OpenAlex"
569
+ + (f" (best {best[0]:.2f}: \u201c{best[1][:90]}\u201d)" if best[1] else ""))
570
+ return True
571
+ check_metadata(ref, best[1], best[2], best[3], best[4])
572
+ return True
573
+
574
+
575
+ def verify_rfc(ref: Reference, f: Fetcher) -> bool:
576
+ data = f.json(f"https://datatracker.ietf.org/api/v1/doc/document/rfc{ref.rfc}/?format=json")
577
+ if data and data.get("title"):
578
+ check_metadata(ref, data["title"], [], None, f"IETF datatracker RFC {ref.rfc}")
579
+ if data.get("std_level"):
580
+ ref.evidence.append(f"status: {data['std_level']}")
581
+ return True
582
+ rec = f.get(f"https://www.rfc-editor.org/rfc/rfc{ref.rfc}.txt", accept="text/plain")
583
+ if not rec["ok"]:
584
+ ref.status = "NOT_FOUND"
585
+ ref.notes.append(f"RFC {ref.rfc} not retrievable from datatracker or rfc-editor")
586
+ return True
587
+ head = rec["body"][:4000]
588
+ if (ref.title and title_score(ref.title, head) < 0.10
589
+ and normalise(ref.title)[:40] not in normalise(head)):
590
+ ref.status = "PARTIAL"
591
+ ref.notes.append(f"RFC {ref.rfc} exists but its text does not obviously "
592
+ "contain the cited title")
593
+ else:
594
+ ref.status = "VERIFIED"
595
+ ref.evidence.append(f"rfc-editor: rfc{ref.rfc}.txt retrieved")
596
+ return True
597
+
598
+
599
+ def verify_draft(ref: Reference, f: Fetcher) -> bool:
600
+ data = f.json(
601
+ f"https://datatracker.ietf.org/api/v1/doc/document/{ref.draft}/?format=json")
602
+ if not data or not data.get("title"):
603
+ ref.status = "NOT_FOUND"
604
+ ref.notes.append(f"IETF datatracker has no draft named {ref.draft}")
605
+ return True
606
+ check_metadata(ref, data["title"], [], None, f"IETF datatracker {ref.draft}")
607
+ current = str(data.get("rev") or "")
608
+ if current:
609
+ ref.evidence.append(f"current revision: -{current}")
610
+ if ref.draft_rev and ref.draft_rev != current:
611
+ ref.status = "STALE"
612
+ ref.notes.append(
613
+ f"cited as -{ref.draft_rev} but the current revision is -{current}; "
614
+ "an Internet-Draft is a moving target, so confirm the cited text survived")
615
+ if str(data.get("state") or "").lower() in {"expired", "dead", "replaced"}:
616
+ ref.notes.append(f"datatracker state: {data['state']}")
617
+ return True
618
+
619
+
620
+ TITLE_TAG = re.compile(r"<title[^>]*>(.*?)</title>", re.I | re.S)
621
+ OG_TITLE = re.compile(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\'](.*?)["\']', re.I)
622
+ SOFT_404 = re.compile(r"\b(404|not found|page (?:not|no longer) (?:found|available)|"
623
+ r"deleted|does not exist)\b", re.I)
624
+
625
+
626
+ def verify_web(ref: Reference, f: Fetcher) -> bool:
627
+ if not ref.url:
628
+ return False
629
+ rec = f.get(ref.url)
630
+ if not rec["ok"]:
631
+ # Our own offline switch is not evidence about the link.
632
+ ref.status = "UNVERIFIABLE" if rec["error"] == "offline" else "LINK_DEAD"
633
+ ref.notes.append(f"{ref.url} -> {rec['error']}")
634
+ return True
635
+
636
+ page_title = ""
637
+ if m := OG_TITLE.search(rec["body"]):
638
+ page_title = m.group(1)
639
+ elif m := TITLE_TAG.search(rec["body"]):
640
+ page_title = re.sub(r"<[^>]+>", " ", m.group(1))
641
+ page_title = clean(page_title)[:200]
642
+ ref.evidence.append(f"page title: \u201c{page_title}\u201d" if page_title
643
+ else f"HTTP {rec['status']}, no <title>")
644
+
645
+ final = rec["final_url"]
646
+ if final.rstrip("/") != ref.url.rstrip("/"):
647
+ ref.notes.append(f"redirects to {final}")
648
+
649
+ if page_title and SOFT_404.search(page_title):
650
+ ref.status = "LINK_DEAD"
651
+ ref.notes.append(f"page resolves but looks like an error page: \u201c{page_title}\u201d")
652
+ return True
653
+
654
+ if not page_title:
655
+ ref.status = "PARTIAL"
656
+ ref.notes.append("URL resolves but no title could be read (PDF or JS-rendered page); "
657
+ "confirm by eye")
658
+ return True
659
+
660
+ score = max(title_score(ref.title, page_title),
661
+ title_score(ref.title, page_title + " " + ref.container))
662
+ if score >= 0.55:
663
+ ref.status = "VERIFIED"
664
+ elif normalise(ref.name) and normalise(ref.name).split(" ")[0] in normalise(page_title):
665
+ ref.status = "PARTIAL"
666
+ ref.notes.append(f"page title \u201c{page_title}\u201d does not match the cited title, "
667
+ "though the publisher matches")
668
+ else:
669
+ ref.status = "MISMATCH"
670
+ ref.notes.append(f"cited title vs page title mismatch (similarity {score:.2f}): "
671
+ f"\u201c{page_title}\u201d")
672
+ if final.rstrip("/") != ref.url.rstrip("/") and ref.status == "VERIFIED":
673
+ ref.status = "LINK_MOVED"
674
+ return True
675
+
676
+
677
+ def verify(ref: Reference, f: Fetcher) -> None:
678
+ if f.offline:
679
+ # Absence of a lookup is not a finding about the citation.
680
+ ref.status = "UNVERIFIABLE"
681
+ ref.notes.append("offline mode: no source was contacted")
682
+ return
683
+ done = False
684
+ if ref.arxiv:
685
+ done = verify_arxiv(ref, f)
686
+ if not done and ref.doi:
687
+ done = verify_doi(ref, f)
688
+ if not done and ref.rfc:
689
+ done = verify_rfc(ref, f)
690
+ if not done and ref.draft:
691
+ done = verify_draft(ref, f)
692
+ if not done and ref.kind == "paper":
693
+ done = verify_by_title(ref, f)
694
+ if not done and ref.url:
695
+ done = verify_web(ref, f)
696
+ elif done and ref.url and ref.status in {"VERIFIED", "PARTIAL"} and ref.kind != "web":
697
+ # Identifier checked out; still make sure the link the reader follows works.
698
+ probe = f.get(ref.url)
699
+ if not probe["ok"]:
700
+ ref.notes.append(f"companion link {ref.url} -> {probe['error']}")
701
+ ref.status = "LINK_DEAD" if ref.status == "VERIFIED" else ref.status
702
+ if not done and not ref.url:
703
+ ref.status = "UNVERIFIABLE"
704
+ ref.notes.append("no DOI, arXiv id, RFC number or URL to check against")
705
+
706
+
707
+ # --------------------------------------------------------------------------
708
+ # reporting
709
+ # --------------------------------------------------------------------------
710
+
711
+ def build_report(refs: list[Reference], cites: list[Citation], doc: str,
712
+ offline: bool) -> str:
713
+ counts: dict[str, int] = {}
714
+ for r in refs:
715
+ counts[r.status] = counts.get(r.status, 0) + 1
716
+
717
+ uncited = [r for r in refs if not r.cited_by]
718
+ unresolved = [c for c in cites if not c.key]
719
+ ambiguous = ambiguous_suffixes(refs, cites)
720
+
721
+ L = [f"# Citation check \u2014 {os.path.basename(doc)}", "",
722
+ f"Run {time.strftime('%Y-%m-%d %H:%M')}"
723
+ + (" \u2014 **offline mode, nothing was verified against a live source**"
724
+ if offline else ""),
725
+ "", f"{len(refs)} reference entries, {len(cites)} in-text citation instances.", "",
726
+ "## Summary", "", "| Status | Count | Meaning |", "|---|---:|---|"]
727
+ meaning = {
728
+ "VERIFIED": "matched an authoritative record",
729
+ "PARTIAL": "found, but the match is loose \u2014 eyeball it",
730
+ "MISMATCH": "**found something that disagrees with what you wrote**",
731
+ "NOT_FOUND": "**searched and could not find it at all**",
732
+ "LINK_DEAD": "**URL does not resolve**",
733
+ "LINK_MOVED": "URL redirects elsewhere",
734
+ "STALE": "**cited revision superseded**",
735
+ "UNVERIFIABLE": "nothing checkable in the entry",
736
+ }
737
+ for status in sorted(counts, key=lambda s: SEVERITY[s]):
738
+ L.append(f"| {status} | {counts[status]} | {meaning[status]} |")
739
+
740
+ problems = [r for r in refs if r.status in PROBLEM_STATUSES]
741
+ L += ["", f"**{len(problems)} entries need attention.**" if problems
742
+ else "**No entry failed verification.**", ""]
743
+
744
+ L += ["## Entries needing attention", ""]
745
+ if not problems:
746
+ L.append("_None._")
747
+ for r in sorted(problems, key=lambda r: SEVERITY[r.status]):
748
+ L += [f"### {r.status} \u2014 {r.name} ({r.year}{r.suffix})", "",
749
+ f"> {r.raw}", ""]
750
+ for n in r.notes:
751
+ L.append(f"- {n}")
752
+ for e in r.evidence:
753
+ L.append(f"- _{e}_")
754
+ L.append("")
755
+
756
+ soft = [r for r in refs if r.status in {"PARTIAL", "LINK_MOVED", "UNVERIFIABLE"}]
757
+ L += ["## Worth a glance", ""]
758
+ if not soft:
759
+ L.append("_None._")
760
+ for r in sorted(soft, key=lambda r: SEVERITY[r.status]):
761
+ note = r.notes[0] if r.notes else (r.evidence[0] if r.evidence else "")
762
+ L.append(f"- **{r.status}** \u2014 {r.name} ({r.year}{r.suffix}): {note}")
763
+ L.append("")
764
+
765
+ L += ["## Verified", ""]
766
+ ok = [r for r in refs if r.status == "VERIFIED"]
767
+ for r in ok:
768
+ L.append(f"- {r.name} ({r.year}{r.suffix}) \u2014 {r.title[:80]}"
769
+ + (f" \u2014 _{r.evidence[0][:100]}_" if r.evidence else ""))
770
+ if not ok:
771
+ L.append("_None._")
772
+
773
+ L += ["", "## Cross-reference consistency", "",
774
+ "### Reference-list entries never cited in the text", ""]
775
+ L += [f"- {r.name} ({r.year}{r.suffix}) \u2014 {r.title[:90]}" for r in uncited] or ["_None._"]
776
+
777
+ L += ["", "### In-text citations with no matching reference entry", "",
778
+ "_Expect false positives here: parenthetical years such as `(ICLR 2023)` "
779
+ "look like citations to a regex._", ""]
780
+ seen = set()
781
+ rows = []
782
+ for c in unresolved:
783
+ k = (c.name, c.year, c.suffix)
784
+ if k in seen:
785
+ continue
786
+ seen.add(k)
787
+ rows.append(f"- `{c.name} ({c.year}{c.suffix})` \u2014 \u2026{c.context[:150]}\u2026")
788
+ L += rows or ["_None._"]
789
+
790
+ L += ["", "### Ambiguous year suffixes", ""]
791
+ L += ambiguous or ["_None._"]
792
+
793
+ L += ["", "## What this run did not check", "",
794
+ "- Whether each source **supports the claim it is attached to**. That needs "
795
+ "reading, not fetching \u2014 run `touchneedle.py claims` and work the list.",
796
+ "- Page numbers, edition, and publisher details.",
797
+ "- Anything behind a paywall or a JS-rendered page, which comes back PARTIAL.", ""]
798
+ return "\n".join(L)
799
+
800
+
801
+ def ambiguous_suffixes(refs: list[Reference], cites: list[Citation]) -> list[str]:
802
+ """Same author+year split across a/b in the list must be cited with a suffix."""
803
+ groups: dict[str, list[Reference]] = {}
804
+ for r in refs:
805
+ groups.setdefault(f"{normalise(r.name)}|{r.year}", []).append(r)
806
+ out = []
807
+ for stem, group in groups.items():
808
+ if len(group) < 2:
809
+ continue
810
+ name, year = group[0].name, group[0].year
811
+ bare = [c for c in cites
812
+ if citation_key(c.name, c.year, "") == stem and not c.suffix]
813
+ if bare:
814
+ out.append(f"- `{name} ({year})` is cited without a suffix "
815
+ f"{len(bare)}\u00d7, but the list has "
816
+ f"{', '.join(year + r.suffix for r in group)}")
817
+ if any(not r.suffix for r in group):
818
+ out.append(f"- reference list has {len(group)} entries for {name} ({year}) "
819
+ "but not all carry an a/b suffix")
820
+ return out
821
+
822
+
823
+ def build_claims(refs: list[Reference], cites: list[Citation], doc: str) -> str:
824
+ by_key = {r.key: r for r in refs}
825
+ L = [f"# Claim-support worklist \u2014 {os.path.basename(doc)}", "",
826
+ "One row per in-text citation. For each, read the source and decide whether it "
827
+ "supports the sentence: **SUPPORTED / PARTIAL / UNSUPPORTED / INACCESSIBLE**. "
828
+ "Do not guess \u2014 if the source cannot be read, say INACCESSIBLE.", ""]
829
+ ordered = sorted(cites, key=lambda c: (c.key or "zzz", c.year))
830
+ for n, c in enumerate(ordered, 1):
831
+ r = by_key.get(c.key)
832
+ L += [f"## {n}. {c.name} ({c.year}{c.suffix}) \u2014 {c.form}", ""]
833
+ if r:
834
+ src = r.url or (f"arXiv:{r.arxiv}" if r.arxiv else "") or (
835
+ f"doi:{r.doi}" if r.doi else "") or (f"RFC {r.rfc}" if r.rfc else "")
836
+ L += [f"- **Source**: {r.title or r.raw[:100]}",
837
+ f"- **Locate at**: {src or '_no locator in the reference entry_'}"]
838
+ else:
839
+ L.append("- **Source**: _no matching reference-list entry_")
840
+ L += ["- **Claim in the text**:", f" > \u2026{c.context}\u2026", "",
841
+ "- **Verdict**: ", ""]
842
+ return "\n".join(L)
843
+
844
+
845
+ # --------------------------------------------------------------------------
846
+ # entry point
847
+ # --------------------------------------------------------------------------
848
+
849
+ def collect(doc: str) -> tuple[list[Reference], list[Citation]]:
850
+ text = load_text(doc)
851
+ body, block = split_document(text)
852
+ refs = [parse_entry(e) for e in split_entries(block)]
853
+ if not refs:
854
+ sys.exit("error: found a References heading but could not parse any entries under it")
855
+ cites = match_citations(refs, find_citations(body))
856
+ return refs, cites
857
+
858
+
859
+ def main() -> int:
860
+ ap = argparse.ArgumentParser(description=__doc__,
861
+ formatter_class=argparse.RawDescriptionHelpFormatter)
862
+ sub = ap.add_subparsers(dest="cmd", required=True)
863
+
864
+ for name in ("check", "claims"):
865
+ p = sub.add_parser(name)
866
+ p.add_argument("doc", help="Markdown or .docx document")
867
+ p.add_argument("--out", help="write the report here instead of stdout")
868
+ chk = sub.choices["check"]
869
+ chk.add_argument("--json", dest="json_out", help="also write machine-readable results")
870
+ chk.add_argument("--offline", action="store_true",
871
+ help="parse and cross-check only; make no network calls")
872
+ chk.add_argument("--mailto", default=os.environ.get("CITATION_CHECK_MAILTO"),
873
+ help="contact address sent to Crossref/OpenAlex for their polite "
874
+ "rate-limit pool (optional; also read from CITATION_CHECK_MAILTO)")
875
+ chk.add_argument("--cache", default=".touchneedle-cache", help="HTTP cache directory")
876
+ chk.add_argument("--timeout", type=int, default=25)
877
+
878
+ args = ap.parse_args()
879
+ refs, cites = collect(args.doc)
880
+
881
+ if args.cmd == "claims":
882
+ out = build_claims(refs, cites, args.doc)
883
+ else:
884
+ f = Fetcher(args.cache, timeout=args.timeout, offline=args.offline,
885
+ mailto=args.mailto)
886
+ for i, r in enumerate(refs, 1):
887
+ print(f"[{i}/{len(refs)}] {r.name} ({r.year}{r.suffix}) \u2026",
888
+ file=sys.stderr, flush=True)
889
+ verify(r, f)
890
+ print(f" {r.status}", file=sys.stderr, flush=True)
891
+ out = build_report(refs, cites, args.doc, args.offline)
892
+ if args.json_out:
893
+ with open(args.json_out, "w", encoding="utf-8") as fh:
894
+ json.dump({"references": [dataclasses.asdict(r) for r in refs],
895
+ "citations": [dataclasses.asdict(c) for c in cites]},
896
+ fh, indent=2, ensure_ascii=False)
897
+
898
+ if args.out:
899
+ with open(args.out, "w", encoding="utf-8") as fh:
900
+ fh.write(out + "\n")
901
+ print(f"wrote {args.out}", file=sys.stderr)
902
+ else:
903
+ print(out)
904
+
905
+ if args.cmd == "check":
906
+ return 2 if any(r.status in PROBLEM_STATUSES for r in refs) else 0
907
+ return 0
908
+
909
+
910
+ if __name__ == "__main__":
911
+ sys.exit(main())