Skip to content

fix(changelog-md): cite a line the entry has, and accept an anchored :rfc: role (#588, #592) - #595

Merged
JarryShaw merged 1 commit into
mainfrom
fix/588-592-changelog-gate-diagnostics
Sep 21, 2026
Merged

JarryShaw merged 1 commit into
mainfrom
fix/588-592-changelog-gate-diagnostics

Conversation

@JarryShaw

Copy link
Copy Markdown
Owner

Fixes #588
Fixes #592

Both defects live in the same error path of util/changelog_md.py — the Changelog drift gate that blocked two of the five pull requests opened against this repo today. Neither changes what the converter emits; they change what it says when it refuses.

#588 — a line number counted in the wrong frame

ResidualMarkupError named the entry file and numbered its lines against the converted body. Re-measured on this tree before fixing anything:

#588  source lines: 580  converted body lines: 55
#588  injected roles at source lines 467 and 468
        line 46: an unconverted interpreted-text role: ':data:`...`'
        line 46: an unconverted interpreted-text role: ':func:`...`'
        line 46: an unconverted interpreted-text role: ':class:`...`'
        line 46: an unconverted interpreted-text role: ':meth:`...`'

Four roles, two source lines, one reported number — and 46 is neither of them. The bound settles it on its own: a number that cannot exceed 55 cannot name a line in a 580-line file.

Fix shape: the source map, not the relabelling. The issue offered both. Relabelling ("line 46 of the converted entry") would have made the message honest but still useless — the author is being told to edit a file, and rule 6 joins ten source lines into one, so the nine offences that blocked #586 would still arrive as nine copies of one number. So convert_traced() now returns a (offset, line) map beside the Markdown, and residual() resolves each hit through it. Same input, after:

        line 467: an unconverted interpreted-text role: ':data:`...`'
        line 467: an unconverted interpreted-text role: ':func:`...`'
        line 467: an unconverted interpreted-text role: ':class:`...`'
        line 468: an unconverted interpreted-text role: ':meth:`...`'

The map is cheap because rules 1–5 rewrite a line in place — one map entry per source line — and only rule 6 joins. Complaints now also come out one per construct (not one per joined output line) and in the entry's order rather than the pattern list's. residual() still accepts a bare string, and then says converted line N, because the relabelling is the right answer when there is genuinely nothing to map through.

#592 — rule 2 read only half the role

Widened, as the issue suggested. :rfc:`6554#section-3 was falling past the rule that exists for it and being reported as a role the rules do not cover.

The link text is taken from sphinx.roles._format_rfc_target (read from Sphinx 9.1.0, the version this repo builds docs with) rather than invented here, so an entry reads the same in CHANGELOG.md as in the rendered history: [RFC 6554 Section 3](https://datatracker.ietf.org/doc/html/rfc6554#section-3), with the three anchor prefixes Sphinx titles (section, appendix, page) and every other anchor left as written. The pattern is deliberately (?:#([\w.-]+))? rather than "anything between the backticks": an anchor of a shape the helper cannot name still reaches the guard rather than being carried into a link nobody checked.

This PR's own changelog bullet cites :rfc:6554#section-3`` and passes the gate, which is the first end-to-end test of the fix.

Two supporting changes the map needs

  • Masking a code span is now length-preserving, so a match offset in the masked text is an offset in the Markdown. No residual pattern counts markers, so nothing about what matches moves.
  • Rule 6 collapses runs of blank lines structurally instead of with a \n{3,} substitution over the joined text, which would have shifted every offset. Same result between blocks; one better before the first block, where the substitution used to leave an empty line behind.

Regeneration is unchanged

The obligation this PR is most at risk of breaking, so it is measured rather than asserted. convert() was snapshotted over all 37 committed entries before the change and compared after:

entries compared: 37
entries byte-identical: 36
entries changed: ['1.5.0.rst']
  1.5.0.rst: 55 -> 56 lines
  1.5.0.rst: 1 line(s) added, 0 line(s) removed

git diff --stat agrees: CHANGELOG.md | 1 +. python util/changelog_md.py --check exits 0.

Tests

13 new cases in tests/project/test_changelog_md.py, run against the committed converter first (same test file, util/changelog_md.py from main) and then against the fix:

result exit
committed converter Ran 46 testsFAILED (failures=9, errors=2) 1
with the fix Ran 47 testsOK 0

Sample of what the before-run says, verbatim:

AssertionError: ':data:`sys.modules`' not found in '' : line 3 of the entry does not hold the cited construct
AssertionError: 3 not greater than 4 : the cited line is inside the converted body, so it is a body line number rather than a line of the entry the message names
AssertionError: 1 != 3 : three constructs on three source lines cited [3, 3, 3]
AssertionError: '[RFC 4303 Section 2.1](https://datatracker.ietf.org/doc/html/rfc4303#section-2.1)' not found in '... [:rfc:`4303#section-2.1`] ...'

The location assertions deliberately check the entry file rather than the wording: a cited line is read back out of the entry and has to hold the construct it was cited for.

tests/project/ is green (64 passed, 469 subtests). Coverage of util/changelog_md.py rises 96% → 98% statement and branch (coverage run --source=util --include='util/changelog_md.py' -m pytest tests/project/); what remains is the __main__ guard and a pre-existing break in read_toctree(). The `` -literal branch of the guard had no test at all before and now has two.

Found, not fixed

  • Rule 3 bridges two unconvertible literals on one source line. ([^]+)`` `` pairs the nearest backticks it can, so an a`b and a c`d literal` has its middle converted, leaving one mangled literal instead of two. Harmless in effect — such an entry is refused either way, and it is refused before anything is written — so it is left alone rather than widened speculatively. One test fixture here is two source lines rather than one because of it, and says so.
  • The workflow annotation changelog_md rule 2 rejects :rfc:NNNN#section-N, so citing an RFC section fails the gate even though the role is supported #592 mentions. .github/workflows/unit-tests.yml:195 adds "The unified diff above shows what moved" unconditionally, including when render() raised before any diff was produced. Out of scope for these files and not touched.
  • docs/source/changelog/1.5.0.rst still carries the :rfc:8200 section 4.5 prose workaround in several bullets that predate this fix. The anchored form works now, but those bullets belong to other branches and rewriting them would only manufacture conflicts.

…:rfc: role (#588, #592)

* ResidualMarkupError named the source entry file but numbered its lines
  against the converted body, so the number it printed could not exist in the
  file it pointed at. Rule 6 joins each block onto one line: measured on the
  1.5.0 entry the body is 55 lines against the file's 580, and four roles
  written on source lines 467 and 468 were all reported as "line 46". The
  bound alone settles it -- a number that cannot exceed 55 cannot name a line
  in a 580-line file. convert_traced() now returns a source map beside the
  Markdown, recording which line of the entry each stretch of output came
  from, and residual() resolves every hit through it. That also separates the
  hits: constructs joined onto one output line become one complaint each, at
  their own lines, in the entry's order rather than the pattern list's.
  residual() still takes a bare string, and then labels its numbers
  "converted line N" instead of passing an unusable number off as a location.
* Rule 2 accepted only :rfc:`NNNN`, so :rfc:`NNNN#section-3` -- the spelling
  Sphinx also accepts, and the one anyone citing a section reaches for -- fell
  past the rule that exists for it and was rejected as a role the rules do not
  cover. Both spellings convert now, with the anchor carried into the target.
  The link text comes from sphinx.roles._format_rfc_target rather than being
  invented here, so an entry reads the same in CHANGELOG.md as in the rendered
  history; an anchor of a shape that helper does not name still reaches the
  guard rather than being carried into a link nobody checked.
* Two supporting changes the map needs. Masking a code span is now length-
  preserving, so a match offset in the masked text is an offset in the
  Markdown; no pattern counts markers, so nothing about what matches moves.
  And rule 6 collapses runs of blank lines structurally rather than with a
  \n{3,} substitution over the joined text, which would have shifted every
  offset -- same result between blocks, and one better before the first block,
  where the substitution used to leave an empty line behind.
* The `` literal branch of the guard reports one complaint per literal rather
  than per output line, and had no test of its own until now.

Regeneration is unchanged: 36 of the 37 committed entries convert byte for
byte as before, and the 37th differs by exactly the one bullet added here.
python util/changelog_md.py --check exits 0.

tests/project/ green: 64 passed, 469 subtests. The 13 new cases in
tests/project/test_changelog_md.py were run against the committed converter
first and fail there (46 tests, 9 failures and 2 errors, exit 1) and pass with
the fix (47 tests, exit 0); util/changelog_md.py rises from 96% to 98%
statement and branch coverage, the remainder being the __main__ guard and a
pre-existing break in read_toctree().
@JarryShaw
JarryShaw force-pushed the fix/588-592-changelog-gate-diagnostics branch from a23d8bb to 1719275 Compare September 21, 2026 22:52
@JarryShaw

Copy link
Copy Markdown
Owner Author

✅ GOOD TO MERGE — head 1719275c9ff688b87e28448851f780da0ae2b9a3

@JarryShaw

Copy link
Copy Markdown
Owner Author

Cross-review appendix — PR #595

Reviewer: Sonnet; PR authored on Opus 5. Reviewed at head 1719275c9ff688b87e28448851f780da0ae2b9a3 in an isolated worktree (/tmp/pcapkit-review/pr595, detached at that sha, removed after this review). Falsification posture: every claim below was re-derived from the primary source (the code itself, or the datatracker's own RFC page), not accepted from the PR body.

Fixes keywords

gh pr view 595 --json ... closingIssuesReferences returns [588, 592] — confirmed independently, matching the body's Fixes #588 / Fixes #592 lines. Both issues will close on merge.

CI

statusCheckRollup.state = PENDING at review time; CheckRun tally (via GraphQL checkSuites.checkRuns, __typename == "CheckRun", not a raw jq select): 1 SUCCESS (Changelog drift — the gate this PR itself fixes), 2 SKIPPED (full-suite/docs gates, expected on a partial-file PR), 20 QUEUED/IN_PROGRESS, 0 FAILURE. No CANCELLED runs present, so no ambiguity there.

The load-bearing obligation: no reflow

Independently regenerated CHANGELOG.md from the committed 1.5.0.rst inside the worktree and diffed against the committed file: diff exit 0, byte-identical. Then diffed main's CHANGELOG.md (pre-PR) against the PR's regenerated output directly: exactly one line added (the PR's own new bullet under 1.5.0's "Fixed" section), zero other lines touched. python util/changelog_md.py --check against the committed tree: exits 0 ("is in step with").

Went further than a single-file diff: wrote a script that imports both main's convert() and the PR's convert() and runs both over all 37 committed entries in docs/source/changelog/. Result: 37 compared, 36 byte-identical, only 1.5.0.rst differs — and that's the one entry the PR itself edited to add its own end-to-end test bullet. This exactly matches the PR body's claim and is not a case of trusting the author's own measurement — I reproduced it from scratch.

#588 — source-line mapping

Constructed an independent 11-line synthetic .rst entry with two unconverted roles (:mod: and :func:) deliberately placed on source lines 6 and 9, inside one paragraph that rule 6 joins onto a single output line (markdown line 3 of a 4-line body). Called convert_traced() + residual() directly:

  • With the source map: reports line 6: ... ':mod:...' and line 9: ... ':func:...' — i.e. it distinguishes two constructs joined onto the same output line and cites each one's real, openable .rst line. This is strictly better than the "relabel as converted-body line" alternative the issue also offered.
  • Without a map (residual(markdown) alone): falls back to converted line 3: ... — an honest label rather than a false one, confirming the degraded path still exists for callers with nothing to map through.
  • Confirmed render() (the actual function main()/the CI gate calls) uses convert_traced() and threads sources into residual() — the fix is wired into the real path, not just available as dead capability.

Verdict: this is the real source-map shape, not the cheaper relabeling one, and it locates the construct correctly even in the harder case (two constructs, one output line) that the issue's own example (#586, two source lines → one reported number) was about.

#592 — anchored :rfc: role

  • Bare form regression check: :rfc:8684`` → [RFC 8684](https://datatracker.ietf.org/doc/html/rfc8684) — unchanged from the old behavior.
  • Anchored form: :rfc:6554#section-3`` → [RFC 6554 Section 3](https://datatracker.ietf.org/doc/html/rfc6554#section-3).
  • Checked the anchor resolves, not just that it was carried through: fetched https://datatracker.ietf.org/doc/html/rfc6554 directly (HTTP 200) and confirmed <h2><a class="selflink" id="section-3" href="#section-3">3</a>. Format of the RPL Routing Header</h2> is a real heading on that page. The anchor is genuine.
  • Checked the other two named prefixes: #appendix-A → "RFC 8200 Appendix A", #page-12 → "RFC 8200 Page 12" — both titled as claimed.
  • Checked an anchor matching the capture class but not one of the three named prefixes: #weird-anchor[RFC 4303#weird-anchor](...#weird-anchor) — carried through untitled, matching the PR's "every other anchor left as written" claim.
  • Checked an anchor outside the [\w.-]+ capture class (a space, a slash): the whole :rfc:...`` role is left completely unconverted by rule 2, and residual() correctly flags it as `an unconverted interpreted-text role` — i.e. it "reaches the guard" exactly as claimed, rather than being silently carried into a link nobody checked.

Tests fail without the fix, pass with it

Reverted only util/changelog_md.py to its fa6d18e31 (main) content, kept the PR's tests/project/test_changelog_md.py and its modified docs/source/changelog/1.5.0.rst (which now contains the anchored-RFC bullet as the PR's own end-to-end test), and ran the test file:

  • Reverted: 14 failed, 34 passed (ResidualMarkupError on 1.5.0.rst line 53 cascades into several failures, plus direct assertion failures on the anchor/mapping tests).
  • Restored the fix: 47 passed, 37 subtests passed — exit 0. Matches the PR body's claimed Ran 47 tests — OK exactly.
  • Full tests/project/ (scoped run, not the whole tree, per this repo's host-safety rule): 64 passed, 469 subtests passed — matches the PR's claim exactly.

Coverage

Ran coverage run --source=util -m pytest tests/project/test_changelog_md.py against the fixed tree: util/changelog_md.py98% (163 stmts/2 missed, 60 branches/2 partial-covered), matching the PR's claimed final figure. I did not independently re-derive the claimed 96% baseline on main — noting that as unverified rather than folding it into the pass, since it isn't load-bearing for the merge decision.

Not independently checked

  • The "found, not fixed" disclosures (rule 3's two-literals-on-one-line limitation; the unit-tests.yml:195 annotation; the pre-existing :rfc:8200section 4.5`` prose workaround elsewhere in1.5.0.rst`) are read as honest scope notes, not re-derived — they don't bear on this PR's own correctness.

Disagreement log

None. Every claim I checked held up under independent re-derivation; I found no gap between what the PR says and what the code does.

@JarryShaw
JarryShaw merged commit 0bd517a into main Sep 21, 2026
25 checks passed
@JarryShaw
JarryShaw deleted the fix/588-592-changelog-gate-diagnostics branch September 21, 2026 23:07
@JarryShaw JarryShaw added the fix Pull requests that fix a defect (fix: subject prefix) label Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fix Pull requests that fix a defect (fix: subject prefix)

Projects

None yet

1 participant