Skip to content

Graft .gts SourceFiles onto every virtual twin, once per program - #235

Closed
NullVoxPopuli-ai-agent wants to merge 2 commits into
ember-tooling:mainfrom
NullVoxPopuli-ai-agent:fix/virtual-twin-type-identity
Closed

Graft .gts SourceFiles onto every virtual twin, once per program#235
NullVoxPopuli-ai-agent wants to merge 2 commits into
ember-tooling:mainfrom
NullVoxPopuli-ai-agent:fix/virtual-twin-type-identity

Conversation

@NullVoxPopuli-ai-agent

@NullVoxPopuli-ai-agent NullVoxPopuli-ai-agent commented Jul 5, 2026

Copy link
Copy Markdown

Follow-up to #233 for the remaining problems in #229 (non-deterministic typed-rule false positives, memory).

The bug

A project-mode program can hold one templated file under up to three paths:

  1. the .gts root itself (the linted file),
  2. a .ts virtual, created when another file resolves an extensionless import './x' through the patched fileExists,
  3. a .mts twin injected as a root via the patched readDirectory (the target replaceExtensions rewrites explicit import './x.gts' specifiers to).

syncMtsGtsSourceFiles grafted the real SourceFile onto only the first twin it found, preferring .mts. On older TS/typescript-eslint versions the .mts twin doesn't end up in the program, the fallback fires, and the .ts virtual gets grafted — everything works. On current versions (verified with typescript-eslint 8.62.1 + TypeScript 6.0.3) the .mts root does exist, so the graft lands on a file nothing imports, while the .ts virtual — the copy every extensionless import actually resolves to — keeps its own independent AST.

Two ASTs for the same class means two class symbols. TypeScript compares private members nominally, so the class becomes not assignable to itself across import paths ("Type 'Foo' is not assignable to type 'Foo'"), unions of the "same" type don't dedupe, and typed rules like no-unnecessary-type-assertion / no-redundant-type-constituents produce false positives that depend on which path a cached type was first reached through — i.e. on lint order, which varies run-to-run with concurrency. Verified in an isolated repro: checker.isTypeAssignableTo(gtsType, tsVirtualType) is false both ways for a class with a private member on 0.14.3, true (identical type objects) with this patch.

The second bug

The graft also ran after every parse, re-walking the entire program file list (O(program files) per linted file) and re-copying binder state (symbol, locals) onto twins while a live type checker holds references to the previous symbols — stale symbols stay cached, fresh ones appear for new queries, and results become lint-order dependent. The graft is only needed once per program, so it now runs behind a WeakSet guard (a rebuilt program is a new object and gets re-grafted).

Measurements (400 synthetic gts + 800 ts files, typed queries on every identifier)

variant round 1 round 2 heap
no graft (3 unshared ASTs/file) 6.1s 2.0s 134.8MB
graft all twins, every parse 8.9s 5.0s 120.1MB
graft all twins, once per program (this PR) 5.9s 2.2s 120.0MB

Sharing statements across twins also drops the duplicate ASTs (~11% heap on tiny synthetic files; real components should save proportionally more).

Tests

tests/virtual-twin-type-identity.test.js, run under both project and projectService (feature-detected; 3 tests on tsee 7, 6 on tsee 8). #240 demonstrated them failing against the unfixed parser on CI:

  • every virtual twin present shares statements with the .gts root,
  • a private-member class has one type identity (identical type objects, assignable both ways) across import paths,
  • an already-synced program is not re-grafted on later parses.

What this should fix for #229

The order-dependent false positives from typed rules, plus a slice of time/memory. The inherent cost — one full TS program per concurrent worker — remains; that part is expected for typed linting.

🤖 Generated with Claude Code

@NullVoxPopuli

This comment was marked as outdated.

@NullVoxPopuli-ai-agent

This comment was marked as outdated.

@NullVoxPopuli-ai-agent

This comment was marked as outdated.

@NullVoxPopuli

This comment was marked as outdated.

@boris-petrov

Copy link
Copy Markdown

Thanks for looking into the issue! I tried the changes of this PR (the one in src/parser/ts-patch.js) but unfortunately it doesn't fix the non-determinism. 😢 I can't say for sure but it looks to me as if the same few files have errors (all of them false-positives which do not happen on plain TS files) and a different subset of them shows up in every run.

@NullVoxPopuli

This comment was marked as outdated.

@NullVoxPopuli-ai-agent
NullVoxPopuli-ai-agent force-pushed the fix/virtual-twin-type-identity branch from d114ce8 to 6568520 Compare July 6, 2026 14:57
@NullVoxPopuli

This comment was marked as outdated.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

🏎️ Benchmark Comparison

Parse

Benchmark Control (p50) Experiment (p50) Δ
🟢 gts small 739.63 ms 711.66 ms -3.8%
gts medium 1245.39 ms 1249.19 ms +0.3%
gts large 576.08 ms 579.41 ms +0.6%
gjs small 610.93 ms 611.24 ms +0.1%
gjs medium 1071.55 ms 1067.51 ms -0.4%
gjs large 477.98 ms 476.57 ms -0.3%
🔴 hbs small 82.58 ms 88.47 ms +7.1%
🟢 hbs medium 568.71 ms 551.80 ms -3.0%
🟢 hbs large 633.49 ms 616.87 ms -2.6%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~3.56 GHz
cpu: AMD EPYC 9V74 80-Core Processor
runtime: node 24.18.0 (x64-linux)

benchmark                   avg (min … max) p75 / p99    (min … top 1%)
------------------------------------------- -------------------------------
gts small (control)          766.66 ms/iter 796.44 ms █   █                
                    (656.18 ms … 983.71 ms) 896.32 ms █  ▅█  ▅▅ ▅ ▅  ▅    ▅
                    ( 12.59 mb …  84.02 mb)  42.03 mb █▁▁██▁▁██▁█▁█▁▁█▁▁▁▁█

gts small (experiment)       749.33 ms/iter 775.88 ms  █                   
                    (669.39 ms … 960.05 ms) 885.88 ms ██                   
                    ( 12.26 mb …  83.74 mb)  41.92 mb ██▁▁██▁█▁▁█▁█▁▁▁▁▁▁▁█

                             ┌                                            ┐
                             ╷       ┌────────────┬────┐                  ╷
         gts small (control) ├───────┤            │    ├──────────────────┤
                             ╵       └────────────┴────┘                  ╵
                               ╷  ┌───────────┬────┐                    ╷
      gts small (experiment)   ├──┤           │    ├────────────────────┤
                               ╵  └───────────┴────┘                    ╵
                             └                                            ┘
                             656.18 ms         776.25 ms          896.32 ms

summary
  gts small (experiment)
   1.02x faster than gts small (control)

------------------------------------------- -------------------------------
gts medium (control)            1.28 s/iter    1.29 s   █           █      
                          (1.19 s … 1.60 s)    1.33 s ▅▅█ ▅   ▅ ▅   █▅    ▅
                    ( 75.94 mb … 136.94 mb)  89.23 mb ███▁█▁▁▁█▁█▁▁▁██▁▁▁▁█

gts medium (experiment)         1.27 s/iter    1.27 s            █         
                          (1.17 s … 1.62 s)    1.31 s ▅ ▅▅▅ ▅    █▅ ▅    ▅▅
                    ( 75.97 mb …  99.74 mb)  83.71 mb █▁███▁█▁▁▁▁██▁█▁▁▁▁██

                             ┌                                            ┐
                                 ╷   ┌────────────────────┬───┐           ╷
        gts medium (control)     ├───┤                    │   ├───────────┤
                                 ╵   └────────────────────┴───┘           ╵
                             ╷    ┌─────────────────────┬┐          ╷
     gts medium (experiment) ├────┤                     │├──────────┤
                             ╵    └─────────────────────┴┘          ╵
                             └                                            ┘
                             1.17 s              1.25 s              1.33 s

summary
  gts medium (experiment)
   1.01x faster than gts medium (control)

------------------------------------------- -------------------------------
gts large (control)          614.99 ms/iter 614.06 ms   ██                 
                    (559.70 ms … 891.22 ms) 662.43 ms ▅▅██▅ ▅    ▅▅       ▅
                    ( 14.96 mb …  85.54 mb)  43.27 mb █████▁█▁▁▁▁██▁▁▁▁▁▁▁█

gts large (experiment)       622.46 ms/iter 641.96 ms █                    
                    (567.92 ms … 866.11 ms) 675.78 ms ██            █      
                    ( 14.97 mb …  87.00 mb)  33.04 mb ███▁█▁▁▁▁█▁▁▁▁█▁▁▁▁▁█

                             ┌                                            ┐
                             ╷    ┌───────────────┬                  ╷
         gts large (control) ├────┤               │──────────────────┤
                             ╵    └───────────────┴                  ╵
                                ╷┌───────────────────┬───────┐            ╷
      gts large (experiment)    ├┤                   │       ├────────────┤
                                ╵└───────────────────┴───────┘            ╵
                             └                                            ┘
                             559.70 ms         617.74 ms          675.78 ms

summary
  gts large (control)
   1.01x faster than gts large (experiment)

------------------------------------------- -------------------------------
gjs small (control)          633.38 ms/iter 636.19 ms        █             
                    (571.28 ms … 865.59 ms) 693.16 ms     █  █   █         
                    ( 26.26 mb …  86.00 mb)  36.82 mb █▁███▁▁█▁▁▁█▁▁▁▁▁▁▁▁█

gjs small (experiment)       635.65 ms/iter 641.58 ms   ██  █    █         
                    (580.22 ms … 853.51 ms) 691.99 ms ▅ ██  █▅   █        ▅
                    ( 26.36 mb …  85.77 mb)  36.77 mb █▁██▁▁██▁▁▁█▁▁▁▁▁▁▁▁█

                             ┌                                            ┐
                             ╷     ┌────────────────┬┐                    ╷
         gjs small (control) ├─────┤                │├────────────────────┤
                             ╵     └────────────────┴┘                    ╵
                                ╷    ┌───────────────┬─┐                  ╷
      gjs small (experiment)    ├────┤               │ ├──────────────────┤
                                ╵    └───────────────┴─┘                  ╵
                             └                                            ┘
                             571.28 ms         632.22 ms          693.16 ms

summary
  gjs small (control)
   1x faster than gjs small (experiment)

------------------------------------------- -------------------------------
gjs medium (control)            1.10 s/iter    1.12 s        █             
                          (1.02 s … 1.39 s)    1.12 s        █  █         █
                    ( 21.00 mb …  88.85 mb)  71.41 mb █▁▁▁▁▁▁█▁██▁▁█▁▁▁▁▁██

gjs medium (experiment)         1.11 s/iter    1.13 s    █          █      
                          (1.02 s … 1.40 s)    1.17 s ▅  █▅▅▅ ▅ ▅   █     ▅
                    ( 18.04 mb …  90.52 mb)  70.78 mb █▁▁████▁█▁█▁▁▁█▁▁▁▁▁█

                             ┌                                            ┐
                             ╷          ┌─────────────┬────┐╷
        gjs medium (control) ├──────────┤             │    ├┤
                             ╵          └─────────────┴────┘╵
                             ╷      ┌──────────────────┬─────┐            ╷
     gjs medium (experiment) ├──────┤                  │     ├────────────┤
                             ╵      └──────────────────┴─────┘            ╵
                             └                                            ┘
                             1.02 s              1.09 s              1.17 s

summary
  gjs medium (control)
   1x faster than gjs medium (experiment)

------------------------------------------- -------------------------------
gjs large (control)          508.19 ms/iter 494.56 ms    █                 
                    (461.69 ms … 722.79 ms) 566.01 ms    █                 
                    ( 52.34 kb …  70.08 mb)  43.90 mb ▆▁▆█▁▆▆▁▁▆▁▁▁▁▁▁▁▁▁▁▆

gjs large (experiment)       505.67 ms/iter 481.89 ms    █▂                
                    (458.60 ms … 714.64 ms) 570.80 ms    ██                
                    (388.59 kb …  70.27 mb)  43.97 mb ▇▁▇██▁▁▁▁▇▁▁▁▁▁▁▁▁▁▁▇

                             ┌                                            ┐
                              ╷     ┌────────────┬                      ╷
         gjs large (control)  ├─────┤            │──────────────────────┤
                              ╵     └────────────┴                      ╵
                             ╷      ┌───────────┬                         ╷
      gjs large (experiment) ├──────┤           │─────────────────────────┤
                             ╵      └───────────┴                         ╵
                             └                                            ┘
                             458.60 ms         514.70 ms          570.80 ms

summary
  gjs large (experiment)
   1x faster than gjs large (control)

------------------------------------------- -------------------------------
hbs small (control)           95.51 ms/iter  99.23 ms        █         █   
                     (70.65 ms … 190.21 ms) 104.99 ms ▅  ▅ ▅▅█ ▅   ▅   █  ▅
                    ( 51.32 mb …  58.00 mb)  54.17 mb █▁▁█▁███▁█▁▁▁█▁▁▁█▁▁█

hbs small (experiment)        99.35 ms/iter  97.92 ms          █           
                     (70.87 ms … 208.24 ms) 109.24 ms     █    █    █     █
                    ( 51.23 mb …  58.36 mb)  54.28 mb █▁▁██▁▁▁▁█▁▁▁▁█▁▁▁▁▁█

                             ┌                                            ┐
                             ╷         ┌──────────────────┬───┐      ╷
         hbs small (control) ├─────────┤                  │   ├──────┤
                             ╵         └──────────────────┴───┘      ╵
                             ╷         ┌──────────────────────┬           ╷
      hbs small (experiment) ├─────────┤                      │───────────┤
                             ╵         └──────────────────────┴           ╵
                             └                                            ┘
                             70.65 ms          89.95 ms           109.24 ms

summary
  hbs small (control)
   1.04x faster than hbs small (experiment)

------------------------------------------- -------------------------------
hbs medium (control)         598.17 ms/iter 591.02 ms   █                  
                    (559.85 ms … 780.29 ms) 633.62 ms █ █                  
                    (  8.77 mb …  82.94 mb)  32.57 mb ███▁█▁█▁█▁▁▁▁▁▁▁▁▁█▁█

hbs medium (experiment)      581.07 ms/iter 593.48 ms   █                  
                    (541.94 ms … 742.56 ms) 616.72 ms   █                  
                    (  8.59 mb …  82.79 mb)  32.57 mb █████▁█▁▁▁▁▁▁▁█▁▁▁▁██

                             ┌                                            ┐
                                      ╷┌─────────────────┬                ╷
        hbs medium (control)          ├┤                 │────────────────┤
                                      ╵└─────────────────┴                ╵
                             ╷  ┌───────────────┬─────┐           ╷
     hbs medium (experiment) ├──┤               │     ├───────────┤
                             ╵  └───────────────┴─────┘           ╵
                             └                                            ┘
                             541.94 ms         587.78 ms          633.62 ms

summary
  hbs medium (experiment)
   1.03x faster than hbs medium (control)

------------------------------------------- -------------------------------
hbs large (control)          651.29 ms/iter 634.82 ms ▂ █                  
                    (628.10 ms … 815.62 ms) 678.87 ms █ █▅                 
                    ( 53.32 mb …  64.87 mb)  55.47 mb █▇██▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▇

hbs large (experiment)       636.53 ms/iter 619.16 ms ▂█▂                  
                    (613.38 ms … 805.06 ms) 666.94 ms ███                  
                    ( 53.23 mb …  64.87 mb)  55.45 mb ███▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▇

                             ┌                                            ┐
                                       ╷┌──────────────┬                  ╷
         hbs large (control)           ├┤              │──────────────────┤
                                       ╵└──────────────┴                  ╵
                             ╷┌──────────────┬                    ╷
      hbs large (experiment) ├┤              │────────────────────┤
                             ╵└──────────────┴                    ╵
                             └                                            ┘
                             613.38 ms         646.12 ms          678.87 ms

summary
  hbs large (experiment)
   1.02x faster than hbs large (control)
### Project mode (type-aware)
Benchmark Control (p50) Experiment (p50) Δ
🟢 project warm parse x150 474.47 ms 353.02 ms -25.6%
🟢 project parse+typed x150 490.67 ms 368.40 ms -24.9%
🟢 projectService warm parse x150 480.08 ms 365.50 ms -23.9%
🟢 projectService parse+typed x150 513.49 ms 404.68 ms -21.2%

🟢 faster · 🔴 slower · 🟠 slightly slower · ⚪ within 2%

Full mitata output
clk: ~3.47 GHz
cpu: AMD EPYC 9V74 80-Core Processor
runtime: node 24.18.0 (x64-linux)

benchmark                                   avg (min … max) p75 / p99    (min … top 1%)
----------------------------------------------------------- -------------------------------
project warm parse x150 (control)            470.42 ms/iter 474.91 ms                  █   
                                    (456.62 ms … 478.68 ms) 478.36 ms ▆▁▆▁▆▁▁▁▆▁▁▁▁▆▁▁▆█▆▁▆
                                  gc(257.26 ms … 270.63 ms) 113.12 mb (109.95 mb…114.88 mb)

project warm parse x150 (experiment)         351.76 ms/iter 357.29 ms                    ██
                                    (332.69 ms … 361.23 ms) 358.66 ms █▁▁▁▁▁▁█▁▁█▁██▁▁█▁███
                                  gc(265.62 ms … 271.78 ms) 112.77 mb (111.91 mb…113.94 mb)

                                             ┌                                            ┐
                                                                                   ╷ ┌──┬┐╷
           project warm parse x150 (control)                                       ├─┤  │├┤
                                                                                   ╵ └──┴┘╵
                                             ╷   ┌─┬─┐
        project warm parse x150 (experiment) ├───┤ │ │
                                             ╵   └─┴─┘
                                             └                                            ┘
                                             332.69 ms         405.53 ms          478.36 ms

summary
  project warm parse x150 (experiment)
   1.34x faster than project warm parse x150 (control)

----------------------------------------------------------- -------------------------------
project parse+typed x150 (control)           496.28 ms/iter 498.68 ms       █              
                                    (480.69 ms … 530.68 ms) 515.39 ms ██▁█▁██▁████▁▁▁▁▁▁▁▁█
                                  gc(261.60 ms … 276.31 ms) 113.56 mb (111.50 mb…118.25 mb)

project parse+typed x150 (experiment)        370.49 ms/iter 372.70 ms         █   █        
                                    (353.30 ms … 386.39 ms) 384.55 ms █▁▁█▁▁▁▁█████▁▁▁█▁▁▁█
                                  gc(269.30 ms … 280.59 ms) 111.55 mb (108.84 mb…114.06 mb)

                                             ┌                                            ┐
                                                                                ╷ ┌──┬    ╷
          project parse+typed x150 (control)                                    ├─┤  │────┤
                                                                                ╵ └──┴    ╵
                                             ╷   ┌┬   ╷
       project parse+typed x150 (experiment) ├───┤│───┤
                                             ╵   └┴   ╵
                                             └                                            ┘
                                             353.30 ms         434.34 ms          515.39 ms

summary
  project parse+typed x150 (experiment)
   1.34x faster than project parse+typed x150 (control)

----------------------------------------------------------- -------------------------------
projectService warm parse x150 (control)     478.07 ms/iter 482.50 ms                 █    
                                    (467.07 ms … 484.67 ms) 483.79 ms ██▁▁█▁▁▁▁▁█▁▁▁▁██████
                                  gc(268.94 ms … 276.43 ms) 114.60 mb (109.33 mb…116.07 mb)

projectService warm parse x150 (experiment)  369.08 ms/iter 376.15 ms      ▃█             ▃
                                    (359.33 ms … 379.50 ms) 378.75 ms ▆▁▁▆▁██▁▁▁▆▁▁▁▁▁▁▆▁▁█
                                  gc(269.57 ms … 279.21 ms) 116.57 mb (116.23 mb…117.23 mb)

                                             ┌                                            ┐
                                                                                    ╷┌──┬─┐
    projectService warm parse x150 (control)                                        ├┤  │ │
                                                                                    ╵└──┴─┘
                                             ╷ ┌─┬─┐╷
 projectService warm parse x150 (experiment) ├─┤ │ ├┤
                                             ╵ └─┴─┘╵
                                             └                                            ┘
                                             359.33 ms         421.56 ms          483.79 ms

summary
  projectService warm parse x150 (experiment)
   1.3x faster than projectService warm parse x150 (control)

----------------------------------------------------------- -------------------------------
projectService parse+typed x150 (control)    516.54 ms/iter 520.71 ms    █   █             
                                    (502.72 ms … 537.34 ms) 535.27 ms ██▁█▁█▁█▁█▁█▁▁▁█▁▁▁▁█
                                  gc(273.91 ms … 281.25 ms) 113.87 mb (112.74 mb…118.48 mb)

projectService parse+typed x150 (experiment) 405.93 ms/iter 410.43 ms           █          
                                    (387.01 ms … 426.48 ms) 424.00 ms █▁▁▁██▁█▁███▁██▁▁▁▁▁█
                                  gc(277.60 ms … 289.96 ms) 117.90 mb (115.43 mb…120.37 mb)

                                             ┌                                            ┐
                                                                                ╷ ┌─┬─┐   ╷
   projectService parse+typed x150 (control)                                    ├─┤ │ ├───┤
                                                                                ╵ └─┴─┘   ╵
                                             ╷  ┌──┬┐   ╷
projectService parse+typed x150 (experiment) ├──┤  │├───┤
                                             ╵  └──┴┘   ╵
                                             └                                            ┘
                                             387.01 ms         461.14 ms          535.27 ms

summary
  projectService parse+typed x150 (experiment)
   1.27x faster than projectService parse+typed x150 (control)

A project program can hold one templated file under up to three paths:
the .gts root, a .ts virtual created when another file resolves
`import './x'` extensionless through the patched fileExists, and (on
newer TS/typescript-eslint versions) a .mts twin injected as a root via
the patched readDirectory.

syncMtsGtsSourceFiles only grafted the first twin it found, preferring
.mts. On setups where the .mts root exists, the .ts virtual — the copy
every extensionless import actually resolves to — kept its own AST and
therefore its own class symbols. A class with a private member is not
assignable to itself across two declarations, unions of the "same" type
don't dedupe, and typed rules (no-unnecessary-type-assertion,
no-redundant-type-constituents) report order-dependent false positives
(ember-tooling#229). Graft all twins so every import path reaches the same nodes and
one type identity; sharing statements also drops the duplicate ASTs.

The graft also ran after every parse, walking the whole program file
list each time and re-copying binder state (symbol/locals) onto twins
under a live type checker, leaving stale symbols in checker caches —
the remaining non-determinism vector. Run it once per program instead.

Regression tests are split out to a follow-up PR (they fail without
this change by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three assertions, each under both project and projectService
(feature-detected; v7's experimental service can't hold .gts files
in-project, so the mode is registered only when a probe parse yields a
program that contains virtual twins — 3 tests on tsee 7, 6 on tsee 8):

- every virtual twin present shares statements with the .gts root,
- a private-member class has one type identity across import paths,
- an already-synced program is not re-grafted on later parses.

Against the unfixed parser these fail — demonstrated on CI in ember-tooling#240:
the re-graft canaries fail on TS 5.7, and the twin-graft/type-identity
tests additionally fail on TS 6 cells where the .mts twin shadows the
old single-candidate fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@NullVoxPopuli

Copy link
Copy Markdown
Member

I think something is wrong with the benchmark, or the benchmark isn't testing the right things.

on the 4 million line project at work, this PR is actually a pretty serious reduction in speed.

image

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants