Stop inheriting the query and fragment of the base URI - #951
Conversation
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WUCMYykYqJHv67fmwiWZip
There was a problem hiding this comment.
🟡 Changes recommended
Empty references and explicit empty queries still incorrectly inherit base URI components.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Updates URI resolution to follow RFC 3986 query and fragment inheritance rules.
Changes:
- Clears inherited queries for path-bearing references.
- Ensures fragments come from the reference.
- Adds query and fragment resolution tests.
File summaries
| File | Description |
|---|---|
src/JsonSchema/Uri/UriResolver.php |
Revises query and fragment resolution. |
tests/Uri/UriResolverTest.php |
Adds regression cases for URI inheritance. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 2
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // RFC 3986 section 5.3: a reference carrying a path replaces the query of the base, | ||
| // whether or not it has one of its own. Only a reference without a path, such as a | ||
| // bare fragment, keeps it. | ||
| if ('' !== $path) { |
There was a problem hiding this comment.
Correct, and I measured it before changing anything:
resolve('?', 'http://e.org/a/x.json?old') = http://e.org/a/x.json?old
RFC 3986 section 5.2.2 treats a query that is supplied but empty as defined, so it replaces the base one rather than letting it through.
One thing your suggestion did not say but which decides the shape of the fix: parse() cannot tell an empty query from an absent one, because both come out as ''.
parse('#f') => path='' query=''
parse('?') => path='' query=''
So the components are no help here and the delimiter has to be looked for in the reference itself, which is why e0f078c does
$hasQuery = false !== strpos(explode('#', $uri, 2)[0], '?');rather than testing $components['query']. ? and ?#f are both in the provider now.
| $baseComponents['query'] = $components['query']; | ||
| } | ||
|
|
||
| // the fragment always comes from the reference and is never inherited |
There was a problem hiding this comment.
Correct as well:
resolve('', 'http://e.org/a/x.json?old#frag') = http://e.org/a/x.json?old#frag
RFC 3986 assigns T.fragment = R.fragment with no exception, so an empty reference takes the absent fragment of the reference and keeps only the path and query of the base. My own comment two lines below claimed that invariant while the early return quietly bypassed it.
e0f078c restricts the early return to the case where there is no base to resolve against ('' === $uri && (null === $baseUri || '' === $baseUri)), so everything else goes through component resolution. testResolveEmpty keeps its original case and a second one covers the fragment-bearing base.
Both changes are pinned by tests that fail without them. Full suite green: 3190 tests, and the single remaining warning is on the base branch too.
RFC 3986 section 5.2.2 treats a query that is supplied but empty as defined, so it replaces the query of the base rather than letting it through, and it takes the fragment from the reference without exception, so an empty reference must not keep the fragment of its base. parse() cannot tell an empty query from an absent one, both being '', so the delimiter is looked for in the reference itself. The early return for an empty reference now applies only when there is no base to resolve against.
Fixes #948.
resolve()reused the parsed base components wholesale, so the base query and fragment survived into the resolved URI.While reproducing it I found a second half you did not mention: the query of the reference was dropped entirely, not just the base one kept.
So the rule now follows RFC 3986 section 5.3: a reference carrying a path replaces the query of the base whether or not it has one of its own, only a reference without a path keeps it, and the fragment always comes from the reference.
One detail worth flagging, because it is the trap here.
isset($components['query'])is not enough:parse('#/definitions/x')reports an empty query, so testing the key alone would drop the base query for every bare fragment, which is the most common$refshape in this library. The condition therefore tests for a non-empty value, which is consistent withgenerate()already omitting an empty query.Testing
Six cases in a data provider. Five of them fail on
main, the sixth is the bare fragment against a base with a query, which already worked and is there to keep it working.Full suite green (3187 tests), same warning and skips as
main, and PHPStan reports no errors.