NORTHSET
Proof-of-Pass Receipt — M-009
CONTRIBUTOR SELF-RUN — NOT MAINTAINER VERIFICATION
Project
Work
Idempotency bug in escaped semicolon in Markdown link · PR #19611
Verification execution
runtime: northset-oss executor v0
human operator: aeziz
Code
- base
41a8b99bb8d7d68c00d90d5b8fbaed20511d4cfd- recorded patch commit
296019b33e287334febc9882b204c9501725116d
declared metadata; not execution-bound- patch diff SHA-256
sha256:c29d387a39ad255b7b8e506095b4281e5b9a704b6de656b55377629dcac0a714
bound to executed patch bytes
Environment
- image reference
- node:22-bookworm
- repository digest
node@sha256:a25c9934ff6382cd4f08b6bc26c82bf4ea69b1e6f8dabfb2ead457374127c365- network
- phaseA:bridge,phaseB:none
Declared checks
Execution summary
1/1 declared command returned exit 0 in the recorded environment
node .yarn/releases/yarn-4.17.1.cjs jest tests/format/markdownexit 0 · 11.7s
unclassified executor time (derived residual) 1m02s
run wall (derived from recorded timestamps) 1m14s
PASS — 1/1 declared command
Every command listed returned exit 0 in the declared environment. Only the listed commands are in scope. Unlisted test, lint, typecheck, build, coverage, compiler, full-suite, and CI gates are not implied or recorded.
Public scope interpretation
The declared network-off check runs Prettier's full Markdown Jest suite (tests/format/markdown, 1476 tests including the new escaped-entity-semicolon regression fixture) on Node 22 via the repository's vendored Yarn, after a disclosed online phase-A install. It does not run the full repository test/lint matrix; upstream CI additionally runs multiple Node versions, ESLint, TypeScript typecheck, changelog lint, and production/browser tests.
Record details
- payment
- none · not merge-contingent
- redactions
- none recorded
- Bundle contents digest
sha256:3655d695fef0b43a3af656fbbbc5d260a84f69eebcdcc4fe76e435174fc2a0f5- Signed asset SHA-256
sha256:83000c6ca030d502b4b3f1fc6afd16168bb1148c3699d17dc618792c874dc9a8- Signed provenance recorded
- verified 2026-07-14T14:21:48Z
NOT INCLUDED
- Does not prove code quality
- Does not prove security
- Contributor self-run record of Northset's own contribution; not the maintainer's verification.
- The declared network-off check runs Prettier's full Markdown Jest suite (tests/format/markdown, 1476 tests including the new escaped-entity-semicolon regression fixture) on Node 22 via the repository's vendored Yarn, after a disclosed online phase-A install. It does not run the full repository test/lint matrix; upstream CI additionally runs multiple Node versions, ESLint, TypeScript typecheck, changelog lint, and production/browser tests.
Signed bundle
Verify this receipt
gh attestation verify run-record-M-009.tar.gz --repo northset-oss/verification-pilot --signer-workflow northset-oss/verification-pilot/.github/workflows/attest-bundle.ymlAttestation confirms that Northset's signing workflow produced this exact bundle. The signer does not witness the recorded run, and verification does not turn it into maintainer verification.
QR → receipt pageCommitted patch.diff
diff --git a/changelog_unreleased/markdown/19611.md b/changelog_unreleased/markdown/19611.md
new file mode 100644
index 0000000000000000000000000000000000000000..9999818122d0ae50304e2ac651630fba2e0cf3dd
--- /dev/null
+++ b/changelog_unreleased/markdown/19611.md
@@ -0,0 +1,18 @@
+#### Preserve escaped semicolons that guard character references in Markdown link URLs (#19611 by @AysajanE)
+
+An escaped semicolon terminating an HTML character reference in a link/image URL
+(e.g. `"\;`) previously lost its backslash on the first format, producing a
+real entity that a second format then resolved — breaking idempotency. Prettier
+now keeps the escape so the URL round-trips.
+
+<!-- prettier-ignore -->
+```markdown
+<!-- Input -->
+[test](/hello?foo"\;bar)
+
+<!-- Prettier stable -->
+[test](/hello?foo"bar)
+
+<!-- Prettier main -->
+[test](/hello?foo"\;bar)
+```
diff --git a/src/language-markdown/print/mdast.js b/src/language-markdown/print/mdast.js
index ca3a5991bfd3f8bac52b1a6e58f297c5a453ca33..7674f66ed0a451b7dca0cfe99cc65faec9b36239 100644
--- a/src/language-markdown/print/mdast.js
+++ b/src/language-markdown/print/mdast.js
@@ -531,11 +531,18 @@ function printUrl(url, dangerousCharOrChars = []) {
: [dangerousCharOrChars]),
];
- return new RegExp(
+ const printedUrl = new RegExp(
dangerousChars.map((x) => escapeStringRegexp(x)).join("|"),
).test(url)
? `<${encodeUrl(url, "<>")}>`
: url;
+
+ // An escaped semicolon is decoded by the parser. Escape it again when it
+ // would otherwise turn the preceding text into a character reference.
+ return printedUrl.replaceAll(
+ /&(?:#\d{1,7}|#x[0-9A-F]{1,6}|[A-Z][A-Z0-9]{1,30});/gi,
+ (reference) => String.raw`${reference.slice(0, -1)}\;`,
+ );
}
function printTitle(title, options, printSpace = true) {
diff --git a/tests/format/markdown/link/__snapshots__/format.test.js.snap b/tests/format/markdown/link/__snapshots__/format.test.js.snap
index f896dc4143af354c5856f6a20dfc481bb8e4ff40..eea6d5d9f24578d23827bd39944708b7f1929829 100644
--- a/tests/format/markdown/link/__snapshots__/format.test.js.snap
+++ b/tests/format/markdown/link/__snapshots__/format.test.js.snap
@@ -129,6 +129,56 @@ proseWrap: "always"
================================================================================
`;
+exports[`escaped-entity-semicolon.md - {"proseWrap":"always"} format 1`] = `
+====================================options=====================================
+parsers: ["markdown"]
+proseWrap: "always"
+ printWidth: 80 (default) |
+=====================================input======================================
+[test](/hello?foo"\\;bar)
+
+[test](/hello?foo"bar)
+
+
+
+[decimal](/hello?foo{\\;bar)
+
+[hex-lowercase](/hello?foo«\\;bar)
+
+[hex-uppercase](/hello?foo«\\;bar)
+
+[non-entity](/hello?foo;a;b)
+
+[wrapped](</hello?foo="\\;bar baz>)
+
+[reference][entity]
+
+[entity]: /hello?foo{\\;bar
+
+=====================================output=====================================
+[test](/hello?foo"\\;bar)
+
+[test](/hello?foo"bar)
+
+
+
+[decimal](/hello?foo{\\;bar)
+
+[hex-lowercase](/hello?foo«\\;bar)
+
+[hex-uppercase](/hello?foo«\\;bar)
+
+[non-entity](/hello?foo;a;b)
+
+[wrapped](</hello?foo="\\;bar baz>)
+
+[reference][entity]
+
+[entity]: /hello?foo{\\;bar
+
+================================================================================
+`;
+
exports[`long.md - {"proseWrap":"always"} format 1`] = `
====================================options=====================================
parsers: ["markdown"]
diff --git a/tests/format/markdown/link/escaped-entity-semicolon.md b/tests/format/markdown/link/escaped-entity-semicolon.md
new file mode 100644
index 0000000000000000000000000000000000000000..89b76c23968ce1f628dc68b9cb1f24d254f3b999
--- /dev/null
+++ b/tests/format/markdown/link/escaped-entity-semicolon.md
@@ -0,0 +1,19 @@
+[test](/hello?foo"\;bar)
+
+[test](/hello?foo"bar)
+
+
+
+[decimal](/hello?foo{\;bar)
+
+[hex-lowercase](/hello?foo«\;bar)
+
+[hex-uppercase](/hello?foo«\;bar)
+
+[non-entity](/hello?foo;a;b)
+
+[wrapped](</hello?foo="\;bar baz>)
+
+[reference][entity]
+
+[entity]: /hello?foo{\;bar
Redacted stdout
=== cmd 1: node .yarn/releases/yarn-4.17.1.cjs jest tests/format/markdown ===
Redacted stderr
=== cmd 1: node .yarn/releases/yarn-4.17.1.cjs jest tests/format/markdown ===
PASS tests/format/markdown/trim-space/format.test.js
PASS tests/format/markdown/splitCjkText/format.test.js
PASS tests/format/markdown/commonmark-test-suite-legacy/format.test.js
PASS tests/format/markdown/gfm-test-suite/format.test.js
PASS tests/format/markdown/auto-link/format.test.js
PASS tests/format/markdown/splitCjkText/non-bmp/format.test.js
PASS tests/format/markdown/list/empty-list-items/format.test.js
PASS tests/format/markdown/heading/setext/format.test.js
PASS tests/format/markdown/wiki-link/format.test.js
PASS tests/format/markdown/list/indent/format.test.js
PASS tests/format/markdown/broken-plugins/format.test.js
PASS tests/format/markdown/footnoteDefinition/format.test.js
PASS tests/format/markdown/table/empty-table/format.test.js
PASS tests/format/markdown/inlineCode/format.test.js
PASS tests/format/markdown/html/format.test.js
PASS tests/format/markdown/paragraph/format.test.js
PASS tests/format/markdown/commonmark-test-suite/format.test.js
PASS tests/format/markdown/link/quotes/format.test.js
PASS tests/format/markdown/jsx-semi/format.test.js
PASS tests/format/markdown/fenced-code-block/format.test.js
PASS tests/format/markdown/list/huge-first-number/format.test.js
PASS tests/format/markdown/linkReference/format.test.js
PASS tests/format/markdown/blockquote/format.test.js
PASS tests/format/markdown/code/lwc/format.test.js
PASS tests/format/markdown/break/list-item/format.test.js
PASS tests/format/markdown/code/angular/format.test.js
PASS tests/format/markdown/liquid/format.test.js
PASS tests/format/markdown/long-table/format.test.js
PASS tests/format/markdown/indentation/format.test.js
PASS tests/format/markdown/multiparser-json/format.test.js
PASS tests/format/markdown/list/parser-regression/format.test.js
PASS tests/format/markdown/multiparser-js/format.test.js
PASS tests/format/markdown/link/parser-regression/format.test.js
PASS tests/format/markdown/word/format.test.js
PASS tests/format/markdown/thematicBreak/format.test.js
PASS tests/format/markdown/toml/format.test.js
PASS tests/format/markdown/table/format.test.js
PASS tests/format/markdown/strong/format.test.js
PASS tests/format/markdown/spec-legacy/format.test.js
PASS tests/format/markdown/yaml/format.test.js
PASS tests/format/markdown/link/format.test.js
PASS tests/format/markdown/image/format.test.js
PASS tests/format/markdown/imageReference/format.test.js
PASS tests/format/markdown/ignore/format.test.js
PASS tests/format/markdown/heading/format.test.js
PASS tests/format/markdown/footnote/format.test.js
PASS tests/format/markdown/footnoteReference/format.test.js
PASS tests/format/markdown/emphasis/format.test.js
PASS tests/format/markdown/list/format.test.js
PASS tests/format/markdown/delete/format.test.js
PASS tests/format/markdown/definition/format.test.js
PASS tests/format/markdown/break/format.test.js
PASS tests/format/markdown/list/blank-lines/format.test.js
PASS tests/format/markdown/wiki-link/alias/format.test.js
PASS tests/format/markdown/list/task-list/format.test.js
PASS tests/format/markdown/linkReference/case-and-space/format.test.js
PASS tests/format/markdown/html/html/format.test.js
PASS tests/format/markdown/markdown/format.test.js
PASS tests/format/markdown/math/format.test.js
PASS tests/format/markdown/front-matter/format.test.js
PASS tests/format/markdown/multiparser-css/format.test.js
PASS tests/format/markdown/cursor/format.test.js
PASS tests/format/markdown/code/format.test.js
Test Suites: 63 passed, 63 total
Tests: 1476 passed, 1476 total
Snapshots: 1465 passed, 1465 total
Time: 7.23 s
Ran all test suites matching tests/format/markdown.