Skip to content

Commit 558db0d

Browse files
committed
Fix author order comparison and test cleanup
1 parent 88df924 commit 558db0d

13 files changed

Lines changed: 72 additions & 52 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"name": "bibkit",
1414
"source": "./",
1515
"description": "A bibliography toolkit for LaTeX",
16-
"version": "1.2.1",
16+
"version": "1.2.2",
1717
"keywords": ["bibtex", "bibliography", "latex", "overleaf", "academic", "reference", "citation"],
1818
"category": "academic",
1919
"license": "MIT"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
22
"name": "bibkit",
33
"description": "A bibliography toolkit for LaTeX",
4-
"version": "1.2.1"
4+
"version": "1.2.2"
55
}

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ A bibliography toolkit for LaTeX — Claude Code plugin.
77
```
88
bibkit/
99
├── .claude-plugin/
10+
│ ├── marketplace.json ← marketplace catalog
1011
│ └── plugin.json ← plugin manifest
1112
├── skills/
1213
│ └── bibtidy/
@@ -36,6 +37,8 @@ bibkit/
3637

3738
## How it works
3839

40+
### bibtidy
41+
3942
Invoke with `/bibtidy refs.bib`. Claude reads the .bib file, dispatches parallel subagents to verify entries against Google Scholar (WebSearch) and CrossRef (bundled `crossref.py`), then applies fixes sequentially using targeted Edit tool replacements. Every change includes the original entry commented out and a source URL for verification.
4043

4144
## Versioning

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "bibkit"
3-
version = "1.2.1"
3+
version = "1.2.2"
44
description = "A bibliography toolkit for LaTeX — Claude Code plugin"
55
requires-python = ">=3.10"
66
license = "MIT"

skills/bibtidy/SKILL.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
---
22
name: bibtidy
33
description: Use when the user wants to validate, check, or fix a BibTeX (.bib) reference file — wrong authors, stale arXiv preprints, incorrect metadata, duplicate entries, formatting issues
4+
argument-hint: <path-to-file.bib>
5+
allowed-tools: Bash(python3 *), Read, Edit, Agent, WebSearch
46
---
57

68
Validate and fix the BibTeX file at: $ARGUMENTS
@@ -28,8 +30,7 @@ All bundled tools live in the `tools/` directory next to this SKILL.md, installe
2830
```
2931
TOOLS_DIR="$HOME/.claude/skills/bibtidy/tools"
3032
if [ ! -f "$TOOLS_DIR/crossref.py" ]; then
31-
echo "Error: bibtidy tools not found. Reinstall the plugin." >&2
32-
exit 1
33+
TOOLS_DIR="${CLAUDE_PLUGIN_ROOT}/skills/bibtidy/tools"
3334
fi
3435
```
3536

skills/bibtidy/tools/compare.py

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,10 @@ def _normalize_title(title: str) -> str:
4444

4545

4646
def _normalize_author_list(authors_str: str) -> list[str]:
47-
"""Parse 'Last, First and Last, First' into sorted lowercase last names."""
47+
"""Parse 'Last, First and Last, First' into ordered lowercase last names.
48+
49+
Preserves order so first-author swaps are detected.
50+
"""
4851
names = []
4952
for name in authors_str.split(" and "):
5053
name = name.strip()
@@ -58,11 +61,11 @@ def _normalize_author_list(authors_str: str) -> list[str]:
5861
last = last.replace("{", "").replace("}", "")
5962
last = re.sub(r"\\.", "", last)
6063
names.append(last.lower())
61-
return sorted(names)
64+
return names
6265

6366

6467
def _crossref_author_last_names(authors: list[str]) -> list[str]:
65-
"""Extract sorted lowercase last names from CrossRef 'Last, First' strings."""
68+
"""Extract ordered lowercase last names from CrossRef 'Last, First' strings."""
6669
names = []
6770
for a in authors:
6871
if "," in a:
@@ -71,7 +74,7 @@ def _crossref_author_last_names(authors: list[str]) -> list[str]:
7174
parts = a.split()
7275
last = parts[-1].lower() if parts else ""
7376
names.append(last)
74-
return sorted(names)
77+
return names
7578

7679

7780
def compare_entry(entry: dict, crossref: dict) -> list[dict]:
@@ -92,19 +95,21 @@ def _add(field, bib_val, cr_val, severity="fix"):
9295
if _normalize_title(bib_title) != _normalize_title(cr_title):
9396
_add("title", bib_title, cr_title)
9497

95-
# Authors (compare last names only — first name formats vary)
98+
# Authors (compare ordered last names — first name formats vary too much)
9699
bib_author = entry.get("author", "")
97100
cr_authors = crossref.get("authors", [])
98101
if bib_author and cr_authors:
99102
bib_names = _normalize_author_list(bib_author)
100103
cr_names = _crossref_author_last_names(cr_authors)
101-
# Ignore "others" truncation: only compare if bib has fewer unique names
102-
# that don't appear in CrossRef
103-
bib_set = set(bib_names)
104-
cr_set = set(cr_names)
105-
extra_in_bib = bib_set - cr_set
106-
missing_from_bib = cr_set - bib_set
107-
if extra_in_bib or (missing_from_bib and len(bib_names) >= len(cr_names)):
104+
# Compare the overlapping prefix — flag if it disagrees or if
105+
# either side has authors the other doesn't.
106+
n = min(len(bib_names), len(cr_names))
107+
prefix_matches = bib_names[:n] == cr_names[:n]
108+
if not prefix_matches:
109+
# The authors they both list don't agree (wrong names or order)
110+
_add("author", bib_author, " and ".join(cr_authors), "review")
111+
elif len(bib_names) != len(cr_names):
112+
# One side has more authors than the other — let Claude decide
108113
_add("author", bib_author, " and ".join(cr_authors), "review")
109114

110115
# Year

tests/conftest.py

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,9 @@
33
import os
44
import sys
55

6-
# Add tools directory so tests can import crossref, duplicates, fmt directly
7-
_tools_dir = os.path.join(os.path.dirname(__file__), "..", "skills", "bibtidy", "tools")
8-
if _tools_dir not in sys.path:
9-
sys.path.insert(0, os.path.abspath(_tools_dir))
6+
_base = os.path.join(os.path.dirname(__file__), "..")
107

11-
# Add tests directory so validate.py is importable
12-
_tests_dir = os.path.dirname(__file__)
13-
if _tests_dir not in sys.path:
14-
sys.path.insert(0, os.path.abspath(_tests_dir))
8+
for subdir in ("skills/bibtidy/tools", "tests"):
9+
d = os.path.abspath(os.path.join(_base, subdir))
10+
if d not in sys.path:
11+
sys.path.insert(0, d)

tests/test_compare.py

Lines changed: 39 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,6 @@
11
#!/usr/bin/env python3
22
"""Tests for compare.py — field-level comparison between BibTeX and CrossRef."""
33

4-
import os
5-
import sys
6-
7-
8-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "skills", "bibtidy", "tools"))
9-
104
from compare import compare_entry
115

126

@@ -57,7 +51,8 @@ def test_different_title(self):
5751

5852

5953
class TestAuthors:
60-
def test_extra_author_in_bib(self):
54+
def test_bib_has_more_authors_than_crossref_is_ok(self):
55+
"""Bib with more authors is OK — CrossRef may truncate."""
6156
entry = {"key": "X", "author": "Smith, John and Dayan, Peter"}
6257
cr = {"authors": ["Smith, John"]}
6358
ms = compare_entry(entry, cr)
@@ -69,12 +64,47 @@ def test_matching_authors(self):
6964
ms = compare_entry(entry, cr)
7065
assert not any(m["field"] == "author" for m in ms)
7166

67+
def test_swapped_author_order(self):
68+
"""First-author swap should be flagged."""
69+
entry = {"key": "X", "author": "Doe, Jane and Smith, John"}
70+
cr = {"authors": ["Smith, John", "Doe, Jane"]}
71+
ms = compare_entry(entry, cr)
72+
assert any(m["field"] == "author" for m in ms)
73+
7274
def test_bib_has_others(self):
73-
"""'and others' truncation should not flag missing authors."""
75+
"""'and others' with full list available should be flagged for Claude to decide."""
7476
entry = {"key": "X", "author": "Smith, John and others"}
7577
cr = {"authors": ["Smith, John", "Doe, Jane", "Lee, Bob"]}
7678
ms = compare_entry(entry, cr)
77-
assert not any(m["field"] == "author" for m in ms)
79+
assert any(m["field"] == "author" for m in ms)
80+
81+
def test_bib_has_others_wrong_order(self):
82+
"""'and others' with wrong prefix order should be flagged."""
83+
entry = {"key": "X", "author": "Doe, Jane and Smith, John and others"}
84+
cr = {"authors": ["Smith, John", "Doe, Jane", "Lee, Bob"]}
85+
ms = compare_entry(entry, cr)
86+
assert any(m["field"] == "author" for m in ms)
87+
88+
def test_bib_has_more_authors_than_crossref(self):
89+
"""Bib with more authors than CrossRef should be flagged for Claude to decide."""
90+
entry = {"key": "X", "author": "Smith, John and Doe, Jane and Lee, Bob"}
91+
cr = {"authors": ["Smith, John", "Doe, Jane"]}
92+
ms = compare_entry(entry, cr)
93+
assert any(m["field"] == "author" for m in ms)
94+
95+
def test_bib_has_more_but_wrong_prefix(self):
96+
"""Bib with more authors should be flagged if the shared prefix disagrees."""
97+
entry = {"key": "X", "author": "Doe, Jane and Smith, John and Lee, Bob"}
98+
cr = {"authors": ["Smith, John", "Doe, Jane"]}
99+
ms = compare_entry(entry, cr)
100+
assert any(m["field"] == "author" for m in ms)
101+
102+
def test_bib_missing_authors_no_others(self):
103+
"""Bib with fewer authors and no 'others' should be flagged."""
104+
entry = {"key": "X", "author": "Smith, John"}
105+
cr = {"authors": ["Smith, John", "Doe, Jane", "Lee, Bob"]}
106+
ms = compare_entry(entry, cr)
107+
assert any(m["field"] == "author" for m in ms)
78108

79109

80110
class TestYear:

tests/test_crossref.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,8 @@
22
"""Tests for crossref.py — JSON parsing/formatting and error handling."""
33

44
import json
5-
import os
6-
import sys
7-
from unittest.mock import patch
8-
95
import urllib.error
10-
11-
# Ensure the crossref module is importable.
12-
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir, "skills", "bibtidy", "tools")))
6+
from unittest.mock import patch
137

148
import crossref
159

tests/test_duplicates.py

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,6 @@
66
import subprocess
77
import sys
88

9-
10-
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "skills", "bibtidy", "tools"))
11-
129
from duplicates import find_duplicates, is_preprint, normalize_title, parse_bib_entries
1310

1411
TOOL_PATH = os.path.join(os.path.dirname(__file__), "..", "skills", "bibtidy", "tools", "duplicates.py")

0 commit comments

Comments
 (0)