java-topology/tools/tickets/defects/solargraph-0002.md
russell@unturf.com db29a08762 undefect. CWE-407 — 92 sites, 42 ecosystems
B&W print-friendly diagrams + tinkerpop-0001 + wave-3 proof sections.
Squash of 94 local commits onto remote master.
2026-03-26 19:48:18 -04:00

2.8 KiB
Raw Permalink Blame History

id repo severity status created
solargraph-0002 Solargraph MEDIUM OPEN 2026-03-26

Defect

File: lib/solargraph/api_map/constants.rb:262,267 Pattern: skip.to_a inside recursive inner_get_constants — Set converted to Array for every Array subtraction, creating O(G × depth) work per namespace resolution Complexity: O(G × depth × N) where G = gate chain length, depth = namespace tree depth, N = total namespaces Language: Ruby

Description

inner_get_constants resolves the visible constants for a namespace, walking the include/prepend chain recursively. A Set<String> named skip prevents re-visiting namespaces. However, when computing the search gates for each included namespace, the code subtracts the visited set from the gates array using Array's - operator:

# line 262
pre_fqns = resolve(pre.name, pre.closure.gates - skip.to_a)

# line 267
inc_fqns = resolve(pin.name, pin.closure.gates - skip.to_a)

skip.to_a allocates a new Array on every call. The subsequent Array - operator (Array difference) is O(G × |skip|) — for each gate in closure.gates (length G), it scans all of skip.to_a (length = number of already-visited namespaces).

This happens at every recursive call inside inner_get_constants, which visits every namespace in the inheritance chain. For a project with N namespaces of average depth D and gate chain length G:

Total work = N × D × G × (D/2) = O(N ×× G)

For a large Rails application (N ≈ 500, D ≈ 5, G ≈ 3): ~1.8 million string comparisons per get_constants call. get_constants is called during every autocomplete and hover event in the LSP.

Fix

Replace Array subtraction with a filter that checks Set membership:

# line 262 — before
pre_fqns = resolve(pre.name, pre.closure.gates - skip.to_a)

# line 262 — after
pre_fqns = resolve(pre.name, pre.closure.gates.reject { |g| skip.include?(g) })

# line 267 — before
inc_fqns = resolve(pin.name, pin.closure.gates - skip.to_a)

# line 267 — after
inc_fqns = resolve(pin.name, pin.closure.gates.reject { |g| skip.include?(g) })

Set#include? is O(1). The reject is O(G) rather than O(G × |skip|). Total complexity drops to O(N × D × G).

Alternatively, maintain skip as an Array from the start (so no .to_a is needed) and use a parallel Set for the O(1) membership check.

Speedup estimate

210× faster constant resolution on large Rails/Hanami projects with deep namespace hierarchies. Most noticeable as reduced LSP hover/completion latency on initial workspace index.

Work required

  • Patch in defects/solargraph/patch/
  • Unit test — asserts operation counts before/after using a test namespace graph
  • Integration test
  • Benchmark — before/after on a project with N=100 and N=500 namespaces
  • White paper section