Skip to content

Commit 742ec43

Browse files
test(citations): 49 tests pin auditor regex + stopword behaviour
Both §10 auditors (tools/audit_citations.py — 790 LOC, and tools/audit_bib_duplicates.py — 159 LOC) are now the quality command for the whole bibliography, but they had zero tests. Any future tweak to the regex or stopword set could silently break citation enforcement without anyone noticing until wrong attributions leak into a release. Added two stdlib-only test suites (<1s combined) that pin the regressions we fixed in commits fa35662 and 8ce7574, plus the core invariants both tools rely on. tests/test_audit_citations.py (32 tests) ---------------------------------------- * ARXIV_RE / NBER_RE / DOI_RE / YEAR_RE / SURNAME_RE regex behaviour, including the bibtex ``doi={10.x/y}`` trailing-brace regression and DOI-with-internal-digit-periods happy path. * Name helpers: _last_name handles 'Last, First' + 'First Last' + hyphenated surnames; _normalise strips diacritics; PaperMeta.last_names applies the pipeline. * SURNAME_STOPWORDS regression pins for Q-network / Q-learning / DQN / PPO / A3C / actor-critic (the commit fa35662 fix) + sanity check that real author surnames are NOT stopped. * diff_citation: matching author clean, phantom author flagged ('Morgan & Winship' triggers author-list punctuation detection), Q-network case clean with Li/Zhang/Bareinboim as truth authors, bare-URL reference does not spuriously flag missing authors. * extract_citations: tmp_path walk via monkeypatched REPO_ROOT, extension filter, line-number recording, markdown support. * CLI: --strict is advertised in --help; empty tree exits cleanly. tests/test_audit_bib_duplicates.py (17 tests) --------------------------------------------- * parse_bib: single entry + brace-balanced nested-title entry (critical — naive regex would cut at the first inner '}'), DOI lowercasing + trailing-period stripping, arXiv from 'journal={arXiv:...}' and 'eprint={...}' fields, correct start-line tracking, non-entry prose ignored. * find_duplicates: duplicate keys flagged, same-DOI-different-keys flagged, same-DOI-same-key NOT counted as a DOI dup (would double-count with dup-keys), duplicate arXiv flagged, clean-bib reports nothing. * CLI: --strict exits 1 on dirty, 0 on clean; non-strict exits 0 even when reporting dups; missing bib file exits 2. * Repository invariant: paper.bib on main must have 0 dups of any kind — pins the cleanup from commit 8ce7574. CI wiring --------- .github/workflows/citation-audit.yml now runs the test suite BEFORE the live auditors (Gate 0). Rationale: if the auditor itself is broken, the live audit result is meaningless. Test suite is dependency-free (stdlib + pytest), so installing it adds ~2s to the CI job. Verification ------------ python3 -m pytest tests/test_audit_citations.py tests/test_audit_bib_duplicates.py --no-cov -v -> 49 passed in 0.93s actionlint on updated workflow: clean. pytest --collect-only over full tests/: 3321 tests collected, no collection errors from the new files. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8ce7574 commit 742ec43

3 files changed

Lines changed: 725 additions & 2 deletions

File tree

.github/workflows/citation-audit.yml

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ on:
2727
- 'paper.md'
2828
- 'tools/audit_citations.py'
2929
- 'tools/audit_bib_duplicates.py'
30+
- 'tests/test_audit_citations.py'
31+
- 'tests/test_audit_bib_duplicates.py'
3032
- '.github/workflows/citation-audit.yml'
3133
push:
3234
branches: [ main ]
@@ -37,6 +39,8 @@ on:
3739
- 'paper.md'
3840
- 'tools/audit_citations.py'
3941
- 'tools/audit_bib_duplicates.py'
42+
- 'tests/test_audit_citations.py'
43+
- 'tests/test_audit_bib_duplicates.py'
4044
- '.github/workflows/citation-audit.yml'
4145
workflow_dispatch:
4246

@@ -59,8 +63,16 @@ jobs:
5963
with:
6064
python-version: '3.13'
6165

62-
- name: Install certifi
63-
run: python -m pip install --quiet certifi
66+
- name: Install certifi + pytest
67+
# certifi for the citation auditor's HTTPS calls; pytest to run
68+
# the auditor test suite that pins the §10 regex regressions.
69+
run: python -m pip install --quiet certifi pytest
70+
71+
- name: Run auditor test suite
72+
# Fast (<1s) stdlib-only tests guarding DOI regex / Q-network
73+
# stopword / parse_bib brace-balance regressions. Runs BEFORE
74+
# the live auditors so a broken auditor can't silently pass.
75+
run: python -m pytest tests/test_audit_citations.py tests/test_audit_bib_duplicates.py --no-cov -q
6476

6577
- name: Run paper.bib duplicate auditor (strict)
6678
id: bib_audit

tests/test_audit_bib_duplicates.py

Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
"""Tests for ``tools/audit_bib_duplicates.py``.
2+
3+
These tests are stdlib-only — they never touch paper.bib itself, never
4+
hit the network, and never shell out (except for the CLI smoke-test).
5+
They guard the two integrity invariants the tool enforces:
6+
* ``parse_bib`` correctly locates entry keys + DOIs + arXiv ids even
7+
when titles carry nested braces.
8+
* ``find_duplicates`` counts different-key collisions but not the
9+
trivial "key defined N times" case as a DOI duplicate.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import subprocess
15+
import sys
16+
import textwrap
17+
from pathlib import Path
18+
19+
import pytest
20+
21+
REPO_ROOT = Path(__file__).resolve().parent.parent
22+
TOOLS_DIR = REPO_ROOT / "tools"
23+
sys.path.insert(0, str(TOOLS_DIR))
24+
25+
import audit_bib_duplicates as abd # noqa: E402
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# parse_bib
30+
# ---------------------------------------------------------------------------
31+
32+
33+
def test_parse_bib_single_entry_extracts_key_doi_arxiv():
34+
text = textwrap.dedent("""
35+
@article{smith2020example,
36+
title={A Study of X},
37+
author={Smith, Alice},
38+
year={2020},
39+
doi={10.1234/abcd.5678}
40+
}
41+
""").strip()
42+
entries = abd.parse_bib(text)
43+
assert len(entries) == 1
44+
e = entries[0]
45+
assert e.key == "smith2020example"
46+
assert e.kind == "article"
47+
assert e.doi == "10.1234/abcd.5678"
48+
assert e.arxiv is None
49+
50+
51+
def test_parse_bib_handles_nested_title_braces():
52+
"""Bibtex titles often protect capitalisation with nested braces.
53+
54+
The parser must balance braces to find the entry's closing ``}``,
55+
not the first one after the opening.
56+
"""
57+
text = textwrap.dedent("""
58+
@article{jones2021nested,
59+
title={Deep {RL} for {HTE}: Methods and Evidence},
60+
author={Jones, Bob},
61+
year={2021},
62+
doi={10.5555/xxxx.yyyy}
63+
}
64+
65+
@article{doe2022next,
66+
title={Simple},
67+
author={Doe, Jane},
68+
year={2022},
69+
doi={10.6666/zzzz}
70+
}
71+
""").strip()
72+
entries = abd.parse_bib(text)
73+
assert [e.key for e in entries] == ["jones2021nested", "doe2022next"]
74+
assert entries[0].doi == "10.5555/xxxx.yyyy"
75+
assert entries[1].doi == "10.6666/zzzz"
76+
77+
78+
def test_parse_bib_doi_lowercased_and_trailing_period_stripped():
79+
text = textwrap.dedent("""
80+
@article{upper2023case,
81+
title={Test},
82+
doi={10.1177/1536867X20909691.}
83+
}
84+
""").strip()
85+
entry = abd.parse_bib(text)[0]
86+
# Lowercased so `10.X/Y` and `10.x/y` dedupe together.
87+
assert entry.doi == "10.1177/1536867x20909691"
88+
89+
90+
def test_parse_bib_arxiv_from_journal_field():
91+
text = textwrap.dedent("""
92+
@misc{foo2024preprint,
93+
title={Whatever},
94+
author={Foo, Bar},
95+
year={2024},
96+
journal={arXiv:2408.12345}
97+
}
98+
""").strip()
99+
entry = abd.parse_bib(text)[0]
100+
assert entry.arxiv == "2408.12345"
101+
102+
103+
def test_parse_bib_arxiv_from_eprint_field():
104+
text = textwrap.dedent("""
105+
@misc{bar2024preprint,
106+
title={Whatever},
107+
eprint={2501.09876},
108+
archivePrefix={arXiv}
109+
}
110+
""").strip()
111+
entry = abd.parse_bib(text)[0]
112+
assert entry.arxiv == "2501.09876"
113+
114+
115+
def test_parse_bib_records_starting_line_number():
116+
text = "\n\n\n" + textwrap.dedent("""
117+
@article{line4entry,
118+
title={X},
119+
doi={10.1/2}
120+
}
121+
""").strip()
122+
entry = abd.parse_bib(text)[0]
123+
# The '@' sits on line 4 (after three leading blank lines).
124+
assert entry.line == 4
125+
126+
127+
def test_parse_bib_skips_non_entry_text():
128+
text = textwrap.dedent("""
129+
% This is a comment line describing the file.
130+
Some free prose about the bibliography.
131+
132+
@article{real2020entry,
133+
title={Real},
134+
doi={10.1/real}
135+
}
136+
""").strip()
137+
entries = abd.parse_bib(text)
138+
assert len(entries) == 1
139+
assert entries[0].key == "real2020entry"
140+
141+
142+
# ---------------------------------------------------------------------------
143+
# find_duplicates
144+
# ---------------------------------------------------------------------------
145+
146+
147+
def _entries(*specs):
148+
"""Build BibEntry list from (key, doi, arxiv) tuples for terse tests."""
149+
return [
150+
abd.BibEntry(key=k, kind="article", doi=d, arxiv=a, line=i + 1, raw="")
151+
for i, (k, d, a) in enumerate(specs)
152+
]
153+
154+
155+
def test_find_duplicates_flags_duplicate_keys():
156+
entries = _entries(
157+
("same_key", "10.1/a", None),
158+
("other", "10.1/b", None),
159+
("same_key", "10.1/c", None), # same key, different DOI
160+
)
161+
report = abd.find_duplicates(entries)
162+
assert "same_key" in report.duplicate_keys
163+
assert report.duplicate_keys["same_key"] == [1, 3]
164+
assert "other" not in report.duplicate_keys
165+
166+
167+
def test_find_duplicates_flags_same_doi_different_keys():
168+
entries = _entries(
169+
("key_a", "10.1/shared", None),
170+
("key_b", "10.1/shared", None),
171+
("key_c", "10.1/unique", None),
172+
)
173+
report = abd.find_duplicates(entries)
174+
assert "10.1/shared" in report.duplicate_dois
175+
assert len(report.duplicate_dois["10.1/shared"]) == 2
176+
assert "10.1/unique" not in report.duplicate_dois
177+
178+
179+
def test_find_duplicates_ignores_same_doi_same_key():
180+
"""Two entries with the SAME key + same DOI is a duplicate-KEY bug,
181+
not a duplicate-DOI bug. Flagging it as both would double-count."""
182+
entries = _entries(
183+
("same_key", "10.1/same_doi", None),
184+
("same_key", "10.1/same_doi", None),
185+
)
186+
report = abd.find_duplicates(entries)
187+
assert "same_key" in report.duplicate_keys
188+
assert report.duplicate_dois == {}
189+
190+
191+
def test_find_duplicates_flags_same_arxiv_different_keys():
192+
entries = _entries(
193+
("key_a", None, "2408.12345"),
194+
("key_b", None, "2408.12345"),
195+
)
196+
report = abd.find_duplicates(entries)
197+
assert "2408.12345" in report.duplicate_arxiv
198+
199+
200+
def test_find_duplicates_clean_bib_reports_nothing():
201+
entries = _entries(
202+
("a2020x", "10.1/a", None),
203+
("b2021y", "10.1/b", "2401.00001"),
204+
("c2022z", None, "2402.00002"),
205+
)
206+
report = abd.find_duplicates(entries)
207+
assert report.duplicate_keys == {}
208+
assert report.duplicate_dois == {}
209+
assert report.duplicate_arxiv == {}
210+
assert report.total_issues == 0
211+
212+
213+
# ---------------------------------------------------------------------------
214+
# CLI --strict exit code
215+
# ---------------------------------------------------------------------------
216+
217+
218+
def test_cli_strict_exits_nonzero_on_duplicates(tmp_path):
219+
bib = tmp_path / "dirty.bib"
220+
bib.write_text(textwrap.dedent("""
221+
@article{dup_key,
222+
title={A},
223+
doi={10.1/a}
224+
}
225+
226+
@article{dup_key,
227+
title={B},
228+
doi={10.1/b}
229+
}
230+
""").strip(), encoding="utf-8")
231+
result = subprocess.run(
232+
[sys.executable, str(TOOLS_DIR / "audit_bib_duplicates.py"),
233+
"--bib", str(bib), "--strict"],
234+
capture_output=True, text=True, check=False,
235+
)
236+
assert result.returncode == 1, result.stdout + result.stderr
237+
assert "dup_key" in result.stdout
238+
239+
240+
def test_cli_strict_exits_zero_on_clean_bib(tmp_path):
241+
bib = tmp_path / "clean.bib"
242+
bib.write_text(textwrap.dedent("""
243+
@article{unique_one,
244+
title={A},
245+
doi={10.1/a}
246+
}
247+
248+
@article{unique_two,
249+
title={B},
250+
doi={10.1/b}
251+
}
252+
""").strip(), encoding="utf-8")
253+
result = subprocess.run(
254+
[sys.executable, str(TOOLS_DIR / "audit_bib_duplicates.py"),
255+
"--bib", str(bib), "--strict"],
256+
capture_output=True, text=True, check=False,
257+
)
258+
assert result.returncode == 0, result.stdout + result.stderr
259+
260+
261+
def test_cli_non_strict_reports_but_exits_zero(tmp_path):
262+
bib = tmp_path / "dirty.bib"
263+
bib.write_text(textwrap.dedent("""
264+
@article{dup_key,
265+
title={A},
266+
doi={10.1/a}
267+
}
268+
269+
@article{dup_key,
270+
title={B},
271+
doi={10.1/b}
272+
}
273+
""").strip(), encoding="utf-8")
274+
result = subprocess.run(
275+
[sys.executable, str(TOOLS_DIR / "audit_bib_duplicates.py"),
276+
"--bib", str(bib)],
277+
capture_output=True, text=True, check=False,
278+
)
279+
# Without --strict: still report, but exit 0 so humans running it
280+
# locally don't get confused by non-zero exits.
281+
assert result.returncode == 0
282+
assert "dup_key" in result.stdout
283+
284+
285+
def test_cli_missing_bib_file_returns_2(tmp_path):
286+
missing = tmp_path / "nope.bib"
287+
result = subprocess.run(
288+
[sys.executable, str(TOOLS_DIR / "audit_bib_duplicates.py"),
289+
"--bib", str(missing)],
290+
capture_output=True, text=True, check=False,
291+
)
292+
assert result.returncode == 2
293+
assert "not found" in result.stderr
294+
295+
296+
# ---------------------------------------------------------------------------
297+
# Real paper.bib regression: current HEAD must be dup-free
298+
# ---------------------------------------------------------------------------
299+
300+
301+
def test_repository_paper_bib_has_no_duplicates():
302+
"""Pin the §10 invariant: paper.bib on main must have 0 dups.
303+
304+
If this fails, either (a) a duplicate was introduced, or (b) a
305+
legitimate new duplicate arose (same paper, multiple venues) — in
306+
which case the tool's semantics need an allowlist mechanism, not a
307+
silent regression.
308+
"""
309+
bib_path = REPO_ROOT / "paper.bib"
310+
if not bib_path.exists():
311+
pytest.skip("paper.bib not present in this checkout")
312+
entries = abd.parse_bib(bib_path.read_text(encoding="utf-8"))
313+
report = abd.find_duplicates(entries)
314+
assert report.duplicate_keys == {}, (
315+
f"paper.bib has duplicate keys: {list(report.duplicate_keys)}"
316+
)
317+
assert report.duplicate_dois == {}, (
318+
f"paper.bib has duplicate DOIs: {list(report.duplicate_dois)}"
319+
)
320+
assert report.duplicate_arxiv == {}, (
321+
f"paper.bib has duplicate arXiv ids: {list(report.duplicate_arxiv)}"
322+
)

0 commit comments

Comments
 (0)