perf(graph): visit each tsconfig once in the references walk

Merged
TH
8h ago
+144-4

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:

  1. packages/kernel/tsconfig.json
  2. packages/kernel/../../tsconfig.json, through extends
  3. packages/kernel/../../packages/kernel/tsconfig.json, through the root's references
  4. packages/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:

beforeafter
uncached chains1,9841,984
chain entries (distinct configs)88,552 (3,177)3,177 (3,177)
tsconfig_applies_to_file (= GlobSet builds)1,608,31465,583
longest config path in a chain1,023 bytes73 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_cycle in crates/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 references b/tsconfig.json by absolute path and extends c.json; b/tsconfig.json extends ["../tsconfig.json", "../d.json"]; c.json and d.json map the same paths alias to different files. The test asserts the chain order tsconfig.json, b/tsconfig.json, d.json, c.json and that the alias resolves to the d.json target. Without the fix, the chain is tsconfig.json, b/tsconfig.json, b/../tsconfig.json, b/../c.json, b/../d.json, c.json, and the alias resolves to the c.json target.

  • cargo test -p fallow-graph: 895 passed. The existing references tests pass unchanged (references_walk_scopes_referenced_include_to_its_directory, referenced_include_matches_through_dot_and_parent_dir_spellings, tsconfig_applies_to_file and resolve_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 commitlint on both commits, and git 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 ms457 ms
    dead-code --performance: TOTAL10,874 ms939 ms
    dead-code --format json, wall14.2 s2.8 s
    dead-code --production --unused-exports, wall4.0 s0.8 s
    audit --base HEAD~3, wall36.8 s6.8 s
    health --complexity, wall0.7 s0.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, main took 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 json and audit --base HEAD~3 --format json from main and from this PR, three runs each, after sorting keys and removing analysis_run_id and elapsed_ms. All outputs are byte-identical, and the exit codes match. dead-code reported 41 issues; audit --base HEAD~3 had a warn verdict 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_alias and try_tsconfig_root_dirs each stop at the first config that declares paths, baseUrl or rootDirs, 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 absolute references path, 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_VERSION moves 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.