While investigating the failing Lint job on #5639 (Qiskit Fermions 0.2.0), I traced the ~90 check:internal-links errors to three causes. One is already fixed on main (see the note at the end). The other two are bugs in the API conversion pipeline.
Both are verified empirically against the generated MDX on the PR branch and against real docutils output; details and validation below.
Bug 1 — pkgOutputDir.replace("qiskit", "qiskit-c") corrupts any package name starting with qiskit
File: scripts/js/lib/api/updateLinks.ts:126 (in normalizeUrl, the cdoc/ branch)
What happens
When a page links to the C API via a relative cdoc/… path, the output directory is derived by string substitution:
return `${kwargs.pkgOutputDir.replace("qiskit", "qiskit-c")}/${pageAndHash}`;
String.prototype.replace with a string pattern replaces the first occurrence, so for any package whose name merely starts with qiskit, the insertion lands in the middle of the name:
pkgOutputDir |
result |
correct? |
/docs/api/qiskit |
/docs/api/qiskit-c |
yes |
/docs/api/qiskit-fermions |
/docs/api/qiskit-c-fermions |
no — should be /docs/api/qiskit-fermions-c |
This accounts for roughly 45 of the failures on #5639, e.g.:
❌ Could not find link '/docs/api/qiskit-c-fermions/qf-ferm-op#qf_ferm_op_set_groups'. Appears in:
docs/api/qiskit-fermions/release-notes.mdx ❓ Did you mean '/docs/api/qiskit-fermions-c/qf-ferm-op#qf_ferm_op_set_groups'?
The checker's own "did you mean" suggestion is the correct target in every case.
Suggested fix
Both C packages already follow one convention — the C package is named {artifactPackageName}-c (qiskit → qiskit-c, qiskit-fermions → qiskit-fermions-c) — so the sibling name can be built by appending a suffix to the package name rather than by substring surgery on the path.
- if (url.startsWith(`${C_API_BASE_PATH}/`)) {
- const [pageWithHtml, hash] = removePrefix(url, `${C_API_BASE_PATH}/`).split(
- "#",
- );
- const page = removeSuffix(pageWithHtml, ".html");
- // Strip the Sphinx C domain prefix (e.g. `c.qk_circuit_new` → `qk_circuit_new`)
- const normalizedHash = hash ? removePrefix(hash, "c.") : undefined;
- const pageAndHash = normalizedHash ? `${page}#${normalizedHash}` : page;
- return `${kwargs.pkgOutputDir.replace("qiskit", "qiskit-c")}/${pageAndHash}`;
- }
+ if (url.startsWith(`${C_API_BASE_PATH}/`)) {
+ const [pageWithHtml, hash] = removePrefix(url, `${C_API_BASE_PATH}/`).split(
+ "#",
+ );
+ const page = removeSuffix(pageWithHtml, ".html");
+ // Strip the Sphinx C domain prefix (e.g. `c.qk_circuit_new` → `qk_circuit_new`)
+ const normalizedHash = hash ? removePrefix(hash, "c.") : undefined;
+ const pageAndHash = normalizedHash ? `${page}#${normalizedHash}` : page;
+ // The C API package is always named `{pythonPkgName}-c` (`qiskit` → `qiskit-c`,
+ // `qiskit-fermions` → `qiskit-fermions-c`). Replace the final path segment rather
+ // than substituting inside it: a plain `.replace("qiskit", "qiskit-c")` rewrites the
+ // *first* match, turning `qiskit-fermions` into `qiskit-c-fermions`.
+ const cApiOutputDir = path.posix.join(
+ path.posix.dirname(kwargs.pkgOutputDir),
+ `${kwargs.pkgName}-c`,
+ );
+ return `${cApiOutputDir}/${pageAndHash}`;
+ }
path is already imported in this module (import path from "node:path", line 13), and kwargs.pkgName is already part of the normalizeUrl signature, so no plumbing changes are needed.
I verified the replacement produces /docs/api/qiskit-c for qiskit (unchanged behaviour) and /docs/api/qiskit-fermions-c for qiskit-fermions.
If you would rather keep the name in one place, an equivalent option is a Pkg accessor — e.g. cApiName() returning `${this.artifactPackageName}-c` — and passing it through kwargs. That also removes the implicit coupling between Pkg.fromArgs's hardcoded "qiskit-c" / "qiskit-fermions-c" entries and this call site.
Suggested regression test
In scripts/js/lib/api/updateLinks.test.ts, alongside the existing qiskit-c cases:
test("normalizeUrl() rewrites cdoc/ links for a non-qiskit package", () => {
expect(
normalizeUrl(
"cdoc/qf-ferm-op.html#c.qf_ferm_op_set_groups",
{},
new Set(),
{
kebabCaseAndShorten: true,
pkgName: "qiskit-fermions",
pkgOutputDir: "/docs/api/qiskit-fermions",
},
),
).toEqual("/docs/api/qiskit-fermions-c/qf-ferm-op#qf_ferm_op_set_groups");
});
Bug 2 — handleFootnotes drops the anchors of named citations
File: scripts/js/lib/api/processHtml.ts:406-417
What happens
export function handleFootnotes($: CheerioAPI, $main: Cheerio<any>): void {
$main
.find(".footnote, .footnote-reference, .footnote dt.label")
.toArray()
.forEach((footnote) => {
const $footnote = $(footnote);
const id = $footnote.attr("id");
if (id) {
$footnote.before(`<span id="${id}" class="target"></span>`);
}
});
}
Docutils renders footnotes (.. [1]) and citations (.. [Label]) with different classes. The selector covers only the footnote classes, so a named citation's id is never preserved as a <span id> and the anchor disappears from the MDX.
Running the two forms through docutils (writer_name="html5") gives:
| RST |
element |
id |
class |
matched by current selector? |
.. [JW-maj] |
<div> |
jw-maj |
citation |
no |
[JW-maj]_ |
<a> |
citation-reference-1 |
citation-reference |
no |
.. [1] |
<aside> |
footnote-1 |
footnote brackets |
yes |
[1]_ |
<a> |
footnote-reference-1 |
brackets |
no (but the definition is caught) |
The result on #5639 is 12 errors, all on one page, where both ends of each citation dangle — docs/api/qiskit-fermions-c/qf-mappers-library.mdx:
❌ Could not find link '#jw-maj'. ❌ Could not find link '#id6'.
❌ Could not find link '#jw-ferm'. ❌ Could not find link '#id3'.
…also #jw-edge/#id11, #gandon-edge/#id13, #jw-transfer/#id17, #gandon-transfer/#id19
In the generated MDX the reference points at #jw-maj (line 164) and the definition's back-link at #id6 (line 179), and neither id is emitted anywhere on the page.
The source RST is well-formed and each label is unique — the reference and its definition live in the same docstring (crates/cext/src/mappers/library/jordan_wigner.rs). Notably, our Python pages are unaffected because they use numeric [1]_ citations, which render as footnote and so survive; the named form is what breaks.
Suggested fix
export function handleFootnotes($: CheerioAPI, $main: Cheerio<any>): void {
+ // Docutils renders footnotes (`.. [1]`) and citations (`.. [Label]`) with different
+ // classes: `footnote`/`footnote-reference` versus `citation`/`citation-reference`.
+ // Both carry the `id` that in-page links target, so both must be preserved.
$main
- .find(".footnote, .footnote-reference, .footnote dt.label")
+ .find(
+ ".footnote, .footnote-reference, .footnote dt.label, " +
+ ".citation, .citation-reference, .citation dt.label",
+ )
.toArray()
.forEach((footnote) => {
const $footnote = $(footnote);
const id = $footnote.attr("id");
if (id) {
$footnote.before(`<span id="${id}" class="target"></span>`);
}
});
}
Against the docutils output above, the current selector preserves only footnote-1, while the proposed one additionally preserves jw-maj and citation-reference-1 — the two ids whose absence produces the errors.
Suggested regression test
In scripts/js/lib/api/processHtml.test.ts:
test("handleFootnotes() preserves ids of named citations", async () => {
const html = `
<div class="citation" id="jw-maj">
<dt class="label">JW-maj</dt><dd>Jordan and Wigner.</dd>
</div>`;
// Expect a <span id="jw-maj" class="target"> to precede the citation,
// matching the existing behaviour for `.. [1]` footnotes.
});
Not a bug — for completeness
The third group of failures on #5639 (~20 links of the form guides/grouping#grouping-explanation, resolving against docs/api/qiskit-fermions/ instead of docs/addons/qiskit-fermions/guides/) is already fixed on main by the guides/ rewrite rule in pipelineStages.ts:190-199, added in 8ab2896 ("test the qiskit-noise-learning subsite", #5603) on 2026-09-11 19:10 UTC. The artifact on that PR was generated shortly before that commit, so those links should disappear on regeneration — I confirmed the rule's regex rewrites our exact link correctly.
One small caveat on that rule, since it may matter for other subsites: it keys on the literal path segment guides/, so an upstream project whose explanatory pages live in a differently-named directory will still leak relative links.
AI Disclaimer
This summary was written with the assistance of Claude Opus 5.
While investigating the failing
Lintjob on #5639 (Qiskit Fermions 0.2.0), I traced the ~90check:internal-linkserrors to three causes. One is already fixed onmain(see the note at the end). The other two are bugs in the API conversion pipeline.Both are verified empirically against the generated MDX on the PR branch and against real
docutilsoutput; details and validation below.Bug 1 —
pkgOutputDir.replace("qiskit", "qiskit-c")corrupts any package name starting withqiskitFile:
scripts/js/lib/api/updateLinks.ts:126(innormalizeUrl, thecdoc/branch)What happens
When a page links to the C API via a relative
cdoc/…path, the output directory is derived by string substitution:String.prototype.replacewith a string pattern replaces the first occurrence, so for any package whose name merely starts withqiskit, the insertion lands in the middle of the name:pkgOutputDir/docs/api/qiskit/docs/api/qiskit-c/docs/api/qiskit-fermions/docs/api/qiskit-c-fermions/docs/api/qiskit-fermions-cThis accounts for roughly 45 of the failures on #5639, e.g.:
The checker's own "did you mean" suggestion is the correct target in every case.
Suggested fix
Both C packages already follow one convention — the C package is named
{artifactPackageName}-c(qiskit→qiskit-c,qiskit-fermions→qiskit-fermions-c) — so the sibling name can be built by appending a suffix to the package name rather than by substring surgery on the path.pathis already imported in this module (import path from "node:path", line 13), andkwargs.pkgNameis already part of thenormalizeUrlsignature, so no plumbing changes are needed.I verified the replacement produces
/docs/api/qiskit-cforqiskit(unchanged behaviour) and/docs/api/qiskit-fermions-cforqiskit-fermions.If you would rather keep the name in one place, an equivalent option is a
Pkgaccessor — e.g.cApiName()returning`${this.artifactPackageName}-c`— and passing it throughkwargs. That also removes the implicit coupling betweenPkg.fromArgs's hardcoded"qiskit-c"/"qiskit-fermions-c"entries and this call site.Suggested regression test
In
scripts/js/lib/api/updateLinks.test.ts, alongside the existingqiskit-ccases:Bug 2 —
handleFootnotesdrops the anchors of named citationsFile:
scripts/js/lib/api/processHtml.ts:406-417What happens
Docutils renders footnotes (
.. [1]) and citations (.. [Label]) with different classes. The selector covers only the footnote classes, so a named citation'sidis never preserved as a<span id>and the anchor disappears from the MDX.Running the two forms through
docutils(writer_name="html5") gives:idclass.. [JW-maj]<div>jw-majcitation[JW-maj]_<a>citation-reference-1citation-reference.. [1]<aside>footnote-1footnote brackets[1]_<a>footnote-reference-1bracketsThe result on #5639 is 12 errors, all on one page, where both ends of each citation dangle —
docs/api/qiskit-fermions-c/qf-mappers-library.mdx:In the generated MDX the reference points at
#jw-maj(line 164) and the definition's back-link at#id6(line 179), and neither id is emitted anywhere on the page.The source RST is well-formed and each label is unique — the reference and its definition live in the same docstring (
crates/cext/src/mappers/library/jordan_wigner.rs). Notably, our Python pages are unaffected because they use numeric[1]_citations, which render asfootnoteand so survive; the named form is what breaks.Suggested fix
export function handleFootnotes($: CheerioAPI, $main: Cheerio<any>): void { + // Docutils renders footnotes (`.. [1]`) and citations (`.. [Label]`) with different + // classes: `footnote`/`footnote-reference` versus `citation`/`citation-reference`. + // Both carry the `id` that in-page links target, so both must be preserved. $main - .find(".footnote, .footnote-reference, .footnote dt.label") + .find( + ".footnote, .footnote-reference, .footnote dt.label, " + + ".citation, .citation-reference, .citation dt.label", + ) .toArray() .forEach((footnote) => { const $footnote = $(footnote); const id = $footnote.attr("id"); if (id) { $footnote.before(`<span id="${id}" class="target"></span>`); } }); }Against the
docutilsoutput above, the current selector preserves onlyfootnote-1, while the proposed one additionally preservesjw-majandcitation-reference-1— the two ids whose absence produces the errors.Suggested regression test
In
scripts/js/lib/api/processHtml.test.ts:Not a bug — for completeness
The third group of failures on #5639 (~20 links of the form
guides/grouping#grouping-explanation, resolving againstdocs/api/qiskit-fermions/instead ofdocs/addons/qiskit-fermions/guides/) is already fixed onmainby theguides/rewrite rule inpipelineStages.ts:190-199, added in 8ab2896 ("test the qiskit-noise-learning subsite", #5603) on 2026-09-11 19:10 UTC. The artifact on that PR was generated shortly before that commit, so those links should disappear on regeneration — I confirmed the rule's regex rewrites our exact link correctly.One small caveat on that rule, since it may matter for other subsites: it keys on the literal path segment
guides/, so an upstream project whose explanatory pages live in a differently-named directory will still leak relative links.AI Disclaimer
This summary was written with the assistance of Claude Opus 5.