@@ -50,6 +50,34 @@ def _fingerprint(root: Path) -> str:
5050 return hashlib .sha256 ("\n " .join (entries ).encode ("utf-8" )).hexdigest ()
5151
5252
53+ def _cheap_signature (root : Path ) -> str :
54+ """A cheap staleness pre-check: SHA-256 over the sorted
55+ (relative-path, mtime_ns, size) triples of the Python tree. This is
56+ stat-only (no file reads), so it is O(files) rather than the
57+ O(files * bytes) of [`_fingerprint`]. Any added or removed .py file moves
58+ the set of paths, and any write moves that file's mtime, so the signature
59+ moves; a stable tree keeps it identical. It is used only as a FAST PATH
60+ before the authoritative full-content fingerprint: a match means "nothing
61+ stat-visible changed, skip the full re-read"; a mismatch always falls back
62+ to [`_fingerprint`], which stays the source of truth. (The one thing a
63+ stat-only check cannot see is a content edit that preserves both size and
64+ mtime to the nanosecond, which a normal filesystem write never does.)"""
65+ root = Path (root ).resolve ()
66+ entries : list [str ] = []
67+ for py in walk_files (root , suffixes = (".py" ,)):
68+ try :
69+ rel = py .relative_to (root ).as_posix ()
70+ except ValueError :
71+ rel = py .as_posix ()
72+ try :
73+ st = py .stat ()
74+ entries .append (f"{ rel } :{ st .st_mtime_ns } :{ st .st_size } " )
75+ except OSError :
76+ entries .append (f"{ rel } :unstattable" )
77+ entries .sort ()
78+ return hashlib .sha256 ("\n " .join (entries ).encode ("utf-8" )).hexdigest ()
79+
80+
5381class LSPServer :
5482 """A single-workspace language server over the wave-1 symbol graph."""
5583
@@ -58,6 +86,7 @@ def __init__(self, root: Path, trace: str = "off") -> None:
5886 self .trace = trace
5987 self .symbol_graph : SymbolGraph | None = None
6088 self .fingerprint : str | None = None
89+ self .cheap_sig : str | None = None
6190 self .should_exit = False
6291 self ._shutdown = False
6392
@@ -67,12 +96,32 @@ def _build(self) -> None:
6796 """Build (or rebuild) the symbol graph and pin the current fingerprint."""
6897 self .symbol_graph = build_symbol_graph (self .root )
6998 self .fingerprint = _fingerprint (self .root )
99+ self .cheap_sig = _cheap_signature (self .root )
70100
71101 def is_stale (self ) -> bool :
72- """True when the Python tree changed on disk since the last build."""
102+ """True when the Python tree changed on disk since the last build.
103+
104+ Fail-closed and fast in the common case: a cheap stat-only signature is
105+ checked first, and only when it moves does the authoritative full-content
106+ fingerprint run. IDEs issue definition/references on every hover and
107+ click, so the unchanged-tree path (the overwhelming majority) avoids
108+ re-reading and re-hashing every file.
109+ """
73110 if self .fingerprint is None :
74111 return False
75- return _fingerprint (self .root ) != self .fingerprint
112+ cheap_now = _cheap_signature (self .root )
113+ if cheap_now == self .cheap_sig :
114+ return False # fast path: nothing stat-visible changed
115+ # The cheap signature moved; the full-content fingerprint is the
116+ # authority and decides staleness (fail-closed on a real change).
117+ if _fingerprint (self .root ) != self .fingerprint :
118+ return True
119+ # mtime/size moved but content is byte-identical (e.g. a touch or a
120+ # rewrite with the same bytes): not stale. Refresh the cheap signature
121+ # so the next request takes the fast path instead of re-running the
122+ # full check every time.
123+ self .cheap_sig = cheap_now
124+ return False
76125
77126 # --- dispatch ------------------------------------------------------------
78127
0 commit comments