Repository navigation
Conversation
`resolve()` walked a `$ref` chain hop by hop on every call, so a chain of n parameters, headers, or security schemes referenced n times took O(n²) work: 8000 chained security schemes with 8000 requirements took ~20 s to downgrade. Each walk now stops at the first `$ref` already followed and records where the chain ends, or that it ends in a cycle, for every `$ref` on the way, so each one is walked once per conversion. Adds benches for a security scheme chain (3.1 → 3.0) and a parameter chain (3.2 → 3.1), and tests for chains entered partway and cycles reached from outside them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh
Keep `resolve()`'s original `for` header, note that a memoized `undefined` also means a dangling `$ref`, and build the three chain benches' components with one `refChain` helper. The bench documents are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Merging this PR will not alter performance
Performance Changes
Comparing |
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
This run reviewed the full PR: the resolve() memoization in shared.ts, the new chain/cycle tests, and the benchmark refactor.
- Memoized
$refchain resolution —Contextgainsresolved, andresolve()now stops at the first$refalready cached and records the terminal for every$refon the way, turning the prior O(n²) walk over n references into O(n).downgrade()seeds a fresh map per conversion. - Chain/cycle tests — one per converter, covering a chain entered partway and a cycle reached from outside it.
- Benchmark refactor — shared
refChainhelper, new 1000-hopsecuritySchemeChainV31(3.1→3.0) andparameterChainV32(3.2→3.1) benches;pathItemChainV31rebuilt onrefChainwith identical output.
Soundness of the cache. The $ref graph has out-degree ≤ 1, so the terminal reached from a $ref is a pure function of the ref string and ctx.root. A node on a cycle can only ever be cached as undefined, and a chain merging into a cached node necessarily inherits that node's terminal. I confirmed this both by reasoning through the merge and cycle branches and independently via an exhaustive check over all 5-node successor graphs across every call ordering (no divergences), plus an audit that no code path mutates ctx.root mid-conversion — all delete/setOwn calls target freshly built out objects. pnpm test passes 199/199 on the head commit.
deepseek-v4.1-flash (free via Pullfrog for OSS) | 𝕏
…s neighbours Each of the bench's 1000 operations required a different scheme, so the fixture held 1000 objects whose only key differed, each adding a transition to the empty object's hidden class. After CodSpeed's gc(), the measured call of every other bench in the file reinserts its transitions into that list, which made `official corpus`, `order schema with $defs` and `property schema` report 12-23% regressions. Every operation now requires the first scheme, as in the original repro. The schemes themselves still enter the chain at every hop, so the old `resolve()` is still about 15x slower on this bench. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh

Why
resolve()inpackages/downgrader/src/shared.tsfollowed a$refchain hop by hop on every call.resolvePointercached each hop inctx.targets, but not where the chain ended. It runs once per reference:convertParameterRef(3.2 → 3.1): each parameter or header$refschemeType(3.1 → 3.0): eachsecuritySchemesentry and each security requirement nameSo n references into an n-hop chain did O(n²) work, which breaks the README's Performance claim ("work grows with the size of the document"). A valid 1 MB 3.1 document with 8000 chained security schemes and 8000 operations requiring
s0took about 22.7 s to downgrade. Real documents rarely chain more than one or two hops, so this mostly matters for services that convert untrusted uploads.Fix
Contextgets aresolvedmap, created indowngrade(). It records where the chain from each$refends, orundefinedif the chain dangles or loops.resolve()stops at the first$refalready in that map. It then records the result for every$refit passed on the way. Each$refis walked once per conversion.securitySchemescomponents.headersFrom 1,000 to 32,000 paths, chained documents now cost the same as unchained ones, and time roughly doubles when the document doubles.
Benches and tests
parameter $ref chain, 1000 hops(3.2 → 3.1): each operation enters the chain at a different point.security scheme $ref chain, 1000 hops(3.1 → 3.0): converting the schemes enters the chain at every hop, and 1000 operations all require the first scheme. The oldresolve()is about 15× slower on it (287 ms vs 19 ms locally).S0: with 1000 different names, the fixture held 1000 objects whose only key differed. That gave the empty object's hidden class 1000 transitions. After CodSpeed'sgc(), the measured call of every other bench in the file reinserted its own transitions into that list, soofficial corpus,order schema with $defsandproperty schemashowed 12–23% regressions the code didn't cause. Replaying CodSpeed's protocol under callgrind (7 warm-ups,gc(), one measured call), those benches are now within 0.4% ofmainin instruction count.refChainhelper. ThepathItemChainV31document is unchanged.resolve(), so the memo changes no output.Validation
pnpm test(199 tests),pnpm run benchandpnpm run checkpass locally with pnpm 12.4.1.Not in this PR
Chains of bare
$refs that the converter has to replace with their target go throughinline(), which doesn't useresolve(). This coverscomponents.mediaTypesin 3.2 → 3.1 and$defs/definitionsin 3.1 → 3.0. Those chains are still quadratic: 776 ms and 1,137 ms at 2,000 hops. From about 12,000 hops they throwRangeError: Maximum call stack size exceeded, even with one reference. A fix has to stop at the first$refthat stays a reference in 3.0, and keep today's results for cycles and dangling refs, so it gets its own PR.🤖 Generated with Claude Code
https://claude.ai/code/session_01SQziHTnKWGkgUCmhHSPqwh