@galaxy-foundry/audit-citations
v0.2.0
Published
Deterministic scholarly-citation extraction, resolution, evidence capture, and audit reporting.
Maintainers
Readme
@galaxy-foundry/audit-citations
Extract scholarly citations from text artifacts, resolve them against public metadata providers, and produce reproducible citation-integrity findings with exact source spans.
npm install @galaxy-foundry/audit-citations zod@^4Experimental contract: this package is an intentional N=1 design extraction from the Bio Topology Foundry reference-audit spike. Its
0.xschemas may change when a structurally different Foundry or skill repository becomes the second adopter. It does not establish a generic audit substrate, and S2/S3 checkers must not depend on citation-shaped types.
The boundary
The library owns citation mechanics:
- strict schemas for scans, normalized evidence snapshots, adjudications, and audit runs;
- DOI, arXiv, PMID, and PMCID extraction and normalization;
- explicit Markdown bibliography extraction;
- provider interfaces plus Crossref, DOI content negotiation, OpenAlex/arXiv, Europe PMC, Semantic Scholar, DBLP, and allowlisted citation-page resolvers;
- title, year, author-list, first-author, and cross-identifier comparison, separated into identity errors and publication-drift warnings;
- deterministic offline replay from normalized evidence;
- stale-safe manual adjudication; and
- machine-readable findings and a Markdown report.
The consuming repository owns its source paths, artifact kinds, enabled citation-page hosts, reference-heading vocabulary, user-agent identity, output locations, and release policy.
The core accepts explicit SourceDocument values. Only the CLI discovers files, so a caller can
use the library without Git or a particular directory layout.
Supported extraction forms
The experimental extractor intentionally recognizes a narrow, documented grammar:
- DOI strings beginning with
10.and DOI URLs, percent-decoded, because a DOI containing parentheses has to be encoded to survive a Markdown link; - modern arXiv identifiers such as
2401.00001, optionally versioned, when introduced byarXiv:or anarxiv.org/abs|pdf/URL; - five-to-nine digit PMID values introduced by
PMID:or a PubMed URL; PMCfollowed by five-to-nine digits;- allowlisted scholarly-page URLs whose paths end in
.html; and - single-line, numbered Markdown bibliography entries under a configured reference heading, with
either a quoted title or the supported
authors. title. ... yearshape; the author blob is split on,,;,&, andand, rejoining an initials-only fragment with the name before it.
Any other non-blank line under a reference heading is counted as unextracted and reported as missing coverage rather than passed over silently.
Old-style arXiv identifiers, shorter historical PMIDs, bullet-list or wrapped bibliography
entries, and arbitrary scholarly URLs are not currently extracted. Author–year prose such as
Smith et al. (2024) is counted as a diagnostic only; it never becomes a citation candidate.
Typed note frontmatter
A source note records the work it summarizes in fields, not sentences: one prose field carries
the bibliographic record while separate typed fields carry the identifiers. Read line by line the
two halves never meet. The identifier lines describe nothing, so they can report only that the
identifier exists; the description line names no identifier in a form the prose grammar finds. A
wrong DOI four lines below the title that would expose it comes back resolved.
Declaring the fields makes one frontmatter block one citation:
{
"noteFrontmatter": {
"descriptionField": "citation",
"identifierFields": ["doi", "arxiv", "pmid", "pmcid"]
}
}A field's name is the identifier's kind. That is not a shortcut — a bare 1912.04135 has no
prefix for a grammar to recognize, and an arXiv id and a PMID are both just digits, so a kind
inferred from shape is a kind guessed wrong eventually. identifierFields is a closed set for the
same reason: an unrecognized name would be read as ordinary text and its identifier silently never
seen.
Names are matched as leaf keys at any depth, so a nested source_ids: { doi: ... } needs no path
and no YAML parser. Values are read as plain scalars, quoted or not. Identifiers written as prose
or as URLs elsewhere in the block are collected too, and every identifier in the block is
attributed to the one work the block describes — which is what lets the cross-evidence comparison
check that a note's DOI and its arXiv id name the same paper.
The option is opt-in. Without it, frontmatter is ordinary text and is extracted exactly as before.
Resolved is not verified
Identity comparison runs against a described title, so a candidate that names an identifier but
describes no work resolves and can produce no mismatch. It is unfalsifiable, not correct. Those
findings carry verifiable: false, the run summary counts them as resolvedUnverified, and the
report states the split rather than folding them into one headline that reads as a fully verified
corpus.
Data flow
Evidence acquisition and evaluation remain separate. A timeout is unavailable; a completed
lookup with no record is unresolved; a resolved record describing another work is
resolved-mismatched.
Every request carries its own deadline, covering the response body as well as the connection, so a
provider that answers and then stalls mid-stream cannot hang a run. It defaults to 15 seconds and is
set with requestTimeoutMs. The deadline is enforced by the resolver rather than delegated to the
transport, because a caller-supplied fetch may ignore the abort signal it is given.
A DOI Crossref does not register — a deposited dataset or software release, for instance — is
retried through DOI content negotiation, which resolves it through whichever agency registered it.
Only a DOI no agency recognizes becomes unresolved.
A title without an identifier is searched across Crossref, OpenAlex, Semantic Scholar, and DBLP in
turn, because search coverage varies by venue, year, and publication type. If any of those indexes
could not be reached and none resolved the title, the result is unavailable rather than
unresolved: the index that would have recognized it may be the one that failed.
Comparison results are typed mismatches carrying a severity. An error disputes the identity of
the cited work; a warning records ordinary publication drift, such as a preprint that later
acquired a journal year. Only errors make a citation a finding, so drift stays visible without
demanding review beside a wrong author.
The report states extraction coverage — how many reference-section lines produced a candidate — next to the verdict counts, because a resolution rate describes only the citations the extractor could read. It also rolls findings up per artifact, since several flagged citations in one document is a stronger signal than the same number spread across a corpus.
Library usage
import {
ScholarlyResolver,
buildCitationAuditRun,
collectEvidence,
extractCitations,
renderCitationAuditMarkdown,
} from '@galaxy-foundry/audit-citations';
const scan = extractCitations(
[
{
path: 'research/paper.md',
artifactKind: 'research-note',
text: markdown,
},
],
{ scholarlyPageHosts: ['proceedings.mlr.press'] },
);
const collected = await collectEvidence(scan.candidates, cachedEvidence, {
refresh: true,
resolver: new ScholarlyResolver({
userAgent: 'example-audit/1.0 (https://example.org/contact)',
scholarlyPageHosts: ['proceedings.mlr.press'],
}),
});
const run = buildCitationAuditRun(scan, collected.snapshot, { adjudications });
const markdownReport = renderCitationAuditMarkdown(run, collected.snapshot);Omit refresh and the resolver to replay from the evidence snapshot. A missing cached query becomes
unavailable; it is never silently interpreted as a nonexistent citation.
collectEvidence returns both snapshot and cache. Persist cache, which retains evidence for
queries no current candidate references; pass snapshot, which holds exactly the evidence the
candidates reference, to buildCitationAuditRun and renderCitationAuditMarkdown so a run's
identity does not depend on the cache's history.
The main entry point never touches the filesystem. The glob and git adapter that turns a config
file into SourceDocument[] lives behind a subpath, so importing the library does not pull in
fast-glob or node:child_process:
import {
loadCitationAuditConfig,
loadConfiguredDocuments,
} from '@galaxy-foundry/audit-citations/config';CLI
The npm package publishes foundry-audit-citations as a standalone Node.js CLI. It does not require
Astro, a Foundry repository, or any other @galaxy-foundry package. Git is optional and is consulted
only when the configuration enables trackedOnly.
Create audit-citations.config.json in the consuming repository:
{
"schemaVersion": 1,
"trackedOnly": true,
"sources": [
{
"include": ["content/papers/*.md"],
"artifactKind": "paper-note"
},
{
"include": ["content/packages/*.md"],
"artifactKind": "package-note"
}
],
"referenceHeadingTerms": ["references", "source note"],
"scholarlyPageHosts": ["proceedings.mlr.press", "proceedings.neurips.cc"],
"userAgent": "my-foundry-citation-audit/1.0 (https://example.org/contact)",
"requestTimeoutMs": 15000
}Install the package in the repository as shown above, or run the scan once without adding a dependency:
npx --yes \
--package=@galaxy-foundry/audit-citations \
--package=zod@^4 \
foundry-audit-citations scan \
--config audit-citations.config.json \
--output build/citation-scan.jsoninclude and exclude are always matched as globs. trackedOnly intersects that match with
git ls-files, so it only ever narrows the corpus — it never reinterprets the patterns as git
pathspecs, which would widen them (a pathspec * crosses directory separators; a glob * does
not).
Extract without network access:
npx foundry-audit-citations scan \
--config audit-citations.config.json \
--output build/citation-scan.jsonRefresh evidence and audit:
npx foundry-audit-citations audit \
--config audit-citations.config.json \
--refresh \
--evidence audit/provider-evidence.json \
--output build/citation-audit.json \
--markdown build/citation-audit.mdOmit --refresh for offline replay. Use --candidate-source for a historical scan and
--adjudications for a separately persisted manual-review document. CLI outputs, including each
incremental evidence-cache checkpoint during refresh, are written through a same-directory
temporary file and atomic rename so interruption cannot expose truncated JSON.
Wire documents
All persisted documents are strict, versioned JSON contracts. Unknown fields are rejected.
CitationScancontains normalized candidates and extractor diagnostics.CitationEvidenceSnapshotcontains each normalized provider query once.CitationAdjudicationsbinds review decisions to both candidate ID and exact source digest.CitationAuditRuncontains candidates and lightweight findings that reference evidence IDs; it does not duplicate provider metadata inside every finding.
A candidate ID is stable across unrelated line movement because it derives from artifact path,
source text, and same-text occurrence ordinal. sourceDigest detects a changed citation before an
old adjudication can be reused. Corpus provenance records the candidate digest separately from Git
headRevision and workingTreeDirty.
See the rendered architecture and schema notes for entity relationships, ownership, and compatibility rules.
What this package does not do
- It does not decide whether the cited source supports the surrounding claim.
- It does not validate tools, APIs, command flags, or numeric thresholds.
- It does not define a repository-wide release gate or composite score.
- It does not contain Foundry-specific source paths or artifact kinds.
- It does not escalate an unresolved citation to a language model or a web search. That tier cannot be replayed, so it belongs to a consuming repository rather than to this package.
- It does not create
audit-baseoraudit-schemas; those boundaries must be earned by another checker rather than inferred from citation terminology.
License
MIT
