Documentation scripts misread nested Markdown code blocks
Four scripts that edit or check sipnab's documentation mistook a code block shown inside another code block for the end of the outer block; this note records what each script did, the fix, and how to confirm it.
In short
sipnab’s documentation uses Markdown. Markdown is a plain-text format. In it, a
line of backticks (`) before and after a block of example code marks it
off.
Four scripts edit or check those pages. They decided where a code block ends with a rule that was too simple. So a code block shown inside another code block confused them.
- Three of them could add web links inside example commands.
- One could stop checking commands partway through a block without saying so.
All four now use one shared, correct routine. A test fails if a script goes back to its own version.
What happened
A fenced code block in Markdown starts with a line of three or more backticks
(`) or tildes (~). The rule the scripts missed is in
scripts/lib_markdown.py.
That is the repository’s shared Markdown module. The rule:
- A fence is three or more backticks or tildes, indented at most three spaces.
- It closes only on a run of the same character. The run must be at least as long as the one that opened it.
- A fence that is never closed runs to the end of the document.
So a page that shows how to write a code block uses two fences:
- a longer fence outside,
- a shorter one inside.
How to write a fenced block:
```
see RFC 7989 §5 for Session-ID
```
still inside the outer block
The four-backtick lines open and close the block. The three-backtick lines are part of its content.
Before commit e2045af9 (2026-08-27), four scripts in scripts/ found code
blocks this way:
- They checked whether a line starts with three backticks (or three tildes).
- They switched between “inside code” and “outside code” each time one did.
That check cannot tell three backticks from four.
- So it treated the inner three-backtick line as the end of the block.
- It treated the next one as the start of a new block.
- The scripts handled the lines between the two inner markers as ordinary text.
What each script did with that text:
rfc-links.pyturns RFC citations into links.- It rewrote
see RFC 7989 §5 for Session-IDinside the block assee [RFC 7989 §5](https://www.rfc-editor.org/rfc/rfc7989.html#section-5) for Session-ID. - This is the output of the test in the third step below, run against the old script.
- Text in that form, pasted into a shell, is a syntax error.
bashexits with status 2 and reportssyntax error near unexpected token.
- It rewrote
link-repo-paths.pylinks repository file paths.fix-line-anchors.pyrepairs links to source lines. Both used the same check. They treated the same lines as ordinary text.check-cookbook.pyreads the commands in thebashcode blocks ofdocs/examples.md(the cookbook page).- It runs each one against a test capture, or it confirms that every long
flag it names exists in
sipnab --help. - Its old version ended a block at any line starting with three backticks.
- So commands after an inner marker were never collected and never checked.
- The script did not report that it had skipped them.
- It runs each one against a test capture, or it confirms that every long
flag it names exists in
When the fix landed, docs/examples.md contained no line starting with four
backticks or three tildes. So the scripts had skipped no cookbook command. The
fix commit describes the defect. It does not name a published page that it
changed.
Why it happened
Commit 80869f0a added scripts/lib_markdown.py on 2026-07-28. The module
implements the full fence rule above.
- The four scripts each kept a separate copy of the simpler line check.
- They did not call the shared module.
- No test fed any of them a code block nested inside another. So the difference was not detected.
What changed
Commit e2045af9 made all four scripts use the shared module:
rfc-links.py,link-repo-paths.pyandfix-line-anchors.pyimportfence_mask.check-cookbook.pyimportsfences.
It also added
scripts/tests/test_fenced_content_survives.py.
It checks three things:
- Text inside a four-backtick block that contains a three-backtick block is left byte for byte unchanged by each of the three link-writing scripts.
- The same scripts still rewrite citations, paths and line links outside any
code block.
- Without this check, a script that rewrote nothing at all would pass the first one.
- No script under
scripts/other thanlib_markdown.pycallsstartswithwith a string beginning with three backticks or three tildes.- The test reads each script’s parsed syntax tree rather than its text.
- So a comment or Python documentation string that mentions such a call does not trigger it.
- The first version of the test searched the text. It did trigger on a documentation string.
The file holds eight test functions, which run as 16 cases. CI runs every test
under scripts/tests on Linux (python3 -m pytest scripts/tests -q in
.github/workflows/ci.yml).
What you can check yourself
Run these from the root of a full (not shallow) clone of the sipnab
repository, with pytest installed.
- The second step changes files in your checkout.
- The fourth step puts them back.
The steps:
- Run the test against the current scripts:
python3 -m pytest -q scripts/tests/test_fenced_content_survives.py
................ [100%]
16 passed in 1.00s
- Put back the four scripts as they were before the fix. The command prints nothing.
git checkout e2045af9^ -- scripts/rfc-links.py scripts/link-repo-paths.py scripts/fix-line-anchors.py scripts/check-cookbook.py
- Run the nested-block case for
rfc-links.pyagainst the old script. It fails. The message shows the link written inside the code block:
python3 -m pytest -q scripts/tests/test_fenced_content_survives.py -k 'longer_fence and rfc'
F [100%]
=================================== FAILURES ===================================
___ test_a_longer_fence_is_not_closed_by_a_shorter_run_inside_it[rfc-links] ____
stem = 'rfc-links'
@pytest.mark.parametrize("stem", TRANSFORMERS)
def test_a_longer_fence_is_not_closed_by_a_shorter_run_inside_it(stem):
"""Nothing between the ```` markers may change."""
doc = nested_document(stem)
out = convert(stem, doc)
body = doc.split("````")[1]
> assert body in out, (
f"{stem} rewrote content inside a four-backtick fence -- the inner "
f"three-backtick line was read as closing it. Fenced text must survive "
f"byte-for-byte; use lib_markdown.fence_mask rather than a per-line "
f"toggle.\n\n{out}"
)
E AssertionError: rfc-links rewrote content inside a four-backtick fence -- the inner three-backtick line was read as closing it. Fenced text must survive byte-for-byte; use lib_markdown.fence_mask rather than a per-line toggle.
E
E ````markdown
E How to write a fenced block:
E ```
E see [RFC 7989 §5](https://www.rfc-editor.org/rfc/rfc7989.html#section-5) for Session-ID
E ```
E still inside the outer block: see RFC 7989 §5 for Session-ID
E ````
E
E Prose below.
E
E assert 'markdown\nHow to write a fenced block:\n```\nsee RFC 7989 §5 for Session-ID\n```\nstill inside the outer block: see RFC 7989 §5 for Session-ID\n' in '````markdown\nHow to write a fenced block:\n```\nsee [RFC 7989 §5](https://www.rfc-editor.org/rfc/rfc7989.html#section-5) for Session-ID\n```\nstill inside the outer block: see RFC 7989 §5 for Session-ID\n````\n\nProse below.\n'
scripts/tests/test_fenced_content_survives.py:70: AssertionError
=========================== short test summary info ============================
FAILED scripts/tests/test_fenced_content_survives.py::test_a_longer_fence_is_not_closed_by_a_shorter_run_inside_it[rfc-links]
1 failed, 15 deselected in 0.04s
- Restore the current scripts. The command prints nothing.
git checkout HEAD -- scripts
Terms used here
- Markdown: a plain-text format for writing web pages. sipnab’s documentation and these notes use it.
- Fenced code block (fence): a block of example code in Markdown. A line of three or more backticks or tildes before and after it marks it off.
- Backtick: the character
`. Tilde: the character~. - Script: a small program in the repository’s
scripts/directory that edits or checks files. These scripts are Python programs. - Cookbook: the documentation page
docs/examples.md. It lists commands a reader can copy and run. - Test: a program that checks one behavior and fails when it is wrong.
pytestis the tool that runs the Python tests. - Gate: a check that must pass before the project accepts a change.
- CI (continuous integration): the automated build that runs the tests on every change pushed to the project.
- Commit: one recorded change in the project’s history, named by a short
hash such as
e2045af9. - Syntax tree: the structure Python builds when it reads a program.
- In it, comments and strings are separate from calls.
- So a check can look at calls only.