sciwrite-lint 0.2.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 (76) hide show
  1. sciwrite_lint/__init__.py +3 -0
  2. sciwrite_lint/__main__.py +527 -0
  3. sciwrite_lint/_network.py +195 -0
  4. sciwrite_lint/api.py +1484 -0
  5. sciwrite_lint/checks/__init__.py +1 -0
  6. sciwrite_lint/checks/_section_utils.py +111 -0
  7. sciwrite_lint/checks/cite_purpose.py +122 -0
  8. sciwrite_lint/checks/claim_support.py +96 -0
  9. sciwrite_lint/checks/cross_section_consistency.py +185 -0
  10. sciwrite_lint/checks/dangling_cite.py +93 -0
  11. sciwrite_lint/checks/dangling_ref.py +116 -0
  12. sciwrite_lint/checks/full_paper_consistency.py +604 -0
  13. sciwrite_lint/checks/ref_internal_checks.py +919 -0
  14. sciwrite_lint/checks/reference_accuracy.py +277 -0
  15. sciwrite_lint/checks/reference_exists.py +119 -0
  16. sciwrite_lint/checks/reference_unreliable.py +244 -0
  17. sciwrite_lint/checks/registry.py +136 -0
  18. sciwrite_lint/checks/retracted_cite.py +96 -0
  19. sciwrite_lint/checks/structure_promises.py +115 -0
  20. sciwrite_lint/checks/unreferenced_figure.py +70 -0
  21. sciwrite_lint/claims.py +330 -0
  22. sciwrite_lint/claude_backend.py +94 -0
  23. sciwrite_lint/claude_cli.py +405 -0
  24. sciwrite_lint/cli/__init__.py +1 -0
  25. sciwrite_lint/cli/check.py +480 -0
  26. sciwrite_lint/cli/config.py +229 -0
  27. sciwrite_lint/cli/fetch.py +250 -0
  28. sciwrite_lint/cli/misc.py +1202 -0
  29. sciwrite_lint/cli/rank.py +470 -0
  30. sciwrite_lint/cli/verify.py +437 -0
  31. sciwrite_lint/config.py +646 -0
  32. sciwrite_lint/cross_paper.py +174 -0
  33. sciwrite_lint/eval_claims.py +1196 -0
  34. sciwrite_lint/fulltext.py +851 -0
  35. sciwrite_lint/llm_utils.py +386 -0
  36. sciwrite_lint/local_pdfs.py +122 -0
  37. sciwrite_lint/manuscript_store.py +674 -0
  38. sciwrite_lint/models.py +139 -0
  39. sciwrite_lint/pdf/__init__.py +1 -0
  40. sciwrite_lint/pdf/grobid.py +785 -0
  41. sciwrite_lint/pdf/pdf_download.py +258 -0
  42. sciwrite_lint/pipeline.py +2694 -0
  43. sciwrite_lint/prompt_safety.py +30 -0
  44. sciwrite_lint/rate_limiter.py +227 -0
  45. sciwrite_lint/references/__init__.py +1 -0
  46. sciwrite_lint/references/citations.py +715 -0
  47. sciwrite_lint/references/crossref.py +282 -0
  48. sciwrite_lint/references/embedding_store.py +380 -0
  49. sciwrite_lint/references/matching.py +273 -0
  50. sciwrite_lint/references/metadata.py +273 -0
  51. sciwrite_lint/references/reference_store.py +823 -0
  52. sciwrite_lint/references/retraction_watch.py +178 -0
  53. sciwrite_lint/references/workspace_db.py +1390 -0
  54. sciwrite_lint/report.py +163 -0
  55. sciwrite_lint/schemas.py +260 -0
  56. sciwrite_lint/scoring/__init__.py +1 -0
  57. sciwrite_lint/scoring/chain.py +716 -0
  58. sciwrite_lint/scoring/contribution.py +322 -0
  59. sciwrite_lint/scoring/scilint_score.py +611 -0
  60. sciwrite_lint/tex_parser.py +248 -0
  61. sciwrite_lint/usage.py +594 -0
  62. sciwrite_lint/vision/__init__.py +1 -0
  63. sciwrite_lint/vision/cache.py +140 -0
  64. sciwrite_lint/vision/describe.py +311 -0
  65. sciwrite_lint/vision/image_extraction.py +491 -0
  66. sciwrite_lint/vision/pipeline.py +207 -0
  67. sciwrite_lint/vllm/__init__.py +1 -0
  68. sciwrite_lint/vllm/metrics.py +157 -0
  69. sciwrite_lint/vllm/vllm_server.py +445 -0
  70. sciwrite_lint/web.py +369 -0
  71. sciwrite_lint-0.2.0.dist-info/METADATA +268 -0
  72. sciwrite_lint-0.2.0.dist-info/RECORD +76 -0
  73. sciwrite_lint-0.2.0.dist-info/WHEEL +5 -0
  74. sciwrite_lint-0.2.0.dist-info/entry_points.txt +2 -0
  75. sciwrite_lint-0.2.0.dist-info/licenses/LICENSE +21 -0
  76. sciwrite_lint-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,715 @@
1
+ """Citation parsing, orphan detection, and local source checking."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from loguru import logger
9
+
10
+ from sciwrite_lint.models import Citation, Finding
11
+ from sciwrite_lint.tex_parser import (
12
+ extract_bibliography,
13
+ find_all_cite_keys,
14
+ find_bare_cite_keys,
15
+ find_unverified_cite_keys,
16
+ )
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Citation extraction (bibitem or .bib)
21
+ # ---------------------------------------------------------------------------
22
+
23
+
24
+ class CitationSource:
25
+ """Describes how citations were extracted (for reporting)."""
26
+
27
+ def __init__(
28
+ self, method: str, path: str, count: int, warnings: list[str] | None = None
29
+ ):
30
+ self.method = method # "bibtex", "bibitem_simple", "bibitem_natbib"
31
+ self.path = path # file that was parsed
32
+ self.count = count # number of citations found
33
+ self.warnings = warnings or []
34
+
35
+ def __str__(self) -> str:
36
+ return f"{self.method} ({self.count} citations from {self.path})"
37
+
38
+
39
+ # Module-level last extraction info (set by extract_bibitems)
40
+ _last_source: CitationSource | None = None
41
+
42
+
43
+ def get_last_citation_source() -> CitationSource | None:
44
+ """Return info about the last extraction (for UI/CLI reporting)."""
45
+ return _last_source
46
+
47
+
48
+ def extract_bibitems(
49
+ tex_path: Path,
50
+ bib_format: str = "auto",
51
+ bib_path: Path | None = None,
52
+ ) -> list[Citation]:
53
+ r"""Extract citations from a paper.
54
+
55
+ Supports two modes:
56
+ - \bibitem inline in .tex (simple or natbib)
57
+ - .bib file referenced via \bibliography{}
58
+
59
+ Args:
60
+ tex_path: Path to .tex file.
61
+ bib_format: "simple", "natbib", or "auto" (detect).
62
+ bib_path: Explicit path to .bib file. If None, auto-detected from
63
+ \bibliography{} command in .tex.
64
+
65
+ Raises:
66
+ ValueError: If both .bib file and \bibitem entries are found.
67
+ """
68
+ global _last_source
69
+ text = tex_path.read_text(encoding="utf-8")
70
+
71
+ # Detect both sources
72
+ resolved_bib = bib_path or _find_bib_file(tex_path, text)
73
+ has_bib_file = resolved_bib is not None and resolved_bib.exists()
74
+ bib_text = extract_bibliography(text)
75
+ has_bibitems = bool(bib_text and "\\bibitem" in bib_text)
76
+
77
+ # Conflict: both .bib and \bibitem present
78
+ if has_bib_file and has_bibitems:
79
+ assert resolved_bib is not None # narrowing for mypy
80
+ raise ValueError(
81
+ f"Ambiguous bibliography in {tex_path.name}: "
82
+ f"found both .bib file ({resolved_bib.name}) and "
83
+ f"\\bibitem entries. Use one or the other."
84
+ )
85
+
86
+ # No citations found at all
87
+ if not has_bib_file and not has_bibitems:
88
+ _last_source = CitationSource(
89
+ "none", str(tex_path.name), 0, ["No bibliography found"]
90
+ )
91
+ return []
92
+
93
+ # .bib file mode
94
+ if has_bib_file:
95
+ assert resolved_bib is not None # narrowing for mypy
96
+ citations = _extract_from_bib(resolved_bib, tex_path.stem)
97
+ _last_source = CitationSource(
98
+ "bibtex",
99
+ str(resolved_bib.name),
100
+ len(citations),
101
+ )
102
+ return citations
103
+
104
+ # \bibitem mode
105
+ if bib_format == "auto":
106
+ bib_format = "natbib" if "\\newblock" in bib_text else "simple"
107
+
108
+ pattern = re.compile(
109
+ r"\\bibitem(?:\[([^\]]*)\])?\{([^}]+)\}\s*(.*?)(?=\\bibitem|$)",
110
+ re.DOTALL,
111
+ )
112
+ citations = []
113
+ for match in pattern.finditer(bib_text):
114
+ label, key, body = match.group(1), match.group(2), match.group(3).strip()
115
+ if bib_format == "natbib":
116
+ c = _parse_natbib(key, body, label or "")
117
+ else:
118
+ c = _parse_simple(key, body)
119
+ c.source_paper = tex_path.stem
120
+ c.bib_format = bib_format
121
+ citations.append(c)
122
+
123
+ _last_source = CitationSource(
124
+ f"bibitem_{bib_format}",
125
+ str(tex_path.name),
126
+ len(citations),
127
+ )
128
+ return citations
129
+
130
+
131
+ def _find_bib_file(tex_path: Path, text: str) -> Path | None:
132
+ r"""Find .bib file from \bibliography{name} command in .tex."""
133
+ match = re.search(r"\\bibliography\{([^}]+)\}", text)
134
+ if not match:
135
+ return None
136
+ bib_name = match.group(1).strip()
137
+ if not bib_name.endswith(".bib"):
138
+ bib_name += ".bib"
139
+ bib_path = tex_path.parent / bib_name
140
+ return bib_path if bib_path.exists() else None
141
+
142
+
143
+ def _extract_from_bib(bib_path: Path, source_paper: str) -> list[Citation]:
144
+ """Parse a .bib file into Citation objects using bibtexparser."""
145
+ try:
146
+ import bibtexparser
147
+ except ImportError:
148
+ return []
149
+
150
+ text = bib_path.read_text(encoding="utf-8")
151
+ db = bibtexparser.loads(text)
152
+
153
+ citations = []
154
+ for entry in db.entries:
155
+ key = entry.get("ID", "")
156
+ if not key:
157
+ continue
158
+
159
+ # Authors: bibtexparser gives "First Last and First Last"
160
+ author_str = entry.get("author", "")
161
+ authors = _parse_author_string(author_str) if author_str else []
162
+
163
+ # Title: strip braces used for capitalization preservation
164
+ title = entry.get("title", "")
165
+ title = re.sub(r"[{}]", "", title).strip()
166
+
167
+ # Venue: journal, booktitle, or howpublished
168
+ venue = (
169
+ entry.get("journal", "")
170
+ or entry.get("booktitle", "")
171
+ or entry.get("howpublished", "")
172
+ )
173
+
174
+ # DOI and URL
175
+ doi = entry.get("doi", "")
176
+ url = entry.get("url", "")
177
+ # Extract URL from howpublished if url field is empty
178
+ if not url:
179
+ hp = entry.get("howpublished", "")
180
+ url_match = re.search(r"\\url\{([^}]+)\}", hp)
181
+ if url_match:
182
+ url = url_match.group(1)
183
+ # Extract DOI from howpublished URL if present
184
+ if not doi and url:
185
+ doi_match = re.search(r"(10\.\d{4,9}/[^\s,}]+)", url)
186
+ if doi_match:
187
+ doi = doi_match.group(1).rstrip(".")
188
+ if not doi:
189
+ hp = entry.get("howpublished", "")
190
+ doi_match = re.search(r"(10\.\d{4,9}/[^\s,}]+)", hp)
191
+ if doi_match:
192
+ doi = doi_match.group(1).rstrip(".")
193
+
194
+ # Build raw text for display
195
+ raw_parts = []
196
+ if author_str:
197
+ raw_parts.append(author_str)
198
+ if title:
199
+ raw_parts.append(title)
200
+ if venue:
201
+ raw_parts.append(venue)
202
+ raw_text = ". ".join(raw_parts)
203
+
204
+ # arXiv ID from eprint field — the NNNN.NNNNN format is
205
+ # arXiv-specific, no other preprint server uses it.
206
+ arxiv_id = ""
207
+ eprint = entry.get("eprint", "")
208
+ if eprint:
209
+ arxiv_match = re.match(r"(\d{4}\.\d{4,5})", eprint)
210
+ if arxiv_match:
211
+ arxiv_id = arxiv_match.group(1)
212
+
213
+ # ISBN — standard BibTeX field for books/chapters
214
+ isbn = entry.get("isbn", "")
215
+
216
+ # LCCN — Library of Congress Control Number
217
+ lccn = entry.get("lccn", "")
218
+
219
+ c = Citation(
220
+ key=key,
221
+ raw_text=raw_text,
222
+ authors=authors,
223
+ title=title,
224
+ year=entry.get("year", ""),
225
+ venue=venue,
226
+ doi=doi,
227
+ url=url,
228
+ arxiv_id=arxiv_id,
229
+ isbn=isbn,
230
+ lccn=lccn,
231
+ source_paper=source_paper,
232
+ bib_format="bibtex",
233
+ entry_type=entry.get("ENTRYTYPE", ""),
234
+ )
235
+
236
+ # Sweep non-standard fields for identifiers not yet found
237
+ _sweep_identifiers(c, entry)
238
+
239
+ citations.append(c)
240
+
241
+ return citations
242
+
243
+
244
+ # Patterns for identifier extraction from non-standard fields.
245
+ # Strict length constraints to minimize false positives.
246
+ _ARXIV_RE = re.compile(r"arXiv[:\s]+(\d{4}\.\d{4,5})", re.IGNORECASE)
247
+ _DOI_RE = re.compile(r"(10\.\d{4,9}/[^\s,}]+)")
248
+ _PMID_RE = re.compile(r"PMID[:\s]+(\d{6,9})")
249
+ _PMC_RE = re.compile(r"(PMC\d{6,8})\b")
250
+ _ISBN_RE = re.compile(r"(?:ISBN[:\s-]*)?(97[89]\d{10}|\d{9}[\dXx])", re.IGNORECASE)
251
+ _LCCN_RE = re.compile(r"LCCN[:\s]+(\d{8,12})", re.IGNORECASE)
252
+
253
+ # Standard BibTeX fields — skip these in the sweep (already parsed above)
254
+ _STANDARD_ID_FIELDS = {
255
+ "doi",
256
+ "eprint",
257
+ "url",
258
+ "eprinttype",
259
+ "archiveprefix",
260
+ "isbn",
261
+ "lccn",
262
+ }
263
+
264
+
265
+ def _sweep_identifiers(citation: Citation, entry: dict[str, str]) -> None:
266
+ """Scan non-standard bib fields for structured identifiers.
267
+
268
+ Only fills in identifiers that are still missing after standard-field
269
+ parsing. Logs every regex-extracted identifier so we can trace the source.
270
+ """
271
+ # Collect text from non-standard fields only
272
+ sweep_parts: list[tuple[str, str]] = []
273
+ for field_name, value in entry.items():
274
+ if field_name.lower() in _STANDARD_ID_FIELDS:
275
+ continue
276
+ if field_name in ("ID", "ENTRYTYPE"):
277
+ continue
278
+ if value and isinstance(value, str):
279
+ sweep_parts.append((field_name, value))
280
+
281
+ if not sweep_parts:
282
+ return
283
+
284
+ for field_name, value in sweep_parts:
285
+ # DOI
286
+ if not citation.doi:
287
+ doi_match = _DOI_RE.search(value)
288
+ if doi_match:
289
+ citation.doi = doi_match.group(1).rstrip(".")
290
+ logger.debug(
291
+ "Extracted DOI from '{}' field of '{}': {}",
292
+ field_name,
293
+ citation.key,
294
+ citation.doi,
295
+ )
296
+
297
+ # arXiv
298
+ if not citation.arxiv_id:
299
+ arxiv_match = _ARXIV_RE.search(value)
300
+ if arxiv_match:
301
+ citation.arxiv_id = arxiv_match.group(1)
302
+ logger.debug(
303
+ "Extracted arXiv ID from '{}' field of '{}': {}",
304
+ field_name,
305
+ citation.key,
306
+ citation.arxiv_id,
307
+ )
308
+
309
+ # PMID
310
+ if not citation.pmid:
311
+ pmid_match = _PMID_RE.search(value)
312
+ if pmid_match:
313
+ citation.pmid = pmid_match.group(1)
314
+ logger.debug(
315
+ "Extracted PMID from '{}' field of '{}': {}",
316
+ field_name,
317
+ citation.key,
318
+ citation.pmid,
319
+ )
320
+
321
+ # PMC
322
+ if not citation.pmc_id:
323
+ pmc_match = _PMC_RE.search(value)
324
+ if pmc_match:
325
+ citation.pmc_id = pmc_match.group(1)
326
+ logger.debug(
327
+ "Extracted PMC ID from '{}' field of '{}': {}",
328
+ field_name,
329
+ citation.key,
330
+ citation.pmc_id,
331
+ )
332
+
333
+ # ISBN
334
+ if not citation.isbn:
335
+ isbn_match = _ISBN_RE.search(value)
336
+ if isbn_match:
337
+ citation.isbn = isbn_match.group(1)
338
+ logger.debug(
339
+ "Extracted ISBN from '{}' field of '{}': {}",
340
+ field_name,
341
+ citation.key,
342
+ citation.isbn,
343
+ )
344
+
345
+ # LCCN
346
+ if not citation.lccn:
347
+ lccn_match = _LCCN_RE.search(value)
348
+ if lccn_match:
349
+ citation.lccn = lccn_match.group(1)
350
+ logger.debug(
351
+ "Extracted LCCN from '{}' field of '{}': {}",
352
+ field_name,
353
+ citation.key,
354
+ citation.lccn,
355
+ )
356
+
357
+
358
+ def _parse_simple(key: str, body: str) -> Citation:
359
+ r"""Parse a simple-style bibitem (no \newblock)."""
360
+ c = Citation(key=key, raw_text=body)
361
+ _extract_doi_url(c, body)
362
+ _extract_year(c, body)
363
+
364
+ # Split by newlines BEFORE cleaning (to preserve line structure)
365
+ raw_lines = [ln.strip() for ln in body.split("\n") if ln.strip()]
366
+
367
+ if raw_lines:
368
+ c.authors = _parse_author_string(_clean_text(raw_lines[0]))
369
+
370
+ # Title: second line (between author and venue)
371
+ if len(raw_lines) > 1:
372
+ title_text = raw_lines[1]
373
+ # Remove \emph{} blocks (those are venue names)
374
+ title_text = re.sub(r"\\emph\{[^}]+\}", "", title_text)
375
+ title_text = _clean_text(title_text).rstrip(".,")
376
+ if title_text:
377
+ c.title = title_text
378
+
379
+ # Venue: first \emph{} in body
380
+ emph_matches = re.findall(r"\\emph\{([^}]+)\}", body)
381
+ if emph_matches:
382
+ c.venue = emph_matches[0]
383
+
384
+ return c
385
+
386
+
387
+ def _parse_natbib(key: str, body: str, label: str) -> Citation:
388
+ r"""Parse a natbib-style bibitem with \newblock separators."""
389
+ c = Citation(key=key, raw_text=body)
390
+ _extract_doi_url(c, body)
391
+ _extract_year(c, body)
392
+
393
+ # Extract year from natbib label if present: [Author(Year)]
394
+ label_year = re.search(r"\((\d{4})\)", label)
395
+ if label_year and not c.year:
396
+ c.year = label_year.group(1)
397
+
398
+ # Split by \newblock
399
+ blocks = re.split(r"\\newblock\s*", body)
400
+
401
+ # Block 0: author line (e.g., "Bloom, B.~S. (1984).")
402
+ if blocks:
403
+ author_block = blocks[0].strip()
404
+ # Remove year in parens
405
+ author_clean = re.sub(r"\(\d{4}\)\.", "", author_block).strip()
406
+ c.authors = _parse_author_string(author_clean)
407
+
408
+ # Block 1: typically the title
409
+ if len(blocks) > 1:
410
+ title_block = blocks[1].strip()
411
+ # If title is in \emph{}, it's a book title
412
+ emph_match = re.match(r"\\emph\{(.+?)\}", title_block, re.DOTALL)
413
+ if emph_match:
414
+ c.title = _clean_text(emph_match.group(1)).rstrip(".")
415
+ else:
416
+ c.title = _clean_text(title_block).rstrip(".")
417
+
418
+ # Block 2+: venue
419
+ if len(blocks) > 2:
420
+ venue_block = blocks[2].strip()
421
+ emph_match = re.search(r"\\emph\{([^}]+)\}", venue_block)
422
+ if emph_match:
423
+ c.venue = emph_match.group(1)
424
+
425
+ return c
426
+
427
+
428
+ def _extract_doi_url(c: Citation, body: str) -> None:
429
+ """Extract DOI and URL from bibitem body text."""
430
+ url_match = re.search(r"\\url\{([^}]+)\}", body)
431
+ if url_match:
432
+ c.url = url_match.group(1)
433
+
434
+ doi_match = re.search(r"(10\.\d{4,9}/[^\s,}]+)", body)
435
+ if doi_match:
436
+ c.doi = doi_match.group(1).rstrip(".")
437
+ elif c.url:
438
+ doi_in_url = re.search(r"(10\.\d{4,9}/[^\s,}]+)", c.url)
439
+ if doi_in_url:
440
+ c.doi = doi_in_url.group(1).rstrip(".")
441
+
442
+
443
+ def _extract_year(c: Citation, body: str) -> None:
444
+ """Extract publication year from bibitem body."""
445
+ year_match = re.search(r"\b(19\d{2}|20\d{2})\b", body)
446
+ if year_match:
447
+ c.year = year_match.group(1)
448
+
449
+
450
+ def _parse_author_string(text: str) -> list[str]:
451
+ """Parse an author string into individual author names."""
452
+ text = _clean_text(text).rstrip(".")
453
+ if not text:
454
+ return []
455
+ parts = re.split(r"\s+and\s+|\s*&\s*|\s*,\s+and\s+", text)
456
+ authors = []
457
+ for part in parts:
458
+ part = part.strip().rstrip(",")
459
+ if part and len(part) > 1:
460
+ authors.append(part)
461
+ return authors
462
+
463
+
464
+ def _clean_text(text: str) -> str:
465
+ """Remove LaTeX markup from text."""
466
+ text = re.sub(r"\\emph\{([^}]+)\}", r"\1", text)
467
+ text = re.sub(r"\\textbf\{([^}]+)\}", r"\1", text)
468
+ text = re.sub(r"\\texttt\{([^}]+)\}", r"\1", text)
469
+ text = re.sub(r"\\url\{[^}]*\}", "", text)
470
+ text = re.sub(r"\{([^}]*)\}", r"\1", text)
471
+ text = re.sub(r"~", " ", text)
472
+ text = re.sub(r"\\newblock\s*", "", text)
473
+ text = re.sub(r"\s+", " ", text)
474
+ return text.strip()
475
+
476
+
477
+ # ---------------------------------------------------------------------------
478
+ # Web resource detection
479
+ # ---------------------------------------------------------------------------
480
+
481
+
482
+ def is_web_resource(citation: Citation) -> bool:
483
+ """Detect if a citation is a web resource rather than an academic paper."""
484
+ return citation.entry_type == "misc" and bool(citation.url) and not citation.doi
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Orphan detection
489
+ # ---------------------------------------------------------------------------
490
+
491
+
492
+ def find_orphans(
493
+ citations: list[Citation],
494
+ tex_path: Path,
495
+ aux_path: Path | None = None,
496
+ ) -> tuple[set[str], set[str]]:
497
+ """Find cite keys without bibitems and bibitems never cited.
498
+
499
+ Returns (cited_but_no_bib, bib_but_not_cited).
500
+ """
501
+ text = tex_path.read_text(encoding="utf-8")
502
+ cite_keys = {k for _, k in find_all_cite_keys(text)}
503
+ bib_keys = {c.key for c in citations}
504
+
505
+ if aux_path and aux_path.exists():
506
+ aux_cite, aux_bib = parse_aux_citations(aux_path)
507
+ cite_keys |= aux_cite
508
+
509
+ return cite_keys - bib_keys, bib_keys - cite_keys
510
+
511
+
512
+ def parse_aux_citations(aux_path: Path) -> tuple[set[str], set[str]]:
513
+ """Parse .aux file for citation and bibcite keys."""
514
+ text = aux_path.read_text(encoding="utf-8")
515
+ cite_keys = set()
516
+ bib_keys = set()
517
+
518
+ for match in re.finditer(r"\\citation\{([^}]+)\}", text):
519
+ for key in match.group(1).split(","):
520
+ cite_keys.add(key.strip())
521
+
522
+ for match in re.finditer(r"\\bibcite\{([^}]+)\}", text):
523
+ bib_keys.add(match.group(1).strip())
524
+
525
+ return cite_keys, bib_keys
526
+
527
+
528
+ # ---------------------------------------------------------------------------
529
+ # Local source checking
530
+ # ---------------------------------------------------------------------------
531
+
532
+
533
+ def _is_valid_local_pdf(path: Path) -> bool:
534
+ """Check that a local file is actually a PDF (magic header + minimum size)."""
535
+ from sciwrite_lint.fulltext import _is_valid_pdf
536
+
537
+ try:
538
+ data = path.read_bytes()
539
+ except OSError:
540
+ return False
541
+ return _is_valid_pdf(data)
542
+
543
+
544
+ def check_local_sources(
545
+ citations: list[Citation],
546
+ refs_dir: Path,
547
+ ) -> None:
548
+ """Check which citations have local PDFs or markdown summaries."""
549
+ if not refs_dir.exists():
550
+ return
551
+
552
+ local_files = {f.name.lower(): f for f in refs_dir.iterdir() if f.is_file()}
553
+
554
+ for c in citations:
555
+ key_lower = c.key.lower()
556
+ found = False
557
+
558
+ for suffix in [".pdf", ".md"]:
559
+ for pattern in [
560
+ f"{key_lower}{suffix}",
561
+ f"{key_lower.replace('2', '_2', 1)}{suffix}",
562
+ ]:
563
+ if pattern in local_files:
564
+ fpath = local_files[pattern]
565
+ if suffix == ".pdf" and not _is_valid_local_pdf(fpath):
566
+ logger.warning(
567
+ "{}: local file {} is not a valid PDF, skipping",
568
+ c.key,
569
+ fpath.name,
570
+ )
571
+ continue
572
+ c.local_status = "pdf" if suffix == ".pdf" else "md"
573
+ c.local_path = str(fpath)
574
+ found = True
575
+ break
576
+ if found:
577
+ break
578
+
579
+ if not found:
580
+ author_part = re.match(r"[a-z]+", key_lower)
581
+ year_in_key = re.search(r"\d{4}", key_lower)
582
+ if author_part and year_in_key:
583
+ author = author_part.group(0)
584
+ year = year_in_key.group(0)
585
+ for fname, fpath in local_files.items():
586
+ if (
587
+ fname.startswith(author)
588
+ and year in fname
589
+ and (fname.endswith(".pdf") or fname.endswith(".md"))
590
+ ):
591
+ if fname.endswith(".pdf") and not _is_valid_local_pdf(fpath):
592
+ logger.warning(
593
+ "{}: local file {} is not a valid PDF, skipping",
594
+ c.key,
595
+ fname,
596
+ )
597
+ continue
598
+ c.local_status = "pdf" if fname.endswith(".pdf") else "md"
599
+ c.local_path = str(fpath)
600
+ found = True
601
+ break
602
+
603
+ if not found:
604
+ c.local_status = "none"
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # Shared citation consistency
609
+ # ---------------------------------------------------------------------------
610
+
611
+
612
+ def check_shared_citations(
613
+ all_citations: dict[str, list[Citation]],
614
+ ) -> list[Finding]:
615
+ """Check citations shared across papers for consistency."""
616
+ by_key: dict[str, list[Citation]] = {}
617
+ for paper_name, citations in all_citations.items():
618
+ for c in citations:
619
+ by_key.setdefault(c.key, []).append(c)
620
+
621
+ findings = []
622
+ for key, cites in by_key.items():
623
+ if len(cites) < 2:
624
+ continue
625
+ years = {c.year for c in cites if c.year}
626
+ if len(years) > 1:
627
+ findings.append(
628
+ Finding(
629
+ level="error",
630
+ rule_id="ref-006",
631
+ message=f"Citation '{key}' has different years across papers: {years}",
632
+ file="cross",
633
+ )
634
+ )
635
+ titles = {c.title for c in cites if c.title}
636
+ if len(titles) > 1:
637
+ findings.append(
638
+ Finding(
639
+ level="warning",
640
+ rule_id="ref-006",
641
+ message=f"Citation '{key}' has different titles across papers: {titles}",
642
+ file="cross",
643
+ )
644
+ )
645
+
646
+ return findings
647
+
648
+
649
+ # ---------------------------------------------------------------------------
650
+ # Tier enforcement
651
+ # ---------------------------------------------------------------------------
652
+
653
+
654
+ def check_tiers(
655
+ citations: list[Citation],
656
+ tex_path: Path,
657
+ metadata_dir: Path,
658
+ ) -> list[Finding]:
659
+ r"""Check tier enforcement: bare \cite on T2 = warning, T3 cite = error."""
660
+ from sciwrite_lint.references.metadata import load_all_metadata
661
+
662
+ findings: list[Finding] = []
663
+ all_meta = load_all_metadata(metadata_dir)
664
+ if not all_meta:
665
+ return findings
666
+
667
+ text = tex_path.read_text(encoding="utf-8")
668
+ bare_keys = find_bare_cite_keys(text)
669
+ unverified_keys = find_unverified_cite_keys(text)
670
+
671
+ for c in citations:
672
+ meta = all_meta.get(c.key)
673
+ if not meta:
674
+ continue
675
+
676
+ tier = meta.access.get("tier", "")
677
+
678
+ if tier == "T3" and c.key in (bare_keys | unverified_keys):
679
+ reason = "dead URL" if meta.api_match == "web_dead" else "not found in APIs"
680
+ findings.append(
681
+ Finding(
682
+ level="error",
683
+ rule_id="ref-008",
684
+ message=(
685
+ f"'{c.key}' is T3 ({reason}). Cannot cite — remove or replace."
686
+ ),
687
+ file=str(tex_path.name),
688
+ )
689
+ )
690
+ elif tier == "T2" and c.key in bare_keys:
691
+ findings.append(
692
+ Finding(
693
+ level="warning",
694
+ rule_id="ref-009",
695
+ message=(
696
+ f"'{c.key}' is T2 (no full text). Use \\citeunverified{{{c.key}}} "
697
+ f"or obtain full text."
698
+ ),
699
+ file=str(tex_path.name),
700
+ )
701
+ )
702
+ elif tier == "T1" and c.key in unverified_keys:
703
+ findings.append(
704
+ Finding(
705
+ level="info",
706
+ rule_id="ref-009",
707
+ message=(
708
+ f"'{c.key}' is T1 (full text available). "
709
+ f"Can upgrade \\citeunverified to \\cite."
710
+ ),
711
+ file=str(tex_path.name),
712
+ )
713
+ )
714
+
715
+ return findings