prism branch T-1663/bugfix-…-double-escape commits 1 (+ review fixes) files 4 touched (2 prod, 2 test) lines +143 / -3 tests 182 passed, 0 failed lint 0 violations / 533 files

Pre-push review: T-1663 root-relative query/fragment double-escape

The third appearance of one Foundation trap: a URLComponents setter that re-encodes its whole input. T-875 fixed it for paths, T-1624 for absolute URLs, and this commit fixes it for root-relative ones. The fix is correct, minimal, and tested — but all three review agents independently found the same bug still standing one branch below, on the shape markdown authors write far more often.

At a glance

  • The bug: URLComponents.query and .fragment are non-encoded setters — they treat whatever you hand them as fully raw text and percent-encode all of it. Give one a value that is already escaped and the escape itself gets escaped: token=a%20b becomes token=a%2520b. The image never loads; the link opens the wrong page.
  • The fix: route both through URLComponentHelpers.encodingRawCharacters (keeps existing %XX triplets verbatim, encodes only genuinely-raw characters) and assign via the percentEncoded* setters, which take the value as-is. Two call sites in ImagePathResolver, one in LinkPathResolver.
  • Verified safe against malformed input. A lone %, %zz, %2, %C0%80, NUL, emoji, empty string — all produce a valid URL, none crash the percentEncoded* setter. The helper is closed under valid percent-encoding, so the setter can never receive something it would reject. Empty query/fragment is byte-identical to the old behaviour.
  • The asymmetry between the two resolvers is correct. LinkPathResolver clears the fragment because it carries it separately in the enum for in-app anchor scrolling; ImagePathResolver keeps it inline because the URL is the whole result. Both match how their results are consumed.
  • Major residual (pre-existing, not introduced here): the plain-relative .url branches hand the raw query and fragment to reassembleURL and then URL(string:relativeTo:), which applies the same wholesale re-encode. ImagePathResolver.swift:329,338 and LinkPathResolver.swift:198,209. Not ticketed.
  • Two fixes applied during this review: a stale agent-note that told future sessions this bug still existed, and the missing CHANGELOG entry that every recent bugfix on main carries.

Verdict

Ready to push — file the follow-up first

Nothing in this diff is wrong. The fix is correct, provably crash-free, minimal, and matches the pattern its two predecessors established. 182 targeted tests pass, SwiftLint is clean, and both platform builds succeed with no new warnings. As a fix for T-1663 as ticketed, it is complete.

The reservation is about what it leaves behind. Three review agents, working independently, converged on the same finding: the identical double-escape survives in the plain-relative branch of both resolvers — four call sites, roughly twenty lines below the ones being fixed, in the same function. Empirically reproduced on this toolchain: images/logo.png?token=a%20b c still resolves to ?token=a%2520b%20c. Relative paths are more common in real markdown than root-relative ones, so the larger half of the bug ships unfixed while the smaller half is closed.

That is a scope decision, not a defect in the diff, so it is the author's call rather than the reviewer's — but it should be made deliberately before merge, because this is exactly how T-1663 came to exist: T-1624 fixed absolute URLs and left root-relative ones, with the residual recorded only in an agent-note. Either extend this PR by four call sites, or file the ticket now. The Transit MCP server was unreachable during this review, so the ticket could not be filed automatically; the residual has been written into docs/agent-notes/link-handling.md instead.

Review findings

8 raised · 2 fixed · 6 skipped

Jump to findings →

Commits

Three-level explanation

What this does

Web addresses cannot contain spaces, so a space is written as %20. That is called percent-encoding, and % is its escape character — which means % itself has to be escaped too, as %25.

Prism has to build addresses out of pieces. Apple's toolkit offers two ways to set the ?query part of an address: one that says "this text is raw, please escape it for me" and one that says "this text is already escaped, use it as-is." Prism was calling the first one with text that was already escaped. So the toolkit dutifully escaped it again — and the % in the existing %20 turned into %25, leaving %2520.

The address came out subtly wrong. An image with that address failed to load; a link with it opened the wrong page.

Why it matters

This only bites when the address mixes the two styles — part already escaped, part not — which is exactly what happens when a person hand-writes a link in a markdown file. And it only bites on documents opened from a URL, where addresses beginning with / are resolved against the site the document came from.

Key concepts

  • Percent-encoding — writing characters that are illegal in a URL as % plus two hex digits. Like doubling a quote mark inside a quoted string: the escape character needs escaping too.
  • Root-relative address — one starting with /, meaning "from the top of this website," as opposed to "next to the current page."
  • Idempotent — an operation you can safely apply twice. Escaping is not idempotent, which is the whole story here.

Changes overview

Two production hunks, both in the .url arm of the root-relative (leading /) branch of resolveRelativePath:

  • prism/Services/ImagePathResolver.swift:294-299 — query and fragment
  • prism/Services/LinkPathResolver.swift:163-165 — query only (the fragment is cleared here and carried in the enum)

Plus seven regression tests across two suites.

Implementation approach

URLComponents exposes each component twice: query (raw in, encoded out) and percentEncodedQuery (encoded in, encoded out). The old code used the first with input that was already partly encoded, so the setter's wholesale re-encode doubled every existing escape.

The fix uses URLComponentHelpers.encodingRawCharacters(in:allowedCharacters:), introduced by T-1624. It walks the component and, for each position, either recognises a valid %XX triplet and copies it through verbatim, or accumulates the character into a pending run that gets addingPercentEncoding'd when the run ends. The result goes to the percentEncoded* setter, which does not touch it.

The critical detail is why query and fragment need this helper rather than the treatment the path gets. The path, one line above, is normalised by decode-then-encode — a pair that is idempotent for raw, encoded, and mixed input alike. Applying that to a query would be actively wrong: decoding turns an escaped %26 into a literal &, and re-encoding cannot know it was ever escaped, so an escaped literal silently becomes a parameter separator. The selective encoder exists precisely to avoid that round-trip.

Trade-offs

Reusing the T-1624 helper over a local solution. This is the third site to need it, which is the strongest argument for it being the right abstraction. The cost is that the two-line Optional.map + CharacterSet pairing now appears at four sites — see the reuse finding below.

Fixing root-relative only. The ticket scoped it that way, and the diff honours the scope faithfully. The counter-argument is that the same function contains the same bug twenty lines further down.

Technical deep dive

The underlying behaviour is Foundation's per-component validity fallback. Its URL parser and the URLComponents non-encoded setters both treat a component as an atom: if any character in it is disallowed-and-raw, the component is deemed wholly unencoded and re-encoded in full, which promotes every pre-existing % to %25. A fully-valid component passes through untouched. That is why the bug is invisible for uniformly-raw and uniformly-encoded input and only surfaces on mixed input — and why it has now been found three separate times (T-875 path, T-1624 absolute, T-1663 root-relative) rather than once.

Crash safety. The percentEncoded* setters reject syntactically invalid input in some Foundation versions, so accepting markdown-controlled text into them deserves scrutiny. It is safe by construction: encodingRawCharacters emits only (a) verbatim %XX triplets it validated against ASCII hex — the explicit isASCII guard matters, since isHexDigit alone accepts fullwidth forms — and (b) the output of addingPercentEncoding, which by definition contains only allowed characters and well-formed triplets. The image is closed under valid percent-encoding. Empirically confirmed non-crashing and correct for %, %zz, %2, %GG, a%, %C0%80 (overlong UTF-8, preserved — valid syntax is all the setter checks), NUL, U+007F, BOM, and astral-plane emoji.

Empty-string equivalence. splitURLComponents returns a non-nil empty string for a source ending in ? or #. encodingRawCharacters("") returns "", and percentEncodedQuery = "" yields a trailing ? — byte-identical to the old query = "". The .map preserves nil-vs-empty, so no shape changes behaviour except the mixed one being fixed.

Architecture impact

Non-structural: three call sites now use a helper that already existed for exactly this purpose. Its cost is a single O(n) pass over a short component, behind an Optional.map, so a source with no ? or # — the overwhelming majority — pays nothing. Neither resolver sits in a hot path: ImagePathResolver.resolve runs once per image subresource request from WebKit and once per detail-window open; LinkPathResolver.resolve runs once per link activation.

The more interesting architectural fact is what this commit reveals about the class. There is no single chokepoint through which markdown-supplied URL text must pass; instead there are six or seven independent assembly sites, each of which has to remember the rule. Three have now been fixed one at a time, each fix leaving the others standing and each residual recorded only in an agent-note. A structural fix would be a single "resolve a markdown-supplied reference against a base" entry point that owns the encoding rules once. That is a refactor, not a bugfix, and is out of scope here — but it is the reason the residual below matters more than its line count suggests.

Potential issues

  • The relative-path residual (major). ImagePathResolver.swift:329,338 and LinkPathResolver.swift:198,209 reassemble with the raw query/fragment and hand the string to URL(string:relativeTo:), which applies the same per-component fallback. Reproduced: images/logo.png?token=a%20b c?token=a%2520b%20c. Note the path in that same string comes out correct, so the failure is now confined to query and fragment — the exact T-1663 signature. Existing coverage misses it because the T-875 relative tests use ?ref=main, which has no escape to double.
  • ResolvedLink.fragment has no encoding contract. The absolute branch yields a decoded fragment (url.fragment), .anchorOnly decodes explicitly, and the root-relative branch passes the source text through verbatim — so the new test correctly pins fragment: "frag%20ment" as encoded. Harmless today because the sole consumer, DocumentSession.scrollToAnchor, decodes before slugging. Worth a doc comment on the enum case rather than a code change.
  • The path's decode-then-encode is not truly idempotent for encoded reserved delimiters: /a%2Fb.png/a/b.png silently changes path structure. Pre-existing, out of scope, but the in-code comment's blanket idempotence claim overstates it.

Important changes — detailed

ImagePathResolver: root-relative query and fragment through the selective encoder

ImagePathResolver.swift

Why it matters. This is the bug. Both components were being assigned through non-encoded setters that re-encode wholesale, so any already-escaped value doubled its escapes and the image silently failed to load.

What to look at. prism/Services/ImagePathResolver.swift:294-299

Takeaway. URLComponents gives every component two setters, and the naming is a trap: `query` means "this is raw, escape it for me" and `percentEncodedQuery` means "this is already escaped, leave it alone." Neither name says so. If a value has passed through any escaping step — or came from a human writing markdown — the percent-encoded setter is the one you want.
Rationale. Chosen to match what T-1624 did for absolute URLs and what `embeddingFragment` already did for fragments, rather than inventing a third treatment. The stated goal in the commit message is parity across the branches.

LinkPathResolver: query only, fragment deliberately left cleared

LinkPathResolver.swift

Why it matters. The apparent asymmetry with the image resolver — one clears the fragment, one keeps it — looks like an oversight and is not. Getting this wrong in either direction would duplicate the fragment or lose in-app anchor scrolling.

What to look at. prism/Services/LinkPathResolver.swift:163-167

Takeaway. Where a value is consumed determines where it should live. `ResolvedLink` carries the fragment as an enum payload so in-app navigation can scroll to it, and re-embeds it into the URL only on the routes that hand off to the system browser. `ResolvedImageSource` has no such routing — the URL is the entire result — so the fragment stays inline.
Rationale. Verified correct against all four branches of the file: the absolute branch and both non-root-relative branches also pass `fragment: nil`, with re-embedding centralised in `embeddingFragment`.

Regression coverage for mixed, and for fully-encoded

ImagePathResolverTests.swift

Why it matters. The mixed cases prove the fix; the fully-encoded case is the one that catches an over-correction, which is the realistic way a fix like this regresses.

What to look at. prismTests/ImagePathResolverTests.swift:1099-1160, prismTests/LinkPathResolverRootRelativeQueryFragmentTests.swift:1-61

Takeaway. A bug that only manifests on mixed input needs three tests, not one: mixed (the bug), fully-encoded (proves you did not start double-encoding what was already fine), and fully-raw (proves you did not stop encoding). Every expected URL in these suites was independently verified during review.
Rationale. Follows the shape of the T-875 and T-1624 suites already in these files.

The same bug, still live, one branch below

ImagePathResolver.swift

Why it matters. Found independently by all three review agents and reproduced empirically. Relative paths are more common in markdown than root-relative ones, so the unfixed half is the larger half — and it now sits in the same function as the fixed half, behaving differently.

What to look at. prism/Services/ImagePathResolver.swift:329,338 and prism/Services/LinkPathResolver.swift:198,209

Takeaway. When you fix an instance of a bug class, grep for the class rather than the instance — and if you scope the fix deliberately, write the residual down somewhere a future session will read. T-1624's residual lived only in an agent-note, which is the reason T-1663 exists at all.
Open question. Rationale not stated by the author and not inferable from the diff.

Key decisions

Selective encoding for query and fragment, decode-then-encode for the path

The path one line above is normalised by removingPercentEncoding then addingPercentEncoding — a pair that is idempotent across raw, encoded, and mixed input. Query and fragment deliberately do not get that treatment: decoding a query turns an escaped %26 into a literal &, and re-encoding cannot know it was ever escaped, so an escaped literal would silently become a parameter separator. encodingRawCharacters instead preserves existing %XX triplets verbatim and encodes only genuinely-raw characters.

Reuse the T-1624 helper rather than route through normalizedAbsoluteURL

Calling normalizedAbsoluteURL from the root-relative branch would mean synthesising origin + source and abandoning the explicit per-component assignment that T-620 introduced to stop base-URL query/fragment leaking through. The narrower reuse — just the encoder — keeps the T-620 guarantee intact.

(inferred — not stated by the author.)
Scope held to root-relative, matching the ticket

The commit message, both code comments, and the test names all say "root-relative", and the diff does exactly that and no more. The reviewer's position is that this is defensible as a scoping decision but should not be silent: the residual is now recorded in docs/agent-notes/link-handling.md, and a ticket should be filed before merge. See the verdict.

(inferred — not stated by the author.)
New standalone test file for the LinkPathResolver half

The image half was appended to the existing ImagePathResolverTests.swift; the link half became a new file. No rationale is recorded for the split, and the sibling T-1624 suite lives inside LinkPathResolverTests.swift. It compiles either way — prismTests is a PBXFileSystemSynchronizedRootGroup, so no pbxproj edit is needed.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
majorImagePathResolver.swift:329,338 / LinkPathResolver.swift:198,209The identical double-escape survives in the plain-relative .url branches of both resolvers. They pass the raw query (and, in ImagePathResolver, the raw fragment) to reassembleURL and then URL(string:relativeTo:), which applies Foundation's per-component wholesale re-encode. Reproduced on this toolchain: images/logo.png?token=a%20b c resolves to ?token=a%2520b%20c. Relative paths are more common in markdown than root-relative ones, so this is the larger half of the bug. Found independently by all three review agents.Not fixed here — extending production behaviour past the ticket's scope is the author's decision, not the reviewer's. Recorded as an explicit residual in docs/agent-notes/link-handling.md, including the reproduction, why the existing T-875 tests miss it, and the constraint that the fix must be selective encoding rather than the path's decode-then-encode. The Transit MCP server was unreachable throughout this review, so the follow-up ticket could not be filed automatically and must be raised by hand before merge.
majordocs/agent-notes/link-handling.md:51The agent-note ended with 'never feed an already-encoded query to it — the root-relative branches currently do (latent, narrower bug)'. That sentence *is* T-1663. Once this commit lands the note actively misinforms the next session, telling it a bug exists where it no longer does.Rewrote the clause, added a bullet recording that T-1663 closed the root-relative instance, and added a second bullet documenting the still-open relative-branch residual with its reproduction. A one-word deletion would have been wrong here — the note needed to gain the new residual as it lost the old one.
minorCHANGELOG.mdNo entry, breaking an unbroken convention: every recent bugfix on main (T-1822, T-1669, T-1812, T-1840) adds a '### Fixed' line under [Unreleased], and the sibling T-1624 has one. This is user-visible behaviour, which is precisely what that section documents.Added an entry in house style at the top of the Fixed section — concrete symptom, the address that broke, why it broke, and an explicit note that the document-relative form of the same shape is still affected and tracked separately.
minorURLComponentHelpers.swift:110-115 and the three new call sitesThe two-line binding of component kind to CharacterSet now appears at four sites. An Optional-taking pair (encodingRawQuery / encodingRawFragment) would make it impossible for a call site to pass .urlQueryAllowed for a fragment — the one mistake this shape invites.Skipped. The algorithm is already shared; only the pairing repeats. Adding wrappers is an eight-line additive change with five call-site edits, which is a reasonable cleanup but not one to fold into a bugfix diff that a review round has already approved.
minorprismTests/LinkPathResolverRootRelativeQueryFragmentTests.swiftStandalone new file, while the image half of the same commit was appended in-file and the sibling T-1624 suite lives at LinkPathResolverTests.swift:1005 next to an existing T-875 root-relative case. Splitting a matched pair of regression suites across two conventions makes the link half harder to find.Skipped. Organisational only — it compiles and runs (prismTests is a filesystem-synchronized group, so no pbxproj edit is needed), and moving passing tests for tidiness is churn the pre-push constraints discourage.
minorprismTests/LinkPathResolverRootRelativeQueryFragmentTests.swift:34The expected fragment payload 'frag%20ment' is percent-encoded, whereas the absolute branch yields a decoded fragment and .anchorOnly decodes explicitly. ResolvedLink's fragment payload therefore has no defined encoding contract.Skipped as a code change — the test pins current behaviour correctly and the only consumer, DocumentSession.scrollToAnchor, decodes before slugging, so nothing is broken. Worth a doc comment on the enum case in some future pass.
nitImagePathResolver.swift:288 / LinkPathResolver.swift:158The comment claims the path's decode-then-encode pair is idempotent for raw, encoded, and mixed inputs. Not true for encoded reserved delimiters: %2F decodes to a live separator, so /a%2Fb.png becomes /a/b.png.Skipped. Pre-existing, untouched by this diff, and the overstatement is harmless in the shapes the resolvers actually meet.
nitspecs/bugfixes/link-path-resolver-absolute-query/report.md, specs/bugfixes/imagepathresolver-root-relative-leakage/report.mdBoth older reports recommend or describe URLComponents(string:) for parsing markdown-supplied URLs. The code now deliberately does the opposite via splitURLComponents, because that initialiser is one of the things that doubles escapes on mixed input (T-875).Skipped. Historical bugfix reports record what was true when written; rewriting them is out of scope. The current rule is stated correctly in the agent-note, which is what a future session reads first.

Per-file diffs

Click to expand.

prism/Services/ImagePathResolver.swift Modified +11 / -2
diff --git a/prism/Services/ImagePathResolver.swift b/prism/Services/ImagePathResolver.swiftindex cc893c2..6bebabb 100644--- a/prism/Services/ImagePathResolver.swift+++ b/prism/Services/ImagePathResolver.swift@@ -286,8 +286,17 @@ enum ImagePathResolver {                 // re-encode it. Setting .path with already-encoded text would                 // double the existing escapes (T-875).                 components.path = pathPart.removingPercentEncoding ?? pathPart-                components.query = queryPart-                components.fragment = fragmentPart+                // The non-encoded `.query`/`.fragment` setters treat their input+                // as fully raw and re-encode it wholesale, doubling any existing+                // escape (`token=a%20b` → `token=a%2520b`). Selectively encode+                // only genuinely-raw characters instead (T-1663, follow-up to+                // T-1624, which fixed the same class of bug for absolute URLs).+                components.percentEncodedQuery = queryPart.map {+                    URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)+                }+                components.percentEncodedFragment = fragmentPart.map {+                    URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlFragmentAllowed)+                }                 guard let resolved = components.url else {                     return .failed(.invalidURL)                 }
prism/Services/LinkPathResolver.swift Modified +8 / -1
diff --git a/prism/Services/LinkPathResolver.swift b/prism/Services/LinkPathResolver.swiftindex f43fdb5..79033ef 100644--- a/prism/Services/LinkPathResolver.swift+++ b/prism/Services/LinkPathResolver.swift@@ -155,7 +155,14 @@ enum LinkPathResolver {                 // re-encode it. Setting .path with already-encoded text would                 // double the existing escapes (T-875).                 components.path = pathPart.removingPercentEncoding ?? pathPart-                components.query = queryPart+                // The non-encoded `.query` setter treats its input as fully raw+                // and re-encodes it wholesale, doubling any existing escape+                // (`token=a%20b` → `token=a%2520b`). Selectively encode only+                // genuinely-raw characters instead, mirroring embeddingFragment+                // below (T-1663, follow-up to T-1624).+                components.percentEncodedQuery = queryPart.map {+                    URLComponentHelpers.encodingRawCharacters(in: $0, allowedCharacters: .urlQueryAllowed)+                }                 // Clear fragment from URL — it's carried separately in the enum.                 components.fragment = nil                 guard let resolved = components.url else {
prismTests/ImagePathResolverTests.swift Modified +63 / -0
diff --git a/prismTests/ImagePathResolverTests.swift b/prismTests/ImagePathResolverTests.swiftindex 5a21835..20e5836 100644--- a/prismTests/ImagePathResolverTests.swift+++ b/prismTests/ImagePathResolverTests.swift@@ -1095,3 +1095,66 @@ struct ImagePathResolverMixedAbsoluteURLTests {         ))     } }++// MARK: - T-1663: Mixed Encoded And Raw Root-Relative Query/Fragment++/// Bug T-1663 (follow-up to T-1624, PR #309): the root-relative (leading+/// `/`) `.url`-source branch assigned query and fragment straight to the+/// non-encoded `URLComponents.query`/`.fragment` setters, which treat their+/// input as fully raw and re-encode it wholesale — doubling an existing+/// `%20` to `%2520`, the same failure class T-1624 fixed for absolute URLs.+@Suite("ImagePathResolver mixed encoded root-relative query/fragment")+struct ImagePathResolverRootRelativeQueryFragmentTests {++    @Test("Mixed encoded/raw query on a root-relative remote source normalizes encoding (T-1663)")+    func mixedEncodedQueryRootRelativeRemote() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = ImagePathResolver.resolve(+            source: "/images/logo.png?token=a%20b c",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .remote(+            URL(string: "https://example.com/images/logo.png?token=a%20b%20c")!+        ))+    }++    @Test("Mixed encoded/raw fragment on a root-relative remote source normalizes encoding (T-1663)")+    func mixedEncodedFragmentRootRelativeRemote() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = ImagePathResolver.resolve(+            source: "/images/logo.svg#sec%20one two",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .remote(+            URL(string: "https://example.com/images/logo.svg#sec%20one%20two")!+        ))+    }++    @Test("Mixed encoded/raw query and fragment on a root-relative remote source normalize encoding (T-1663)")+    func mixedEncodedQueryAndFragmentRootRelativeRemote() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = ImagePathResolver.resolve(+            source: "/images/logo.png?token=a%20b c#frag%20ment two",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .remote(+            URL(string: "https://example.com/images/logo.png?token=a%20b%20c#frag%20ment%20two")!+        ))+    }++    @Test("Fully encoded root-relative remote source is unchanged (T-1663)")+    func fullyEncodedRootRelativeRemoteUnchanged() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = ImagePathResolver.resolve(+            source: "/images/logo.png?token=a%20b#sec%20one",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .remote(+            URL(string: "https://example.com/images/logo.png?token=a%20b#sec%20one")!+        ))+    }+}
prismTests/LinkPathResolverRootRelativeQueryFragmentTests.swift Added +61 / -0
diff --git a/prismTests/LinkPathResolverRootRelativeQueryFragmentTests.swift b/prismTests/LinkPathResolverRootRelativeQueryFragmentTests.swiftnew file mode 100644index 0000000..30d97ec--- /dev/null+++ b/prismTests/LinkPathResolverRootRelativeQueryFragmentTests.swift@@ -0,0 +1,61 @@+//+//  LinkPathResolverRootRelativeQueryFragmentTests.swift+//  prismTests+//+//  Created by Claude on 15/8/2026.+//++import Foundation+import Testing+@testable import prism++// MARK: - T-1663: Mixed Encoded And Raw Root-Relative Query/Fragment++/// Bug T-1663 (follow-up to T-1624, PR #309): the root-relative (leading+/// `/`) `.url`-source branch assigned the query straight to the non-encoded+/// `URLComponents.query` setter, which treats its input as fully raw and+/// re-encodes it wholesale — doubling an existing `%20` to `%2520`, the same+/// failure class T-1624 fixed for absolute URLs.+@Suite("LinkPathResolver root-relative query/fragment")+struct LinkPathResolverRootRelativeQueryFragmentTests {++    @Test("Mixed encoded/raw query on a root-relative markdown destination normalizes encoding (T-1663)")+    func mixedEncodedQueryRootRelativeMarkdown() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = LinkPathResolver.resolve(+            destination: "/docs/file.md?token=a%20b c#frag%20ment",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .remoteMarkdown(+            URL(string: "https://example.com/docs/file.md?token=a%20b%20c")!,+            fragment: "frag%20ment"+        ))+    }++    @Test("Mixed encoded/raw query and fragment on a root-relative non-markdown destination normalize encoding (T-1663)")+    func mixedEncodedQueryAndFragmentRootRelativeExternal() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = LinkPathResolver.resolve(+            destination: "/docs/page.html?token=a%20b c#sec%20one two",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .externalURL(+            URL(string: "https://example.com/docs/page.html?token=a%20b%20c#sec%20one%20two")!+        ))+    }++    @Test("Fully encoded root-relative markdown destination is unchanged (T-1663)")+    func fullyEncodedRootRelativeMarkdownUnchanged() {+        let baseURL = URL(string: "https://example.com/docs/page.md")!+        let result = LinkPathResolver.resolve(+            destination: "/docs/file.md?token=a%20b",+            baseURL: baseURL,+            sourceType: .url+        )+        #expect(result == .remoteMarkdown(+            URL(string: "https://example.com/docs/file.md?token=a%20b")!+        ))+    }+}

Things to double-check

File the residual ticket before merging

The Transit MCP server was unreachable for the whole review, so this could not be done automatically. The relative-branch double-escape is documented in docs/agent-notes/link-handling.md but has no ticket. Given that T-1663 exists because T-1624's residual lived only in an agent-note, this is the one action item that should not be deferred.

Decide deliberately: extend this PR, or split it

The follow-up fix is four call sites using a helper that already exists — mechanically about the size of this diff. Extending here means one internally consistent function and no third round of this bug; splitting means keeping a reviewed, approved, minimal diff intact. Both are defensible. What is not defensible is shipping without choosing.

Two working-tree changes are uncommitted

CHANGELOG.md and docs/agent-notes/link-handling.md were modified during this review and left uncommitted for your inspection. Neither touches Swift source, so the test and lint results above still hold.

Verification performed

GitHub Actions is billing-blocked, so everything was validated locally. 182 tests across ImagePathResolverTests, ImagePathResolverRootRelativeQueryFragmentTests, ImagePathResolverMixedAbsoluteURLTests, LinkPathResolverTests, LinkPathResolverCredentialTests, LinkPathResolverMixedAbsoluteURLTests, LinkPathResolverRootRelativeQueryFragmentTests and URLComponentHelpersNormalizedAbsoluteURLTests, in two runs of 79 and 103 — both confirmed through Tools/check-test-results.sh against the result bundle, zero failed, zero skipped, and non-zero test counts. SwiftLint: 0 violations across 533 files. iOS Simulator and macOS builds both succeed; the 101 build warnings are pre-existing and none are in the changed files.