skilldrop-cli 1.11.1 → 1.13.0

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 (72) hide show
  1. package/README.md +1 -1
  2. package/bin/skilldrop.js +46 -3
  3. package/catalogue.json +2 -1
  4. package/model-routing.json +4 -0
  5. package/package.json +1 -1
  6. package/packs/ai-engineering/skills/agent-config-review/evals/files/1/dot.gitignore +5 -0
  7. package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/dot.gitignore +3 -0
  8. package/packs/ai-engineering/skills/agent-config-review/examples/python-repo-tighten.md +3 -3
  9. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/dot.npmrc +3 -0
  10. package/packs/experience-design/skills/locale-check/SKILL.md +1 -1
  11. package/packs/experience-design/skills/locale-check/manifest.json +2 -2
  12. package/packs/frontend/skills/bundle-size-review/evals/files/9/dot.gitignore +5 -0
  13. package/packs/research/skills/manuscript-check/SKILL.md +121 -0
  14. package/packs/research/skills/manuscript-check/evals/eval_queries.json +30 -0
  15. package/packs/research/skills/manuscript-check/evals/evals.json +73 -0
  16. package/packs/research/skills/manuscript-check/evals/files/1/paper.md +56 -0
  17. package/packs/research/skills/manuscript-check/evals/files/2/paper.md +118 -0
  18. package/packs/research/skills/manuscript-check/evals/files/3/report.md +115 -0
  19. package/packs/research/skills/manuscript-check/evals/files/4/paper.md +79 -0
  20. package/packs/research/skills/manuscript-check/evals/files/5/chapter3.md +65 -0
  21. package/packs/research/skills/manuscript-check/examples/inputs/paper.md +56 -0
  22. package/packs/research/skills/manuscript-check/examples/retention-paper.md +48 -0
  23. package/packs/research/skills/manuscript-check/manifest.json +36 -0
  24. package/packs/research/skills/manuscript-check/scripts/manuscript_check.py +355 -0
  25. /package/packs/ai-engineering/skills/agent-config-review/evals/files/1/{.claude → dot.claude}/agents/db-migrator.md +0 -0
  26. /package/packs/ai-engineering/skills/agent-config-review/evals/files/1/{.claude → dot.claude}/agents/reviewer.md +0 -0
  27. /package/packs/ai-engineering/skills/agent-config-review/evals/files/1/{.claude → dot.claude}/settings.json +0 -0
  28. /package/packs/ai-engineering/skills/agent-config-review/evals/files/1/{.claude → dot.claude}/settings.local.json +0 -0
  29. /package/packs/ai-engineering/skills/agent-config-review/evals/files/1/{.mcp.json → dot.mcp.json} +0 -0
  30. /package/packs/ai-engineering/skills/agent-config-review/evals/files/2/{.cursor → dot.cursor}/mcp.json +0 -0
  31. /package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/{.claude → dot.claude}/agents/release-notes.md +0 -0
  32. /package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/{.claude → dot.claude}/settings.json +0 -0
  33. /package/packs/ai-engineering/skills/agent-config-review/evals/files/4/quillstack/{.mcp.json → dot.mcp.json} +0 -0
  34. /package/packs/ai-engineering/skills/agent-config-review/evals/files/5/{.github → dot.github}/workflows/codex-triage.yml.fixture +0 -0
  35. /package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/{.claude → dot.claude}/settings.json +0 -0
  36. /package/packs/ai-engineering/skills/agent-config-review/examples/inputs/tidewater-ingest/{.mcp.json → dot.mcp.json} +0 -0
  37. /package/packs/ai-engineering/skills/agent-incident-response/evals/files/6/{.mcp.json → dot.mcp.json} +0 -0
  38. /package/packs/ai-engineering/skills/agent-incident-response/evals/files/7/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  39. /package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/{.mcp.json → dot.mcp.json} +0 -0
  40. /package/packs/api-builder/skills/eval-harness-generator/evals/files/1/{SKILL.md → SKILL.md.fixture} +0 -0
  41. /package/packs/api-builder/skills/eval-harness-generator/evals/files/2/{SKILL.md → SKILL.md.fixture} +0 -0
  42. /package/packs/core/skills/context-graph/evals/files/9/{.env.example → dot.env.example} +0 -0
  43. /package/packs/dev-team/skills/agents-md-generator/evals/files/1/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  44. /package/packs/dev-team/skills/agents-md-generator/evals/files/5/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  45. /package/packs/dev-team/skills/codebase-tour/evals/files/1/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  46. /package/packs/dev-team/skills/codebase-tour/evals/files/2/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  47. /package/packs/dev-team/skills/codebase-tour/evals/files/3/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  48. /package/packs/dev-team/skills/codebase-tour/evals/files/4/{.env.example → dot.env.example} +0 -0
  49. /package/packs/dev-team/skills/codebase-tour/evals/files/4/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  50. /package/packs/dev-team/skills/codebase-tour/evals/files/5/{.env.example → dot.env.example} +0 -0
  51. /package/packs/dev-team/skills/codebase-tour/evals/files/5/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  52. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  53. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  54. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/{.github → dot.github}/workflows/release.yml.fixture +0 -0
  55. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  56. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  57. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/{.github → dot.github}/workflows/release.yml.fixture +0 -0
  58. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/{.nvmrc → dot.nvmrc} +0 -0
  59. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  60. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/6/{.github → dot.github}/workflows/integration.yml.fixture +0 -0
  61. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  62. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/{.lintwright.json → dot.lintwright.json} +0 -0
  63. /package/packs/dev-team/skills/dependency-upgrade-review/evals/files/7/{.nvmrc → dot.nvmrc} +0 -0
  64. /package/packs/dev-team/skills/sonar-onboard/evals/files/2/{.github → dot.github}/workflows/ci.yml.fixture +0 -0
  65. /package/packs/dev-team/skills/sonar-onboard/evals/files/2/{.gitlab-ci.yml → dot.gitlab-ci.yml} +0 -0
  66. /package/packs/frontend/skills/bundle-size-review/evals/files/5/{.browserslistrc → dot.browserslistrc} +0 -0
  67. /package/packs/frontend/skills/bundle-size-review/evals/files/6/{.browserslistrc → dot.browserslistrc} +0 -0
  68. /package/packs/frontend/skills/bundle-size-review/evals/files/8/{.browserslistrc → dot.browserslistrc} +0 -0
  69. /package/packs/frontend/skills/bundle-size-review/evals/files/9/{.github → dot.github}/workflows/size.yml.fixture +0 -0
  70. /package/packs/skill-engineering/skills/skill-review/evals/files/1/skills/changelog-writer/{SKILL.md → SKILL.md.fixture} +0 -0
  71. /package/packs/skill-engineering/skills/skill-review/evals/files/1/skills/pr-summary/{SKILL.md → SKILL.md.fixture} +0 -0
  72. /package/packs/skill-engineering/skills/skill-review/evals/files/2/invoice-chaser/{SKILL.md → SKILL.md.fixture} +0 -0
@@ -0,0 +1,355 @@
1
+ #!/usr/bin/env python3
2
+ """Check a manuscript's citations and cross-references before it goes to a journal, a
3
+ supervisor or a publisher. Stdlib only.
4
+
5
+ Reads a paper, thesis chapter or report (Markdown, plain text or .docx) and reports, each with
6
+ its line:
7
+ - numbered citations ([3], [2-5], [1, 4]): a number with no reference, a reference never
8
+ cited, references not numbered in order of first citation, a number used twice in the list
9
+ - author-year citations ((Smith, 2020), Smith et al. (2019), (Lee & Park 2021a; Okafor 2018)):
10
+ a citation with no matching reference, a near miss (the right author with another year, or
11
+ the right year with a misspelt name), a reference never cited
12
+ - the same work listed twice (same DOI, or same title)
13
+ - Figure, Table and Section references with no such figure, table or section; a caption never
14
+ referred to; figures or tables numbered out of order or with gaps
15
+ - Markdown footnotes used but not defined, or defined but not used; malformed DOIs
16
+ - the abstract's word count against a limit
17
+ It checks the manuscript's internal consistency, not whether the citations support the claims.
18
+
19
+ Usage:
20
+ python3 manuscript_check.py paper.md [--abstract-limit 250] [--json]
21
+ Exit codes: 0 no failures, 1 a failure, 2 the file can't be read.
22
+ """
23
+ import argparse
24
+ import json
25
+ import re
26
+ import sys
27
+ import unicodedata
28
+ import zipfile
29
+ from xml.etree import ElementTree
30
+
31
+ W = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
32
+ REF_HEAD = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*(?:\d+\.?\s*)?(references|bibliography|works\s+cited|literature\s+cited|reference\s+list|sources)\s*(?:\*\*)?\s*:?\s*$", re.I)
33
+ NUM_CITE = re.compile(r"\[(\d{1,3}(?:\s*[-–,]\s*\d{1,3})*)\]")
34
+ NUM_ENTRY = re.compile(r"^\s*(?:\[(\d{1,3})\]|(\d{1,3})\.)\s+(.+)")
35
+ SURNAME = r"(?:[A-Z][A-Za-z'’\-]+|(?:van|von|de|da|del|der|di|le|la)\s+[A-Z][A-Za-z'’\-]+)"
36
+ AUTH = SURNAME + r"(?:\s+(?:et\s+al\.?|and|&)\s*(?:" + SURNAME + r")?)?"
37
+ YEAR = r"(?:1[89]\d\d|20\d\d)[a-z]?|n\.d\.|in press|forthcoming"
38
+ PAREN = re.compile(r"\(([^()]*?(?:" + YEAR + r")[^()]*)\)")
39
+ NARRATIVE = re.compile(r"\b(" + AUTH + r")\s*\((" + YEAR + r")(?:[,;][^)]*)?\)")
40
+ IN_PAREN = re.compile(r"(?:see\s+|e\.g\.,?\s+|cf\.\s+)?(" + AUTH + r"),?\s+(" + YEAR + r")")
41
+ FIG_REF = re.compile(r"\b(Fig(?:ure|\.)?s?|Tables?|Sections?|§)\s*(\d{1,3}(?:\.\d{1,3})*)(?:\s*(?:and|&|–|-|to)\s*(\d{1,3}(?:\.\d{1,3})*))?", re.I)
42
+ CAPTION = re.compile(r"^\s*(?:!\[[^\]]*\]\([^)]*\)\s*)?(?:\*\*|_|\*)?\s*(Fig(?:ure|\.)?|Table)\s+(\d{1,3})\s*[.:|—–-]", re.I)
43
+ SECTION_HEAD = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?(\d{1,2}(?:\.\d{1,2}){0,3})\.?\s+[A-Z]")
44
+ FOOT_USE = re.compile(r"\[\^([^\]\s]+)\](?!:)")
45
+ FOOT_DEF = re.compile(r"^\s*\[\^([^\]\s]+)\]:")
46
+ DOI = re.compile(r"\b(?:doi:\s*|https?://(?:dx\.)?doi\.org/)?(10\.\S+)", re.I)
47
+ GOOD_DOI = re.compile(r"^10\.\d{4,9}/[-._;()/:A-Za-z0-9<>\[\]]+$")
48
+ ABSTRACT = re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*abstract\s*(?:\*\*)?\s*:?\s*(.*)$", re.I)
49
+
50
+
51
+ def read_lines(path):
52
+ if path.lower().endswith(".docx"):
53
+ with zipfile.ZipFile(path) as z:
54
+ root = ElementTree.fromstring(z.read("word/document.xml"))
55
+ return ["".join(t.text or "" for t in p.iter(W + "t")) for p in root.iter(W + "p")]
56
+ with open(path, encoding="utf-8-sig") as f:
57
+ return f.read().splitlines()
58
+
59
+
60
+ def fold(s):
61
+ """Lower case without accents, so Müller matches Muller."""
62
+ return "".join(c for c in unicodedata.normalize("NFKD", s) if not unicodedata.combining(c)).lower()
63
+
64
+
65
+ def expand(spec):
66
+ out = []
67
+ for part in re.split(r"\s*,\s*", spec):
68
+ m = re.match(r"(\d+)\s*[-–]\s*(\d+)$", part)
69
+ if m:
70
+ a, b = int(m.group(1)), int(m.group(2))
71
+ out += list(range(a, b + 1)) if 0 < b - a < 200 else [a, b]
72
+ elif part.strip().isdigit():
73
+ out.append(int(part))
74
+ return out
75
+
76
+
77
+ def split(lines):
78
+ """(body lines with numbers, reference entries) — the list runs from its heading to the next heading."""
79
+ start = next((i for i, l in enumerate(lines) if REF_HEAD.match(l)), None)
80
+ if start is None:
81
+ return list(enumerate(lines, 1)), []
82
+ end = next((i for i in range(start + 1, len(lines)) if re.match(r"^\s*#{1,6}\s+\S", lines[i]) and not REF_HEAD.match(lines[i])
83
+ and not re.match(r"^\s*#{1,6}\s+(appendix|supplement)", lines[i], re.I) or
84
+ re.match(r"^\s*#{1,6}\s+(appendix|supplement)", lines[i], re.I)), len(lines))
85
+ body = list(enumerate(lines[:start], 1)) + list(enumerate(lines[end:], end + 1))
86
+ entries, cur = [], None
87
+ for n, l in enumerate(lines[start + 1:end], start + 2):
88
+ if not l.strip():
89
+ cur = None
90
+ continue
91
+ m = NUM_ENTRY.match(l)
92
+ bullet = re.match(r"^\s*[-*•]\s+(.+)", l)
93
+ if m or bullet or cur is None:
94
+ text = m.group(3) if m else bullet.group(1) if bullet else l.strip()
95
+ cur = {"line": n, "num": int(m.group(1) or m.group(2)) if m else None, "text": text.strip()}
96
+ entries.append(cur)
97
+ else:
98
+ cur["text"] += " " + l.strip()
99
+ return body, entries
100
+
101
+
102
+ def entry_key(e):
103
+ """(first author's surname, year with suffix) from an author-year reference entry."""
104
+ t = re.sub(r"^[\s*_]+", "", e["text"])
105
+ m = re.match(r"((?:van|von|de|da|del|der|di|le|la)\s+)?([A-Z][^\s,.(]*)", t)
106
+ y = re.search(r"\((" + YEAR + r")\)|[,.]\s*(" + YEAR + r")[.,)]", t)
107
+ sur = ((m.group(1) or "") + m.group(2)).strip() if m else None
108
+ year = (y.group(1) or y.group(2)) if y else None
109
+ return (fold(sur) if sur else None, year.lower() if year else None)
110
+
111
+
112
+ def surname_of(auth):
113
+ a = re.split(r"\s+(?:et\s+al\.?|and|&)\s*", auth.strip())[0]
114
+ return fold(a.strip())
115
+
116
+
117
+ def author_year_cites(body):
118
+ cites = []
119
+ for n, line in body:
120
+ clean = re.sub(r"\[[^\]]*\]\([^)]*\)", "", line)
121
+ for m in NARRATIVE.finditer(clean):
122
+ cites.append((n, surname_of(m.group(1)), m.group(2).lower(), m.group(0)))
123
+ for p in PAREN.finditer(clean):
124
+ inner = p.group(1)
125
+ for chunk in re.split(r";", inner):
126
+ mm = IN_PAREN.search(chunk)
127
+ if mm:
128
+ years = re.findall(YEAR, chunk[mm.start(2):])
129
+ for y in years or [mm.group(2)]:
130
+ cites.append((n, surname_of(mm.group(1)), y.lower(), chunk.strip()))
131
+ seen, out = set(), []
132
+ for c in cites:
133
+ if (c[0], c[1], c[2]) not in seen:
134
+ seen.add((c[0], c[1], c[2]))
135
+ out.append(c)
136
+ return out
137
+
138
+
139
+ def close(a, b):
140
+ """Two surnames one edit apart (a typo), or one a prefix of the other."""
141
+ if a == b:
142
+ return True
143
+ if abs(len(a) - len(b)) > 1:
144
+ return False
145
+ if len(a) == len(b):
146
+ return sum(x != y for x, y in zip(a, b)) == 1
147
+ s, l = (a, b) if len(a) < len(b) else (b, a)
148
+ return any(l[:i] + l[i + 1:] == s for i in range(len(l)))
149
+
150
+
151
+ def check(lines, abstract_limit=None):
152
+ fails, warns = [], []
153
+ body, entries = split(lines)
154
+ numbered = [e for e in entries if e["num"] is not None]
155
+ style = "numbered" if numbered and len(numbered) >= len(entries) / 2 else "author-year" if entries else None
156
+ body_text = [(n, l) for n, l in body]
157
+ stats = {"references": len(entries), "style": style}
158
+ if style == "numbered":
159
+ nums = {}
160
+ for e in numbered:
161
+ if e["num"] in nums:
162
+ fails.append(f"reference [{e['num']}] is numbered twice in the list (lines {nums[e['num']]} and {e['line']})")
163
+ nums.setdefault(e["num"], e["line"])
164
+ order, cited = [], {}
165
+ for n, l in body_text:
166
+ for m in NUM_CITE.finditer(l):
167
+ if re.search(r"\]\(", l[m.end():m.end() + 2]):
168
+ continue # a Markdown link, not a citation
169
+ for k in expand(m.group(1)):
170
+ cited.setdefault(k, n)
171
+ if k not in order:
172
+ order.append(k)
173
+ for k, n in sorted(cited.items(), key=lambda x: x[1]):
174
+ if k not in nums:
175
+ fails.append(f"line {n}: cites [{k}], but there is no reference [{k}]")
176
+ for k, n in sorted(nums.items()):
177
+ if k not in cited:
178
+ warns.append(f"reference [{k}] (line {n}) is never cited")
179
+ listed = sorted(nums)
180
+ for a, b in zip(listed, listed[1:]):
181
+ if b != a + 1:
182
+ warns.append(f"the reference list jumps from [{a}] to [{b}]")
183
+ in_list = [k for k in order if k in nums]
184
+ if in_list and in_list != sorted(in_list):
185
+ # the numbering a citation-order style expects: cited references by first citation,
186
+ # then any never cited, in their current order
187
+ new_order = in_list + [k for k in sorted(nums) if k not in in_list]
188
+ stats["renumber"] = {old: new for new, old in enumerate(new_order, 1) if old != new}
189
+ first = next(k for i, k in enumerate(in_list) if k != sorted(in_list)[i])
190
+ warns.append(f"references aren't numbered in order of first citation (from [{first}], line {cited[first]}); "
191
+ "numbered styles such as Vancouver and IEEE expect that")
192
+ stats["cited"] = len([k for k in cited if k in nums])
193
+ elif style == "author-year":
194
+ keys = [(entry_key(e), e) for e in entries]
195
+ cites = author_year_cites(body_text)
196
+ used = set()
197
+ for n, sur, year, text in cites:
198
+ exact = [e for (s, y), e in keys if s == sur and y == year]
199
+ if exact:
200
+ used.update(id(e) for e in exact)
201
+ continue
202
+ if year.rstrip("abcdefgh") == year and any(s == sur and y and y.rstrip("abcdefgh") == year for (s, y), _ in keys):
203
+ warns.append(f"line {n}: “{text}” needs its year's letter: the list has more than one {sur.title()} {year}")
204
+ used.update(id(e) for (s, y), e in keys if s == sur and y and y.startswith(year))
205
+ continue
206
+ same_author = [y for (s, y), _ in keys if s == sur and y]
207
+ typo = [s for (s, y), _ in keys if s and y == year and close(s, sur)]
208
+ if same_author:
209
+ fails.append(f"line {n}: cites “{text}”, but the reference list has {sur.title()} {', '.join(sorted(set(same_author)))}")
210
+ used.update(id(e) for (s, y), e in keys if s == sur)
211
+ elif typo:
212
+ fails.append(f"line {n}: cites “{text}”, but the reference list spells it {typo[0].title()} ({year})")
213
+ used.update(id(e) for (s, y), e in keys if s == typo[0] and y == year)
214
+ else:
215
+ fails.append(f"line {n}: cites “{text}”, but there is no {sur.title()} {year} in the reference list")
216
+ for (s, y), e in keys:
217
+ if id(e) not in used and s:
218
+ warns.append(f"reference on line {e['line']} ({s.title()} {y or 'no year'}) is never cited")
219
+ stats["cited"] = len(used)
220
+ elif any(NUM_CITE.search(l) for _, l in body_text):
221
+ fails.append("the text cites references, but there is no reference list (a heading such as References or Bibliography)")
222
+ # the same work twice
223
+ seen_doi, seen_title, dup_pairs = {}, {}, set()
224
+ for e in entries:
225
+ for m in DOI.finditer(e["text"]):
226
+ d = m.group(1).rstrip(".,;)").lower()
227
+ if not GOOD_DOI.match(d):
228
+ warns.append(f"reference on line {e['line']}: “{m.group(0)[:60]}” isn't a well-formed DOI")
229
+ elif d in seen_doi:
230
+ fails.append(f"references on lines {seen_doi[d]} and {e['line']} are the same work (DOI {d})")
231
+ dup_pairs.add((seen_doi[d], e["line"]))
232
+ seen_doi.setdefault(d, e["line"])
233
+ t = re.search(r"(?:\)\.?|\d{4}[a-z]?\.)\s*[\"“]?([^.\"”]{20,})[\"”]?[.?]", e["text"])
234
+ if not t and e["num"] is not None:
235
+ # Vancouver and IEEE put the title after the authors: "Smith J, Lee K. Title of the work. Journal."
236
+ t = re.search(r"^[^.]*?(?:et al|[A-Z]{1,3})\.\s+[\"“]?([^.\"”]{20,})[\"”]?[.?]", e["text"])
237
+ if t:
238
+ key = re.sub(r"[^a-z0-9]+", " ", fold(t.group(1))).strip()
239
+ if key in seen_title and (seen_title[key], e["line"]) not in dup_pairs:
240
+ fails.append(f"references on lines {seen_title[key]} and {e['line']} have the same title")
241
+ seen_title.setdefault(key, e["line"])
242
+ # figures, tables, sections
243
+ captions = {"figure": {}, "table": {}}
244
+ sections = set()
245
+ for n, l in body_text:
246
+ c = CAPTION.match(l)
247
+ if c:
248
+ kind = "table" if c.group(1).lower().startswith("tab") else "figure"
249
+ k = int(c.group(2))
250
+ if k in captions[kind]:
251
+ fails.append(f"line {n}: {kind.title()} {k} has a second caption (also line {captions[kind][k]})")
252
+ captions[kind].setdefault(k, n)
253
+ h = SECTION_HEAD.match(l)
254
+ if h:
255
+ sections.add(h.group(1))
256
+ refs = {"figure": {}, "table": {}}
257
+ for n, l in body_text:
258
+ if CAPTION.match(l):
259
+ l = CAPTION.sub("", l, count=1)
260
+ for m in FIG_REF.finditer(l):
261
+ word = m.group(1).lower()
262
+ kind = "table" if word.startswith("tab") else "section" if word.startswith(("sec", "§")) else "figure"
263
+ nums = [m.group(2)] + ([m.group(3)] if m.group(3) else [])
264
+ if kind != "section" and m.group(3) and re.search(r"–|-|to", m.group(0)):
265
+ try:
266
+ nums = [str(x) for x in range(int(m.group(2)), int(m.group(3)) + 1)]
267
+ except ValueError:
268
+ pass
269
+ for k in nums:
270
+ if kind == "section":
271
+ if sections and k not in sections and not any(s.startswith(k + ".") for s in sections):
272
+ fails.append(f"line {n}: refers to Section {k}, but there is no section {k}")
273
+ continue
274
+ if "." in k:
275
+ continue
276
+ refs[kind].setdefault(int(k), n)
277
+ if captions[kind] and int(k) not in captions[kind]:
278
+ fails.append(f"line {n}: refers to {kind.title()} {k}, but there is no {kind.title()} {k}")
279
+ for kind in ("figure", "table"):
280
+ nums = sorted(captions[kind])
281
+ for k in nums:
282
+ if k not in refs[kind]:
283
+ warns.append(f"{kind.title()} {k} (line {captions[kind][k]}) is never referred to in the text")
284
+ for a, b in zip(nums, nums[1:]):
285
+ if b != a + 1:
286
+ warns.append(f"{kind}s jump from {a} to {b}")
287
+ if nums and nums[0] != 1:
288
+ warns.append(f"the first {kind} is numbered {nums[0]}")
289
+ first_mention = [k for k, _ in sorted(refs[kind].items(), key=lambda x: x[1]) if k in captions[kind]]
290
+ if first_mention != sorted(first_mention):
291
+ warns.append(f"{kind}s are first mentioned out of order ({', '.join(map(str, first_mention))})")
292
+ stats[kind + "s"] = len(nums)
293
+ # footnotes
294
+ uses, defs = {}, {}
295
+ for n, l in enumerate(lines, 1):
296
+ d = FOOT_DEF.match(l)
297
+ if d:
298
+ defs.setdefault(d.group(1), n)
299
+ for u in FOOT_USE.finditer(l):
300
+ uses.setdefault(u.group(1), n)
301
+ for k, n in uses.items():
302
+ if k not in defs:
303
+ fails.append(f"line {n}: footnote [^{k}] is used but never defined")
304
+ for k, n in defs.items():
305
+ if k not in uses:
306
+ warns.append(f"footnote [^{k}] (line {n}) is defined but never used")
307
+ # abstract
308
+ abstract_words = None
309
+ for i, l in enumerate(lines):
310
+ m = ABSTRACT.match(l)
311
+ if m:
312
+ words, j = len(re.findall(r"\b[\w'’-]+\b", m.group(1))), i + 1
313
+ while j < len(lines) and not re.match(r"^\s*(#{1,6}\s|\*\*[^*]+\*\*\s*$|(keywords|key words)\s*:)", lines[j], re.I):
314
+ words += len(re.findall(r"\b[\w'’-]+\b", lines[j]))
315
+ j += 1
316
+ abstract_words = words
317
+ break
318
+ if abstract_limit and abstract_words is not None and abstract_words > abstract_limit:
319
+ fails.append(f"the abstract is {abstract_words} words, over the limit of {abstract_limit}")
320
+ stats["abstract_words"] = abstract_words
321
+ return {"fails": fails, "warns": warns, "stats": stats}
322
+
323
+
324
+ def report(r):
325
+ s = r["stats"]
326
+ L = ["# Manuscript check", "",
327
+ f"Citation style: {s['style'] or 'none found'}; {s['references']} references"
328
+ + (f", {s.get('cited', 0)} cited" if s["style"] else "") + f"; {s.get('figures', 0)} figures; {s.get('tables', 0)} tables"
329
+ + (f"; abstract {s['abstract_words']} words" if s.get("abstract_words") is not None else "") + ".", ""]
330
+ if s.get("renumber"):
331
+ L += ["Renumbering for citation order (old → new): " + ", ".join(f"[{o}] → [{n}]" for o, n in sorted(s["renumber"].items())), ""]
332
+ if not r["fails"]:
333
+ L.append("PASS every citation has a reference, and every figure, table and section referred to exists.")
334
+ L += [f"FAIL {f}" for f in r["fails"]] + [f"WARN {w}" for w in r["warns"]]
335
+ return "\n".join(L) + "\n"
336
+
337
+
338
+ def main(argv=None):
339
+ ap = argparse.ArgumentParser(description="Check a manuscript's citations and cross-references.")
340
+ ap.add_argument("manuscript")
341
+ ap.add_argument("--abstract-limit", type=int)
342
+ ap.add_argument("--json", action="store_true")
343
+ args = ap.parse_args(argv)
344
+ try:
345
+ lines = read_lines(args.manuscript)
346
+ except (OSError, UnicodeDecodeError, zipfile.BadZipFile, KeyError, ElementTree.ParseError) as e:
347
+ print(f"FAIL cannot read {args.manuscript}: {e}")
348
+ return 2
349
+ r = check(lines, args.abstract_limit)
350
+ print(json.dumps(r, indent=2) if args.json else report(r), end="" if not args.json else "\n")
351
+ return 1 if r["fails"] else 0
352
+
353
+
354
+ if __name__ == "__main__":
355
+ sys.exit(main())