perf(graph): visit each tsconfig once in the references walk
What
collect_local_tsconfig_chain (crates/graph/src/resolve/specifier.rs) now checks for a revisit with the lexically normalized config path. Before, it used the joined path. The chain keeps the joined path as before, because paths, baseUrl and rootDirs resolve against its directory. The walk now drops only repeats of a file that it already visited.
The change is one line with a comment, two regression tests, a GRAPH_CACHE_VERSION bump to 80, and a CHANGELOG entry under Unreleased / Performance.
Why
Fixes #3503.
Since 3.20.0, import resolution is about 20x slower in a common monorepo layout: each package tsconfig.json has "extends": "../../tsconfig.json", and the root tsconfig.json lists each package in references.
The walk joins references and extends paths verbatim. For a file in packages/kernel/src, it visits:
packages/kernel/tsconfig.jsonpackages/kernel/../../tsconfig.json, throughextendspackages/kernel/../../packages/kernel/tsconfig.json, through the root'sreferencespackages/kernel/../../packages/kernel/../../tsconfig.json, and so on
Each spelling was a new key in seen, so the walk recursed until is_file() failed on a path that was too long. At each level, it called tsconfig_applies_to_file for every root reference, and each call built a new GlobSet.
Before #2436, tsconfig_applies_to_file built its include globs from the unnormalized directory. The longer spelling of a package config never matched, so the walk stopped after the first revisit. #2436 normalized that directory, which is correct, and that made the cycle reachable. With the new cycle test copied to both commits, the chain has 2 entries on b0585d4b6^ and 98 entries on b0585d4b6. #2436 first shipped in v3.20.0.
Evidence on callstack/agent-device (pnpm, 29 workspaces, 28 references, about 4,200 modules), from temporary counters in a debug build of fallow dead-code:
| before | after | |
|---|---|---|
| uncached chains | 1,984 | 1,984 |
| chain entries (distinct configs) | 88,552 (3,177) | 3,177 (3,177) |
tsconfig_applies_to_file (= GlobSet builds) | 1,608,314 | 65,583 |
| longest config path in a chain | 1,023 bytes | 73 bytes |
The per-file chain cache was hit as designed. The cost came from the size of each uncached walk. A chain for a package file held 60 to 98 entries for 2 distinct configs, and stopped only when the path reached the 1,024-byte macOS limit.
Test plan
-
New
references_walk_visits_each_config_once_through_extends_cycleincrates/graph/src/resolve/specifier.rs. It fails without the fix (98 chain entries in place of 2) and passes with it. -
New
references_walk_skips_a_repeat_reached_through_an_absolute_reference. The root config referencesb/tsconfig.jsonby absolute path and extendsc.json;b/tsconfig.jsonextends["../tsconfig.json", "../d.json"];c.jsonandd.jsonmap the samepathsalias to different files. The test asserts the chain ordertsconfig.json, b/tsconfig.json, d.json, c.jsonand that the alias resolves to thed.jsontarget. Without the fix, the chain istsconfig.json, b/tsconfig.json, b/../tsconfig.json, b/../c.json, b/../d.json, c.json, and the alias resolves to thec.jsontarget. -
cargo test -p fallow-graph: 895 passed. The existingreferencestests pass unchanged (references_walk_scopes_referenced_include_to_its_directory,referenced_include_matches_through_dot_and_parent_dir_spellings,tsconfig_applies_to_fileandresolve_tsconfig_reference_path_*). -
Local gates:
cargo fmt --all -- --check,cargo clippy --workspace --all-targets -- -D warnings,cargo test --workspace(20,493 passed, 0 failed, 29 ignored),npm run verify:fast,npm run commitlinton both commits, andgit show --check. -
Timing, release build (
cargo build --release -p fallow-cli), macOS on Apple silicon with 12 cores. Cold runs:.fallow/was deleted before each run. Three alternating runs per build, medians:main(7ce45bd27)this PR dead-code --performance:resolve imports10,396 ms 457 ms dead-code --performance:TOTAL10,874 ms 939 ms dead-code --format json, wall14.2 s 2.8 s dead-code --production --unused-exports, wall4.0 s 0.8 s audit --base HEAD~3, wall36.8 s 6.8 s health --complexity, wall0.7 s 0.8 s A run of 3.19.0 on the same project took about 0.6 s for
resolve imports. On a 4-vCPU Linux CI runner,maintook 206 s for this stage. I did not measure this PR on that runner. -
Output equivalence on the same project: I compared
dead-code --format json,dead-code --config fallow-production-exports.json --production --unused-exports --format json,health --config .fallowrc.json --complexity --format json,audit --format jsonandaudit --base HEAD~3 --format jsonfrommainand from this PR, three runs each, after sorting keys and removinganalysis_run_idandelapsed_ms. All outputs are byte-identical, and the exit codes match.dead-codereported 41 issues;audit --base HEAD~3had awarnverdict over 57 changed files. -
The removed entries are not visible to the readers of the chain.
matches_nearest_tsconfig_path_alias,try_nearest_tsconfig_path_aliasandtry_tsconfig_root_dirseach stop at the first config that declarespaths,baseUrlorrootDirs, and they skip a config that does not. A later repeat of the same file has the same content, so these readers never reach it. When a cycle reaches a config again under another spelling and the walk still ends, for example through an absolutereferencespath, the old walk expanded the repeat once, so the configs after it could come in another order. The second new test covers this case.GRAPH_CACHE_VERSIONmoves to 80 so that a warm cache does not replay the old order. The latest release, v3.33.1, writes version 72.
Not in this change
No follow-up planned.