Sphinx Cross-References May Dangle After genomic_scores Module-to-Package Promotion
After PR #902 converted genomic_scores from a single module into a package, sphinx-apidoc now registers symbols under deeper fully-qualified names (e.g., gain.genomic_resources.genomic_scores.records.clip_span instead of gain.genomic_resources.genomic_scores.clip_span).
At a Glance
After PR #902 converted genomic_scores from a single module into a package, sphinx-apidoc now registers symbols under deeper fully-qualified names (e.g., gain.genomic_resources.genomic_scores.records.clip_span instead of gain.genomic_resources.genomic_scores.clip_span).
Summary
After PR #902 converted genomic_scores from a single module into a package, sphinx-apidoc now registers symbols under deeper fully-qualified names (e.g., gain.genomic_resources.genomic_scores.records.clip_span instead of gain.genomic_resources.genomic_scores.clip_span). Approximately fourteen unqualified :func: and :meth: cross-references in base.py, allele.py, and fragment.py now cross a module boundary and may no longer resolve through Sphinx's py-domain fuzzy fallback. Because nitpicky mode and -W are both absent from conf.py, broken refs silently render as plain literals rather than hyperlinks, making the regression invisible in CI.
Root-Cause Analysis
Confirmed Evidence
genomic_scoreswas promoted from a flat module to a package (PR #902 / #1032).docs/build_docs.sh:32runssphinx-apidoc -o docs/source/development/gain/modules/ core/gain, which emits one.rstpage per submodule of the new package.- As a result,
clip_spanis indexed by the py-domain undergain.genomic_resources.genomic_scores.records.clip_span, not the priorgain.genomic_resources.genomic_scores.clip_span. - Five fully-qualified xrefs were corrected in #1032; approximately fourteen unqualified refs in
base.py,allele.py, andfragment.pywere not. - The Sphinx build has not been executed post-split, so the actual resolution state is unconfirmed.
Reasonable Inference
Sphinx's py-domain resolves an unqualified ref (e.g., ` :func:clip_span ) by prepending the current module's name and walking up the dotted hierarchy. When the symbol and the referring source file lived in the same flat module, this succeeded trivially. After the package split the symbol lives in a sibling submodule (records), which is outside the hierarchy that Sphinx's fallback traversal visits from base, allele, or fragment`. This means unqualified refs that previously resolved will likely fail silently and render as monospace literals.
Assumptions / Unknowns
- It is not confirmed which specific refs actually dangle versus which Sphinx resolves through its broader inventory search; this depends on the Sphinx version in use and the exact
intersphinx/autodocconfiguration. - Some refs may still resolve if Sphinx performs a global symbol scan rather than a strictly hierarchical one; this must be verified empirically.
Why CI Does Not Catch This
conf.pysets neithernitpick_ignoretargets nor the globalnitpicky = Trueflag.- The docs build is not invoked with
-W(warnings-as-errors). - The Jenkins/CI
when { changeset 'docs/**' }condition means the docs stage is skipped entirely unless adocs/file is touched.
Resolution Steps
- Build the docs locally first — do not rewrite refs on suspicion. Identify which refs actually dangle before making any changes.
- Review the HTML output for monospace literals where hyperlinks are expected, focusing on
base.py,allele.py, andfragment.pyAPI pages. - Capture Sphinx warnings by running the build with
-W --keep-going(see CLI Commands) to get an exhaustive list of unresolved refs without aborting on the first failure. - For each confirmed dangling ref, replace the unqualified form with its new fully-qualified name, e.g.:
- `
:func:clip_span→:func:gain.genomic_resources.genomic_scores.records.clip_span` - `
:meth:GenomicScore.some_method→:meth:gain.genomic_resources.genomic_scores.base.GenomicScore.some_method`
- Optionally, use a tilde-prefixed short display form to keep rendered text readable:
- `
:func:~gain.genomic_resources.genomic_scores.records.clip_spanrenders as justclip_span` but links correctly.
- Re-run the build with
-Wafter edits to confirm zero unresolved-reference warnings remain. - Enable
nitpicky = Trueinconf.py(see Configuration Snippets) so future regressions of this class are caught at build time. - Add
-Wtobuild_docs.shso warnings become build failures in CI.
CLI Commands
Build with warnings surfaced (non-destructive diagnostic):
# From the repository root
cd docs
sphinx-build -b html source build/html -W --keep-going 2>&1 | tee /tmp/sphinx-build.logGrep the log for unresolved cross-references specifically:
grep -E "WARNING.*undefined label|WARNING.*py:.*not found|WARNING.*reference target" /tmp/sphinx-build.logRe-run sphinx-apidoc to inspect the generated module index (informational only):
sphinx-apidoc --dry-run -o docs/source/development/gain/modules/ core/gainSearch source files for unqualified refs that cross into genomic_scores:
grep -rn ':func:`clip_span\|:func:`clip_to_region\|:meth:`GenomicScore\.' \
core/gain/genomic_resources/genomic_scores/Configuration Snippets
Enable nitpicky mode in docs/source/conf.py to surface broken refs as errors during the build:
# docs/source/conf.py
# Treat all unresolved cross-references as errors
nitpicky = True
# Suppress known acceptable exceptions if needed
nitpick_ignore = [
# ("py:class", "some.third.party.Class"),
]Add -W to docs/build_docs.sh so CI fails on any new dangling ref:
# docs/build_docs.sh (line 32 area — add -W --keep-going)
sphinx-apidoc -o docs/source/development/gain/modules/ core/gain
sphinx-build -b html docs/source docs/build/html -W --keep-goingExample corrected docstring refs (Python source files):
# Before (unqualified — may dangle after package split)
"""
See :func:`clip_span` and :func:`clip_to_region` for boundary helpers.
"""
# After (fully qualified — always resolves)
"""
See :func:`~gain.genomic_resources.genomic_scores.records.clip_span` and
:func:`~gain.genomic_resources.genomic_scores.records.clip_to_region`
for boundary helpers.
"""Verification
Confirm zero unresolved-reference warnings:
sphinx-build -b html docs/source docs/build/html -W --keep-going 2>&1 | \
grep -c "WARNING"
# Expected: 0Spot-check generated HTML for correct hyperlinks:
# Verify that clip_span renders as a link, not a literal
grep -A2 "clip_span" docs/build/html/development/gain/modules/gain.genomic_resources.genomic_scores.base.html | \
grep "<a href"
# Expected: one or more <a href="..."> linesRollback indicator: If the build log still contains lines matching WARNING: py:func reference target not found: clip_span after edits, the unqualified refs have not been fully corrected. Re-examine the grep output from the diagnostic step.
Prevention
- Enable
nitpicky = Truepermanently inconf.py(see above). This is the single most effective guard against silent ref breakage. - Add
-Wto the Sphinx build command inbuild_docs.shso any unresolved ref fails the docs CI stage immediately. - Relax the CI changeset filter. Consider also triggering the docs stage when
core/gain/*/.pychanges, since docstring edits and module restructuring affect rendered docs even without touchingdocs/. - Adopt a linting step for docstrings. Tools such as
pydocstyleordoc8can catch structural issues before a full Sphinx build. - Document the xref convention in a contributing guide: require fully-qualified or tilde-prefixed refs for any symbol that lives outside the current submodule, so future contributors do not introduce new unqualified cross-module refs.
- Add a
sphinx-builddry-run to pre-commit hooks (or as a fast Makefile target) so individual contributors get immediate feedback before pushing.