Run all Liquid checks in one walk of the AST - #1316
Merged
Merged
Conversation
check() walked each Liquid file's AST once per check, about 70 walks per file with the recommended config. Run every check on a file in a single walk instead. At each node, the methods of every check that has one run together, so each check still sees its nodes in walk order with each method settled before its next, and a check that throws still stops on that file only and is reported once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
clauderic
marked this pull request as ready for review
October 1, 2026 12:35
charlespwd
reviewed
Oct 1, 2026
validateNestedBlocks started its Promise.all without awaiting it, and ValidSettingsKey called the async validateReferencedBlock from forEach. Their offenses could arrive after check() returned and be lost; a slow file system or block schema makes it happen on main. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Running every check's methods for a node together made each check wait at every node for the slowest one there, so I/O from different checks on the same file no longer overlapped: up to 3x slower than main on a cold file system, 2x with remote assets. The walk now collects each check's calls, and each check makes its own in order, as before. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What are you adding in this PR?
check()walked each Liquid file's AST once per check. With the recommended config, that's about 75 full walks per file (74 checks plus the disable-comment visitor). No single check was slow: the walks were the cost. This PR walks each file once, makingcheck()2.3–2.9× faster on Dawn and Horizon with identical offenses.How it works
visitLiquid(node, visit)now takes a callback instead of a check. It calls the callback with the method name for each node (typeon the way down, then`${type}:exit`), at exactly the points the old per-check walk called methods.checkLiquidFile(checks, file)walks the file once and collects each check's calls, in walk order. Then each check makes its own calls, awaiting each one, as the old per-check loop did. Which checks handle each method is looked up once per file.What each check sees doesn't change
onCodePathStartruns first. If the file doesn't parse, the walk andonCodePathEndare skipped, as before.config.onErroronce.checkLiquidFilereturns one promise per check, socheck()'s error handling is unchanged.create()returns nothing fails on its own, with the same error as before.The one observable difference
Offenses from different checks can come back in a different order. That order was never specified: with one pipeline per check, it depended on how many
awaits each check happened to make. One test pinned it,ValidBlockContentSettingType"names the built-in parameter as the string authority…". It now sorts by check before asserting.A bug this exposed:
ValidBlockTargetandValidSettingsKeycould lose offensesTwo checks started async work they never awaited:
validateNestedBlocks(used byValidBlockTarget) ranPromise.all(...)twice withoutawait.ValidSettingsKeycalled the asyncvalidateReferencedBlockfromforEach.Their offenses arrived whenever that work happened to finish, possibly after
check()had returned. Onmain, it usually finished first because the ~75 walks keptcheck()busy. With a fastercheck(), two existing tests lost offenses.This is fixed in its own commit, with its own changeset, and the new tests reproduce the bug on
mainby making the file system or a block schema answer 10 ms late. A type-aware scan oftheme-check-commonfor un-awaited promises (expression statements whose type is a promise, or an array of them) found no others.Findings
All numbers come from
themeCheckRunin@shopify/theme-check-node(real file system, recommended config), timing onlycheck(). They are medians of runs alternating betweenmainand this branch, on Node 24 and Apple Silicon. Absolute times vary with machine load from session to session; the ratios held.Whole themes
maincheck()mainon both themes, compared as sorted full JSON.Slow I/O, where the first version of this PR lost
The first commit ran the methods of every check for a node together and waited for all of them before moving on. As @charlespwd pointed out, that made every check wait at every node for the slowest one. A read-only review measured it:
mainsections/main-collection.liquidalone, 20 ms per cold file calllayout/theme.liquidalone, 20 mssections/main-product.liquidalone, 20 mssections/main-product.liquidalone, 5 msAssetSizeCSS+AssetSizeJavaScript, server answers in 100 msThe single-file cases match the browser language server: it checks open documents only, through a file system that answers over the LSP connection, cached per URI. That's why the PR now collects calls and lets each check make its own.
I also compared chaining each check's calls on its own promise. It was just as fast, but used slightly more memory (Dawn: 153 MB vs 147 MB for collected calls, and 138 MB on
main). The lockstep version used the least (110 MB), but it lost the I/O overlap.How I measured
@shopify/theme-check-nodefrom amainworktree and from this branch. It replacedrequire('@shopify/theme-check-common').checkwith a wrapper that records the duration and the heap growth (after a forced GC) of each call, then ranthemeCheckRun(themeRoot)three times per process. For the delayed table, it wrapped eachNodeFileSystemmethod in asetTimeout.getThemeAndConfig, kept one file, and calledcheck()with aThemeLiquidDocsManagerdocset and a file system that caches per URI and delays the first call for each URI. The cache was fresh on every run.HEADafter 100 ms.What's next? Any followup issues?
no-floating-promiseslint rule would catch the next one.What did you learn?
The checks themselves are cheap: walking the same AST ~75 times per file was most of
check()'s time. And a check that doesn't await its own work only looks correct while something else keepscheck()busy.Tophatting
pnpm vitest run packages/theme-check-common: all 119 files and 5,096 tests pass.src/index.spec.ts:create()returns nothing, each stop on their own file only and report once (the latter with the stock error message), while another check's offenses are unaffected.maintoo, since they pin existing guarantees. On this branch they fail if:valid-block-targetandvalid-settings-keyfail onmainand pass here.vitest run: same results asmain. The suites that fail there need builds outside this change.Before you deploy
changeset🤖 Generated with Claude Code