Skip to main content

devscout_rs/
graph.rs

1// Artifact build/load, fragments cache, incremental reuse.
2// Writes are atomic (tmp+rename); fragments cache keying is load-bearing --
3// a mismatch makes reuse break silently.
4//
5// This module owns every serde struct for graph.json + the fragments-cache
6// pair (fragments-v18.json, fragments-index-v18.json), plus their path resolution,
7// atomic I/O, and the cache-then-resolve-then-write orchestration
8// (`rebuild_graph`). The pure resolution ladder that
9// turns fragments into `defs`/`edges` lives in `resolve.rs` and returns the
10// `Graph` value this module serializes -- see that module for the ladder
11// itself.
12//
13// Schema notes (the on-disk format graph.json must keep):
14//   - graph.json is compact JSON with NO indentation:
15//     `{"key":value,...}`, no spaces. `serde_json::to_vec` (non-pretty)
16//     matches this by default.
17//   - Key order is insertion order, not sorted. Defs keep first-insertion
18//     order (partial-class duplicates land in
19//     `also_in`, not a second top-level entry) -- backed here by a
20//     Vec<Def> + HashMap<id, index> pair (`build_def_index` in resolve.rs)
21//     rather than a HashMap alone, which has no defined iteration order.
22//   - `also_in` is omitted entirely (not `[]`) when a def has no additional
23//     declaring sites -- `#[serde(skip_serializing_if = "Vec::is_empty")]`.
24//   - `edges` are shape-heterogeneous by `kind`: inherits/uses-type/
25//     uses-member share `{kind, from_file, from_line, to, to_file}`;
26//     imports is `{kind, from_file, from_line, target}`; ambiguous is
27//     `{kind, origin, from_file, from_line, raw, candidates,
28//     candidate_count}`. Modeled as an internally-tagged enum
29//     (`#[serde(tag = "kind")]`) -- serde always emits the tag field first,
30//     matching every one of these shapes' field order.
31//   - a `uses-member` edge appends three more keys AFTER that shared prefix,
32//     in this exact order: `heuristic` (omitted when precise), `tier`, then
33//     `member`. `tier` names WHICH guess tier emitted the row and is present
34//     exactly when `heuristic` is; `member` names the member the reference
35//     reads or calls and is written on EVERY uses-member edge, the precise
36//     ones included -- it is the one fact the row never carried. Both are
37//     omit-when-`None`, so the append-last rule every other added key follows
38//     holds here too; the other two flagged kinds (inherits, uses-type) stay
39//     exactly as they were.
40//   - `schema_version` is `GRAPH_SCHEMA_VERSION`, which the `tier`/`member`
41//     append above moved to 2. `rebuild_graph`'s unchanged fast path reads
42//     the first bytes of an existing graph.json and refuses to reuse one
43//     written at an older version, so a stale artifact is rebuilt on the next
44//     `map` even when not one fragment moved.
45//   - `units` is the LAST key of the whole object, appended after `names`
46//     and omitted entirely (not `[]`) when the repo declares no `.csproj` --
47//     so a tree without one serializes byte-for-byte as it did before the
48//     project model existed. Each row's own key order is fixed too: `id`,
49//     `name`, then `refs` (omitted when the project references nothing) and
50//     `test` (omitted when false). A unit's DIRECTORY is not persisted: it
51//     is `id`'s parent, recomputed on read (`project::units_from_graph`),
52//     and neither is per-file/per-def membership -- that is derived from the
53//     unit list by `ProjectModel` rather than stored.
54//   - `stats.edges_by_kind` has a FIXED key order (inherits, uses-type,
55//     imports, uses-member, ctor-di) -- not
56//     alphabetical, not insertion order of first edge seen. A plain struct
57//     with that declared field order reproduces it.
58//   - `stats.ambiguous_pct` is `round(x*1000)/10`: a number that prints
59//     WITHOUT a decimal point when whole (`20`, not `20.0`) and with
60//     exactly one decimal digit otherwise (`33.3`). Rust's f64 Serialize
61//     (ryu-backed) does not reproduce this -- see `Percent1` below, which
62//     stores tenths as an integer and branches serialize_i64/serialize_f64
63//     so whole values come out as bare integers, without needing serde_json's
64//     `arbitrary_precision`/`raw_value` features (neither is enabled).
65//   - fragments.json / fragments-index.json are objects keyed by
66//     repo-relative path, in file-walk order (fragments-index.json is
67//     rebuilt by iterating the same cache object fragments.json used).
68//     A `HashMap` has no serialization order guarantee and `serde_json`'s
69//     default `Map` (no `preserve_order` feature, which is off here) sorts
70//     by key -- neither matches. `OrderedMap<V>` below hand-rolls
71//     Serialize/Deserialize (using serde's own `SerializeMap` / `MapAccess`
72//     traits, not a bespoke JSON writer) to preserve insertion order.
73//   - `built_at_head` records `git -C <root> rev-parse HEAD` (`null` on any
74//     failure incl. no commits yet). `manifest.rs` already owns that
75//     (`manifest::git_head`) -- `resolve_graph` (resolve.rs) calls THAT, so
76//     this module does not duplicate it.
77
78use std::collections::HashMap;
79use std::fmt;
80use std::fs;
81use std::io;
82use std::io::Read;
83use std::marker::PhantomData;
84use std::path::{Path, PathBuf};
85use std::sync::atomic::{AtomicU64, Ordering};
86
87use serde::de::{MapAccess, Visitor};
88use serde::ser::SerializeMap;
89use serde::{Deserialize, Deserializer, Serialize, Serializer};
90
91use crate::extract;
92use crate::repo::{git_common_dir, scout_dir};
93
94// ---------------------------------------------------------------------------
95// Path resolution (private helpers for the graph directory and cache paths).
96// ---------------------------------------------------------------------------
97
98// The def/ref graph lives in the git COMMON dir (shared by every linked
99// worktree, alongside the manifest), one level down; a non-git root falls
100// back to `<root>/.scout/graph/`.
101fn graph_dir(root: &Path) -> PathBuf {
102    match git_common_dir(root) {
103        Some(common) => common.join("scout").join("graph"),
104        None => scout_dir(root).join("graph"),
105    }
106}
107
108/// Path to the graph artifact (`graph.json`) for `root`.
109pub fn graph_json_path(root: &Path) -> PathBuf {
110    graph_dir(root).join("graph.json")
111}
112
113/// Path to the project-model staleness sidecar for `root`.
114///
115/// Holds exactly the bytes `graph.json`'s `units` array would carry -- the
116/// serialized `Vec<GraphUnit>` and nothing else. `map` compares this file
117/// against a freshly discovered model to notice a `.csproj` edit, which no
118/// mtime in the fragments index can see: `.csproj` is not a `SOURCE_EXT`, so
119/// editing one moves no graph file and `index_is_stale` stays false. Written
120/// by `rebuild_graph` only when a model exists and DELETED when one does not,
121/// so a repo that never had a `.csproj` never grows the file and one whose
122/// last `.csproj` was removed still sees a difference on the next run.
123pub fn project_units_path(root: &Path) -> PathBuf {
124    graph_dir(root).join("project-units.json")
125}
126
127// The version in both cache filenames is the fragment SCHEMA version, bumped
128// whenever the extractor starts recording something old cached fragments
129// lack -- v10 added def `type_params`/`base_generic_args` and the new
130// `ctor-param` ref kind (with its `args` field), v11 added markup graph
131// facts (a `.xaml` file's fragment now carries the `x:Class` def and its
132// element/`x:Bind` refs, where every cached markup fragment before it carried
133// names only), v12 added the TS/JS reference fragment, v13 added def
134// `propertyTypes` and ref `receiverPropertyOwner` plus the ref
135// `receiverCallOwner`/`receiverCallMember` pair, plus the foreach
136// element-type fact -- no new field (it settles into the existing
137// `receiver_type`), but a cached fragment from before it can still disagree
138// with a fresh one for the same unchanged file, so it rides the same bump
139// rather than skipping it. v16 added the ref `receiverBase` flag, set only
140// for a `base.` qualifier -- a cached v15 fragment carries none, so every
141// `base.` receiver would silently resolve (or fail to resolve) as if it
142// were a plain `this.` receiver. v17 added no field: the extractor now
143// blanks the inactive arms of `#if`/`#else` groups before parsing, so a
144// cached v16 fragment of an unchanged file can carry twin defs and refs
145// from both arms that a fresh one no longer records -- the same
146// "disagrees for an unchanged file" case that moved v13, so it rides a
147// bump too. v18 added def `methodParams` and ref `receiverLambda`, the
148// untyped-lambda-parameter callee slot -- a cached v17 fragment carries
149// neither, so every per-overload parameter shape and every untyped-lambda
150// callee-slot lookup they back would silently see no candidates. The
151// rename IS the invalidation mechanism:
152// pre-bump caches stop being found, every file reparses
153// once, no reader carries version-compat logic. Writers delete every
154// superseded generation (see `remove_superseded_caches`).
155fn fragments_cache_path(root: &Path) -> PathBuf {
156    graph_dir(root).join("fragments-v18.json")
157}
158
159fn fragments_index_path(root: &Path) -> PathBuf {
160    graph_dir(root).join("fragments-index-v18.json")
161}
162
163// Every generation below the current one, not just the immediately previous:
164// a repo mapped last under v1 and never since would otherwise keep its v1
165// pair forever.
166const SUPERSEDED_CACHE_FILES: &[&str] = &[
167    "fragments.json",
168    "fragments-index.json",
169    "fragments-v2.json",
170    "fragments-index-v2.json",
171    "fragments-v3.json",
172    "fragments-index-v3.json",
173    "fragments-v4.json",
174    "fragments-index-v4.json",
175    "fragments-v5.json",
176    "fragments-index-v5.json",
177    "fragments-v6.json",
178    "fragments-index-v6.json",
179    "fragments-v7.json",
180    "fragments-index-v7.json",
181    "fragments-v8.json",
182    "fragments-index-v8.json",
183    "fragments-v9.json",
184    "fragments-index-v9.json",
185    "fragments-v10.json",
186    "fragments-index-v10.json",
187    "fragments-v11.json",
188    "fragments-index-v11.json",
189    "fragments-v12.json",
190    "fragments-index-v12.json",
191    "fragments-v13.json",
192    "fragments-index-v13.json",
193    "fragments-v14.json",
194    "fragments-index-v14.json",
195    "fragments-v15.json",
196    "fragments-index-v15.json",
197    "fragments-v16.json",
198    "fragments-index-v16.json",
199    "fragments-v17.json",
200    "fragments-index-v17.json",
201];
202
203fn remove_superseded_caches(root: &Path) {
204    for name in SUPERSEDED_CACHE_FILES {
205        // Best-effort cleanup only -- a leftover superseded file is inert.
206        let _ = fs::remove_file(graph_dir(root).join(name));
207    }
208}
209
210// ---------------------------------------------------------------------------
211// Atomic writes -- tmp file in the SAME directory as the target (so
212// the final `rename` is same-filesystem and therefore atomic), unique name
213// so concurrent writers (this process's own sequential graph/fragments/index
214// writes, or a genuinely concurrent process) never collide. Writing to a
215// tmp file and renaming is a deliberate upgrade over an in-place write --
216// output BYTES are unaffected, only the write's crash-safety.
217// ---------------------------------------------------------------------------
218
219static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
220
221fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
222    let dir = path.parent().unwrap_or_else(|| Path::new("."));
223    fs::create_dir_all(dir)?;
224    let file_name = path
225        .file_name()
226        .and_then(|n| n.to_str())
227        .unwrap_or("artifact");
228    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
229    let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
230    let write_result = fs::write(&tmp_path, bytes);
231    if write_result.is_err() {
232        let _ = fs::remove_file(&tmp_path);
233        write_result?;
234    }
235    fs::rename(&tmp_path, path)?;
236    Ok(())
237}
238
239fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
240    let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
241    atomic_write_bytes(path, &bytes)
242}
243
244// ---------------------------------------------------------------------------
245// Insertion-ordered string-keyed map -- backs fragments.json's `{rel:
246// {mtime, fragment}}` and fragments-index.json's `{rel: mtime}`. A HashMap
247// has no defined iteration order and serde_json's default Map serializer
248// (the `preserve_order` feature is not enabled) sorts by key; neither
249// matches the required file-walk-order construction. Built on serde's own
250// SerializeMap/MapAccess traits,
251// not a hand-rolled JSON writer -- serde_json still owns the actual byte
252// encoding.
253// ---------------------------------------------------------------------------
254
255#[derive(Debug, Clone, PartialEq)]
256/// Represents `OrderedMap`.
257pub struct OrderedMap<V> {
258    entries: Vec<(String, V)>,
259    index: HashMap<String, usize>,
260}
261
262impl<V> OrderedMap<V> {
263    /// Creates an empty insertion-ordered map.
264    pub fn new() -> Self {
265        Self {
266            entries: Vec::new(),
267            index: HashMap::new(),
268        }
269    }
270
271    /// Insert, or overwrite in place when the key already exists --
272    /// preserves that key's ORIGINAL position, like assigning to an existing
273    /// key on a JSON object.
274    pub fn insert(&mut self, key: String, value: V) {
275        match self.index.get(&key) {
276            Some(&i) => self.entries[i].1 = value,
277            None => {
278                self.index.insert(key.clone(), self.entries.len());
279                self.entries.push((key, value));
280            }
281        }
282    }
283
284    /// Returns the value associated with `key`, if present.
285    pub fn get(&self, key: &str) -> Option<&V> {
286        self.index.get(key).map(|&i| &self.entries[i].1)
287    }
288
289    /// Returns the number of entries.
290    pub fn len(&self) -> usize {
291        self.entries.len()
292    }
293
294    /// Returns whether the map contains no entries.
295    pub fn is_empty(&self) -> bool {
296        self.entries.is_empty()
297    }
298
299    /// Iterates over entries in insertion order.
300    pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
301        self.entries.iter().map(|(k, v)| (k, v))
302    }
303}
304
305impl<V> Default for OrderedMap<V> {
306    fn default() -> Self {
307        Self::new()
308    }
309}
310
311impl<V: Serialize> Serialize for OrderedMap<V> {
312    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
313        let mut map = serializer.serialize_map(Some(self.entries.len()))?;
314        for (k, v) in &self.entries {
315            map.serialize_entry(k, v)?;
316        }
317        map.end()
318    }
319}
320
321impl<'de, V: Deserialize<'de>> Deserialize<'de> for OrderedMap<V> {
322    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
323        struct OrderedMapVisitor<V>(PhantomData<V>);
324
325        impl<'de, V: Deserialize<'de>> Visitor<'de> for OrderedMapVisitor<V> {
326            type Value = OrderedMap<V>;
327
328            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
329                f.write_str("a JSON object")
330            }
331
332            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
333                let mut out = OrderedMap::new();
334                while let Some((k, v)) = map.next_entry::<String, V>()? {
335                    out.insert(k, v);
336                }
337                Ok(out)
338            }
339        }
340
341        deserializer.deserialize_map(OrderedMapVisitor(PhantomData))
342    }
343}
344
345// ---------------------------------------------------------------------------
346// `stats.ambiguous_pct` -- see module header. Stored as tenths (an i64) so
347// the "is this whole?" branch is exact integer arithmetic, never a float
348// epsilon comparison.
349// ---------------------------------------------------------------------------
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352/// Represents `Percent1`.
353pub struct Percent1(pub i64);
354
355impl Percent1 {
356    /// Returns a zero percentage value.
357    pub fn zero() -> Self {
358        Percent1(0)
359    }
360
361    /// `ambiguous_pct` as `round((ambiguous / attempts) * 1000) / 10`, or 0
362    /// when there are no attempts. Takes the pre-division inputs so the
363    /// rounding happens exactly once.
364    pub fn from_ratio(numerator: usize, denominator: usize) -> Self {
365        if denominator == 0 {
366            return Percent1::zero();
367        }
368        let raw = (numerator as f64 / denominator as f64) * 1000.0;
369        Percent1(raw.round() as i64)
370    }
371}
372
373impl Serialize for Percent1 {
374    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
375        if self.0 % 10 == 0 {
376            serializer.serialize_i64(self.0 / 10)
377        } else {
378            serializer.serialize_f64(self.0 as f64 / 10.0)
379        }
380    }
381}
382
383impl<'de> Deserialize<'de> for Percent1 {
384    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
385        let v = f64::deserialize(deserializer)?;
386        Ok(Percent1((v * 10.0).round() as i64))
387    }
388}
389
390// ---------------------------------------------------------------------------
391// graph.json schema.
392// ---------------------------------------------------------------------------
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
395/// Represents `AlsoIn`.
396pub struct AlsoIn {
397    /// The file value.
398    pub file: String,
399    /// The line value.
400    pub line: usize,
401}
402
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
404/// Represents `Def`.
405pub struct Def {
406    /// The id value.
407    pub id: String,
408    /// The name value.
409    pub name: String,
410    /// The namespace value.
411    pub namespace: String,
412    /// The kind value.
413    pub kind: String,
414    /// The file value.
415    pub file: String,
416    /// The line value.
417    pub line: usize,
418    /// The methods value.
419    pub methods: Vec<String>,
420    /// Test-coverage stage -- the one member fact that SURVIVES onto disk
421    /// (`properties`/`fields`/`extensionMethods`/`bases` are resolution inputs
422    /// only, stripped before the row is written): `devscout tests` reads it back
423    /// out of graph.json. Positioned after `methods` and before `also_in`, and
424    /// omitted when empty, so a def declaring no tests keeps its exact
425    /// pre-stage bytes.
426    #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
427    pub test_methods: Vec<String>,
428    #[serde(default, skip_serializing_if = "Vec::is_empty")]
429    /// The also in value.
430    pub also_in: Vec<AlsoIn>,
431    #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
432    /// The end line value.
433    pub end_line: usize,
434}
435
436#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
437/// Represents `Candidate`.
438pub struct Candidate {
439    /// The id value.
440    pub id: String,
441    /// The file value.
442    pub file: String,
443}
444
445/// Which heuristic tier emitted a guess.
446///
447/// The two differ by an order of magnitude in precision and `heuristic: true`
448/// alone cannot tell them apart: `Ext` is C#'s own extension-method lookup run over a recorded
449/// `(member, this-type)` bucket -- a real rule, only unverifiable against an
450/// out-of-graph receiver -- while `Guess` is the scored tier picking by NAME
451/// among the defs that happen to declare a member so called.
452#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
453#[serde(rename_all = "lowercase")]
454pub enum HeuristicTier {
455    /// Tier (f): extension-method lookup.
456    Ext,
457    /// The scored tier: a name guess.
458    Guess,
459}
460
461/// The guess tag, appended LAST on every edge kind that can
462/// carry it. `heuristic: true` is set only on an edge a heuristic tier
463/// emitted and never writes `heuristic: false`, so this side pairs
464/// `skip_serializing_if` with `default`: absent == precise, and a precise
465/// edge's bytes are exactly what they were before the tag existed. Only
466/// the resolver's heuristic tiers set it today (both
467/// `uses-member`), but the flag lives on all three targeted kinds because
468/// the query layer's heuristic adjacency is kind-keyed, so a future tier
469/// tagging a `uses-type` edge needs no schema change.
470///
471/// `uses-member` carries two more appended keys, `tier` then `member`, in
472/// that order and NOT mirrored onto the other two kinds: neither has a
473/// member to name, and no tier tags one today. `tier` splits the umbrella
474/// flag into the two tiers a reader can act on differently; `member` names
475/// the member the reference reads or calls and is written on every
476/// uses-member edge, precise ones included, because "which member" is a fact
477/// about the reference rather than about the guess. Both are
478/// omit-when-`None`, so a reader of the old shape sees only added keys.
479///
480/// The next slot on this variant is reserved for `source: Option<Provenance>`
481/// (`"semantic"` for an edge a real compiler vouched for), appended after
482/// `member` and omitted when empty, on the same rule.
483#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
484#[serde(tag = "kind")]
485pub enum Edge {
486    #[serde(rename = "inherits")]
487    /// Represents `Inherits`.
488    Inherits {
489        /// The value value.
490        from_file: String,
491        /// The value value.
492        from_line: usize,
493        /// The value value.
494        to: String,
495        /// The value value.
496        to_file: String,
497        #[serde(default, skip_serializing_if = "is_false")]
498        /// The value value.
499        heuristic: bool,
500    },
501    #[serde(rename = "uses-type")]
502    /// Represents `UsesType`.
503    UsesType {
504        /// The value value.
505        from_file: String,
506        /// The value value.
507        from_line: usize,
508        /// The value value.
509        to: String,
510        /// The value value.
511        to_file: String,
512        #[serde(default, skip_serializing_if = "is_false")]
513        /// The value value.
514        heuristic: bool,
515    },
516    #[serde(rename = "uses-member")]
517    /// Represents `UsesMember`.
518    UsesMember {
519        /// The value value.
520        from_file: String,
521        /// The value value.
522        from_line: usize,
523        /// The value value.
524        to: String,
525        /// The value value.
526        to_file: String,
527        #[serde(default, skip_serializing_if = "is_false")]
528        /// The value value.
529        heuristic: bool,
530        #[serde(default, skip_serializing_if = "Option::is_none")]
531        /// Which guess tier emitted this edge -- `Some` exactly when
532        /// `heuristic` is true. Build the variant through
533        /// `Edge::uses_member`, which derives one from the other.
534        tier: Option<HeuristicTier>,
535        #[serde(default, skip_serializing_if = "Option::is_none")]
536        /// The member this reference reads or calls, on precise and
537        /// heuristic edges alike. `None` only for a reference the extractor
538        /// recorded no member name for.
539        member: Option<String>,
540    },
541    #[serde(rename = "imports")]
542    /// The value value.
543    Imports {
544        /// The source file.
545        from_file: String,
546        /// The source line.
547        from_line: usize,
548        /// The imported target.
549        target: String,
550    },
551    #[serde(rename = "ambiguous")]
552    /// Represents `Ambiguous`.
553    Ambiguous {
554        /// The value value.
555        origin: String,
556        /// The value value.
557        from_file: String,
558        /// The value value.
559        from_line: usize,
560        /// The value value.
561        raw: String,
562        /// The value value.
563        candidates: Vec<Candidate>,
564        /// The value value.
565        candidate_count: usize,
566    },
567    /// Constructor-parameter DI resolution (see resolve.rs's
568    /// `resolve_ctor_param`). Field order (`kind`, `from_file`, `from_line`,
569    /// `iface`, `resolution`, `args`, `to`, `candidates`) is significant --
570    /// it fixes the serialized field order.
571    /// `iface` is the injected type's bare identifier; `args` its closed
572    /// generic arguments, present only when it has any; `to` the resolved
573    /// implementation's def id, present only for 'plain'/'closed'/
574    /// 'open-generic'; `candidates` the capped, sorted tie list, present only
575    /// for 'ambiguous'.
576    #[serde(rename = "ctor-di")]
577    CtorDi {
578        /// The value value.
579        from_file: String,
580        /// The value value.
581        from_line: usize,
582        /// The value value.
583        iface: String,
584        /// The value value.
585        resolution: String,
586        #[serde(default, skip_serializing_if = "Option::is_none")]
587        /// The value value.
588        args: Option<Vec<String>>,
589        #[serde(default, skip_serializing_if = "Option::is_none")]
590        /// The value value.
591        to: Option<String>,
592        #[serde(default, skip_serializing_if = "Vec::is_empty")]
593        /// The value value.
594        candidates: Vec<Candidate>,
595    },
596    /// A TS/TSX module import. Distinct from `imports` (C#'s
597    /// `using` directive, which names a NAMESPACE and resolves to no file):
598    /// `target` is the specifier as written and `to_file` the file it
599    /// resolved to. `via` is appended LAST and present only on a
600    /// barrel-followed edge, naming the module the source literally imported
601    /// so a reader can tell the routing table apart from the dependency.
602    #[serde(rename = "import")]
603    Import {
604        /// The value value.
605        from_file: String,
606        /// The value value.
607        from_line: usize,
608        /// The value value.
609        target: String,
610        /// The value value.
611        to_file: String,
612        #[serde(default, skip_serializing_if = "Option::is_none")]
613        /// The value value.
614        via: Option<String>,
615    },
616    /// A call or `new` naming an imported or locally-exported
617    /// declaration.
618    #[serde(rename = "call")]
619    /// The value value.
620    Call {
621        /// The source file.
622        from_file: String,
623        /// The source line.
624        from_line: usize,
625        /// The target definition identifier.
626        to: String,
627        /// The target file.
628        to_file: String,
629    },
630    /// A JSX tag naming a component declaration.
631    #[serde(rename = "jsx-use")]
632    /// The value value.
633    JsxUse {
634        /// The source file.
635        from_file: String,
636        /// The source line.
637        from_line: usize,
638        /// The target definition identifier.
639        to: String,
640        /// The target file.
641        to_file: String,
642    },
643    /// An action creator or thunk handed to a dispatching call
644    /// (`dispatch(...)`, `ofType(...)`).
645    #[serde(rename = "dispatch")]
646    /// The value value.
647    Dispatch {
648        /// The source file.
649        from_file: String,
650        /// The source line.
651        from_line: usize,
652        /// The target definition identifier.
653        to: String,
654        /// The target file.
655        to_file: String,
656    },
657}
658
659impl Edge {
660    /// The guess tier that emitted this edge, or `None` on a precise one --
661    /// and on every kind that carries no tier at all, which is every kind but
662    /// `uses-member`.
663    pub fn tier(&self) -> Option<HeuristicTier> {
664        match self {
665            Edge::UsesMember { tier, .. } => *tier,
666            _ => None,
667        }
668    }
669
670    /// Whether the edge declares itself a guess, across the three kinds that
671    /// can carry the flag.
672    pub fn is_heuristic(&self) -> bool {
673        match self {
674            Edge::Inherits { heuristic, .. }
675            | Edge::UsesType { heuristic, .. }
676            | Edge::UsesMember { heuristic, .. } => *heuristic,
677            _ => false,
678        }
679    }
680
681    /// The one way to build a `uses-member` edge. `heuristic` is DERIVED from
682    /// `tier` rather than passed alongside it, so the two cannot disagree: a
683    /// tagged edge is always flagged, a flagged edge always names its tier,
684    /// and no emit site can grow a third state by forgetting a field.
685    pub fn uses_member(
686        from_file: String,
687        from_line: usize,
688        to: String,
689        to_file: String,
690        member: Option<String>,
691        tier: Option<HeuristicTier>,
692    ) -> Edge {
693        Edge::UsesMember {
694            from_file,
695            from_line,
696            to,
697            to_file,
698            heuristic: tier.is_some(),
699            tier,
700            member,
701        }
702    }
703}
704
705#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
706/// Represents `EdgesByKind`.
707pub struct EdgesByKind {
708    /// The inherits value.
709    pub inherits: usize,
710    #[serde(rename = "uses-type")]
711    /// The uses type value.
712    pub uses_type: usize,
713    /// The imports value.
714    pub imports: usize,
715    #[serde(rename = "uses-member")]
716    /// The uses member value.
717    pub uses_member: usize,
718    /// Appended LAST, matching the `ctor-di` slot in the fixed key order.
719    #[serde(rename = "ctor-di")]
720    pub ctor_di: usize,
721    /// The four TS edge counts, appended after `ctor-di` in this exact order
722    /// and ONLY when the repo carries a TS fragment at all. A C#-only repo's
723    /// stats block omits them entirely -- which is why these are `Option` and
724    /// not a plain `0`.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub import: Option<usize>,
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    /// The call value.
729    pub call: Option<usize>,
730    #[serde(rename = "jsx-use", default, skip_serializing_if = "Option::is_none")]
731    /// The jsx use value.
732    pub jsx_use: Option<usize>,
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    /// The dispatch value.
735    pub dispatch: Option<usize>,
736}
737
738/// `heuristic_edge_count` split by the tier that emitted each edge, in the
739/// fixed key order every tier-keyed output uses (ext, then guess).
740///
741/// Both keys are always written, and `ext + guess` equals `heuristic_edge_count` --
742/// including after the heuristic-side dedup, which decrements the dropped
743/// edge's own tier.
744#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize)]
745pub struct HeuristicByTier {
746    /// Edges tier (f) emitted.
747    pub ext: usize,
748    /// Edges the scored tier emitted.
749    pub guess: usize,
750}
751
752#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
753/// Represents `Stats`.
754pub struct Stats {
755    /// The def count value.
756    pub def_count: usize,
757    /// The file count value.
758    pub file_count: usize,
759    /// The edges by kind value.
760    pub edges_by_kind: EdgesByKind,
761    /// The ambiguous count value.
762    pub ambiguous_count: usize,
763    /// The ambiguous pct value.
764    pub ambiguous_pct: Percent1,
765    /// The unresolved external count value.
766    pub unresolved_external_count: usize,
767    /// Appended LAST, and always serialized (unlike the edge flag above): the
768    /// key is written unconditionally, so a graph with no guesses still
769    /// carries `"heuristic_edge_count":0`. `default` is for the READ side only
770    /// -- a graph.json written before this counter existed has no such key,
771    /// and it must read back as 0 rather than fail to parse.
772    #[serde(default)]
773    pub heuristic_edge_count: usize,
774    /// Test-coverage stage -- appended LAST and always serialized, like the
775    /// stage-4 counter above it. Counts merged DEF ROWS, not fragment entries,
776    /// so a partial test class split across two files is one test def.
777    #[serde(default)]
778    pub test_def_count: usize,
779    /// The TS resolver's own four counters, omitted entirely when the repo
780    /// carries no TS fragment (the same omit-when-empty rule every other
781    /// appended fact follows). Appended after `test_def_count`, and NOT last
782    /// any more: `heuristic_by_tier` below is the newer fact and takes the
783    /// tail, so a TS repo's stats block appends in the order the facts were
784    /// added rather than interleaving the newest one before an older key.
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub ts: Option<crate::tsgraph::TsStats>,
787    /// The tier split of `heuristic_edge_count`, appended LAST and always
788    /// serialized, like the two counters above it. `default` is for the READ
789    /// side only: a graph.json written before the tiers existed has no such
790    /// key and must read back as two zeros rather than fail to parse.
791    ///
792    /// A C#-only repo writes no `ts` key at all, so for such a tree this key
793    /// still follows `test_def_count` directly and the bytes are unchanged.
794    #[serde(default)]
795    pub heuristic_by_tier: HeuristicByTier,
796}
797
798/// One row of the full name index. Field order (`name`, `kind`,
799/// `file`, `line`, `owner`) is significant; `owner` carries the
800/// declaring type's def id for a member and is omitted for a type, an enum
801/// member, and every markup or resource key.
802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
803pub struct GraphName {
804    /// The name value.
805    pub name: String,
806    /// The kind value.
807    pub kind: String,
808    /// The file value.
809    pub file: String,
810    /// The line value.
811    pub line: usize,
812    #[serde(default, skip_serializing_if = "String::is_empty")]
813    /// The owner value.
814    pub owner: String,
815}
816
817/// One `.csproj` project as graph.json persists it.
818///
819/// Field order (`id`, `name`, `refs`, `test`) is significant, and the last two are
820/// omit-when-empty/omit-when-false: a leaf project that references nothing
821/// and is not a test project serializes as just its `id` and `name`.
822///
823/// Deliberately NOT `project::Unit`: that type also carries `dir`, which is
824/// always `id`'s parent directory and so is recomputed on read rather than
825/// stored (`project::units_from_graph`). Nothing about which FILE belongs to
826/// which unit is persisted either -- `ProjectModel` derives that from the
827/// unit list.
828#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
829pub struct GraphUnit {
830    /// Repo-relative path to the `.csproj` file, which is also this unit's
831    /// identity -- what `refs` entries name.
832    pub id: String,
833    /// The project name (the csproj file name without its extension).
834    pub name: String,
835    /// The `id`s of this project's DIRECT `ProjectReference` targets, not
836    /// transitively closed. Omitted when empty.
837    #[serde(default, skip_serializing_if = "Vec::is_empty")]
838    pub refs: Vec<String>,
839    /// Whether this is a test project. Omitted when false.
840    #[serde(default, skip_serializing_if = "is_not_test")]
841    pub test: bool,
842}
843
844fn is_not_test(b: &bool) -> bool {
845    !*b
846}
847
848/// The version stamped into every graph.json this build writes, and the one
849/// `rebuild_graph` demands before it reuses an artifact it did not just
850/// produce.
851///
852/// Bumped to 2 when `uses-member` edges gained `tier` and `member`:
853/// a schema-1 graph is READABLE (both keys default) but it is missing facts
854/// the query layer now reports, so it gets rebuilt rather than trusted.
855pub const GRAPH_SCHEMA_VERSION: u32 = 2;
856
857#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
858/// Represents `Graph`.
859pub struct Graph {
860    /// The schema version value.
861    pub schema_version: u32,
862    /// The built at head value.
863    pub built_at_head: Option<String>,
864    /// The defs value.
865    pub defs: Vec<Def>,
866    /// The edges value.
867    pub edges: Vec<Edge>,
868    /// The stats value.
869    pub stats: Stats,
870    /// Appended LAST, after `stats`, and omitted when empty: the
871    /// house rule for every added field, and what keeps a graph built over a
872    /// set that declares no name byte-identical to what it was.
873    #[serde(default, skip_serializing_if = "Vec::is_empty")]
874    pub names: Vec<GraphName>,
875    /// The repo's `.csproj` projects, sorted by `id` -- appended LAST, after
876    /// `names`, and omitted entirely when the repo declares none. A tree with
877    /// no `.csproj` therefore serializes exactly as it did before the project
878    /// model existed.
879    #[serde(default, skip_serializing_if = "Vec::is_empty")]
880    pub units: Vec<GraphUnit>,
881}
882
883/// Reads and deserializes the repository graph, returning `None` on failure.
884pub fn read_graph(root: &Path) -> Option<Graph> {
885    let text = fs::read_to_string(graph_json_path(root)).ok()?;
886    serde_json::from_str(&text).ok()
887}
888
889fn write_graph(root: &Path, graph: &Graph) -> io::Result<()> {
890    atomic_write_json(&graph_json_path(root), graph)
891}
892
893// ---------------------------------------------------------------------------
894// Fragments cache schema -- the RAW (unresolved) per-file extraction output,
895// keyed by repo-relative path. Mirrors extract.rs's DefRecord/UsingRecord/
896// RefRecord field-for-field; the serde impls live here rather than on the
897// extractor's own types.
898// ---------------------------------------------------------------------------
899
900/// Field order is significant: id, name, namespace, kind, line, methods, then
901/// the member-fact additions in that exact order -- properties, fields,
902/// methodReturns -- and after those extensionMethods, appended LAST. Each is
903/// omitted entirely when empty, so a type declaring none of them serializes
904/// exactly as it did before those additions.
905#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
906pub struct FragDef {
907    /// The id value.
908    pub id: String,
909    /// The name value.
910    pub name: String,
911    /// The namespace value.
912    pub namespace: String,
913    /// The kind value.
914    pub kind: String,
915    /// The line value.
916    pub line: usize,
917    /// The methods value.
918    pub methods: Vec<String>,
919    #[serde(default, skip_serializing_if = "Vec::is_empty")]
920    /// The properties value.
921    pub properties: Vec<String>,
922    #[serde(default, skip_serializing_if = "Vec::is_empty")]
923    /// The fields value.
924    pub fields: Vec<String>,
925    /// Member name -> declared return type NAME, in FIRST-DECLARATION SOURCE
926    /// ORDER -- deliberately `OrderedMap`, never a `BTreeMap`: this is an
927    /// ordered pair list, and sorting the keys here would break the
928    /// serialized bytes.
929    #[serde(
930        default,
931        rename = "methodReturns",
932        skip_serializing_if = "OrderedMap::is_empty"
933    )]
934    pub method_returns: OrderedMap<String>,
935    /// The extension methods this type declares, in source
936    /// order, deduped by (name, thisType, arityMin, arityMax). Appended after
937    /// `method_returns`, and omitted entirely when empty, so a type declaring
938    /// none keeps its exact prior bytes.
939    #[serde(
940        default,
941        rename = "extensionMethods",
942        skip_serializing_if = "Vec::is_empty"
943    )]
944    pub extension_methods: Vec<FragExtensionMethod>,
945    /// DIRECT base-type identifiers, source
946    /// order, deduped. Appended LAST, after `extension_methods`, omitted when
947    /// empty. A resolution input only: `resolve_graph` strips it (along with
948    /// properties/fields/extensionMethods) before graph.json's def rows, so
949    /// the on-disk def bytes are unchanged.
950    #[serde(default, skip_serializing_if = "Vec::is_empty")]
951    pub bases: Vec<String>,
952    /// The declaring type's own type-parameter names, empty for
953    /// every non-generic declaration. Appended after `bases`, omitted when
954    /// empty. A resolution input, like `bases`: the ctor-DI resolver's
955    /// "is this def itself an open-generic implementation" signal.
956    #[serde(default, rename = "typeParams", skip_serializing_if = "Vec::is_empty")]
957    pub type_params: Vec<String>,
958    /// Per base name that carried a type-argument list, that
959    /// list's generic-arg descriptors relative to `type_params` (`OrderedMap`
960    /// for the same reason `method_returns` is one: the serialized key order
961    /// is significant). Appended after `type_params`, omitted when
962    /// empty.
963    #[serde(
964        default,
965        rename = "baseGenericArgs",
966        skip_serializing_if = "OrderedMap::is_empty"
967    )]
968    pub base_generic_args: OrderedMap<Vec<String>>,
969    /// The methods a test framework would DISCOVER as
970    /// tests, source order, deduped. Appended LAST, after `baseGenericArgs`,
971    /// omitted when empty. Unlike its neighbours this one is NOT stripped by
972    /// `resolve_graph`: it is what `devscout tests` answers from.
973    #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
974    pub test_methods: Vec<String>,
975    /// Property name -> declared type fact, in source order and
976    /// under the same dedup as `properties`. An `OrderedMap` for the same
977    /// reason `method_returns` is one: the serialized key order is significant.
978    /// Appended LAST, after `test_methods`, omitted when empty. A
979    /// resolution input only, like `properties`/`fields`.
980    #[serde(
981        default,
982        rename = "propertyTypes",
983        skip_serializing_if = "OrderedMap::is_empty"
984    )]
985    pub property_types: OrderedMap<FragFact>,
986    /// Field name -> declared type fact, in source order and
987    /// under the same dedup as `fields`. Mirrors `propertyTypes` field for
988    /// field: an `OrderedMap` for the same reason (the serialized key order
989    /// is significant), a resolution input only, like `properties`/`fields`,
990    /// and this is a purely additive field -- an absent key reads back as
991    /// "no fields typed", the safe default for every fragment cached before
992    /// this field existed, so it joins the schema with no cache-version
993    /// bump. Appended right after `propertyTypes`, omitted when empty.
994    #[serde(
995        default,
996        rename = "fieldTypes",
997        skip_serializing_if = "OrderedMap::is_empty"
998    )]
999    pub field_types: OrderedMap<FragFact>,
1000    /// Per method name `methodReturns` also carries an entry for,
1001    /// that return type's top-level generic-arg descriptors -- the same
1002    /// capture `baseGenericArgs` keeps beside `bases` (see extract.rs's
1003    /// `DefRecord`). `methodReturns` itself is left exactly as it always
1004    /// was (the bare return-type identifier, "Task" for
1005    /// `Task<Order> GetAsync()`) so an UNAWAITED use of the same callee
1006    /// keeps reading "Task" unchanged; this is a purely additive sibling
1007    /// field, not a reshaping of `methodReturns`, which is what lets it
1008    /// join the schema with no cache-version bump -- an absent key reads
1009    /// back as "no generic args", the safe default for every fragment
1010    /// cached before this field existed. Appended LAST of all, after
1011    /// `fieldTypes`, omitted when empty.
1012    #[serde(
1013        default,
1014        rename = "methodReturnArgs",
1015        skip_serializing_if = "OrderedMap::is_empty"
1016    )]
1017    pub method_return_args: OrderedMap<Vec<String>>,
1018    /// Declared method names `methods` does not carry because
1019    /// `is_recorded_method` gates that list on a literal `public` modifier
1020    /// (or `kind == "interface"`, where this list is always empty). Source
1021    /// order, no dedup, same as `methods`. A resolution input only, like
1022    /// `properties`/`fields`/`bases`: `resolve_graph` strips it before
1023    /// graph.json's def rows. Consulted only for hierarchy-internal
1024    /// receivers (`base.` and the `this.` shape's own base walk); the
1025    /// scored tier keeps reading `methods` alone. Appended LAST of all,
1026    /// after `methodReturnArgs`, omitted when empty -- purely additive, so
1027    /// an absent key reads back as "no non-public methods", the safe
1028    /// default for every fragment cached before this field existed, which
1029    /// is what lets it join the schema with no cache-version bump.
1030    #[serde(
1031        default,
1032        rename = "nonPublicMethods",
1033        skip_serializing_if = "Vec::is_empty"
1034    )]
1035    pub non_public_methods: Vec<String>,
1036    /// Method name -> the (min, max) argument-count range every overload
1037    /// sharing that name accepts, one tuple per overload, in declaration
1038    /// order -- covers every method, public and non-public alike (see
1039    /// `extract::DefRecord::method_arities`). `max` is -1 for an unbounded
1040    /// `params` overload, the same sentinel `FragExtensionMethod::arity_max`
1041    /// already uses. An `OrderedMap` for the same reason `methodReturns` is
1042    /// one: the serialized key order is significant. Appended LAST of all,
1043    /// after `nonPublicMethods`, omitted when empty -- purely additive, so
1044    /// an absent key reads back as "no arity facts", the safe default for
1045    /// every fragment cached before this field existed, which is what lets
1046    /// it join the schema with no cache-version bump.
1047    #[serde(
1048        default,
1049        rename = "methodArities",
1050        skip_serializing_if = "OrderedMap::is_empty"
1051    )]
1052    pub method_arities: OrderedMap<Vec<(usize, i64)>>,
1053    /// Method name -> per-overload parameter type descriptors, one
1054    /// `Vec<String>` per overload in declaration order -- covers every
1055    /// method, public and non-public alike, same method set as
1056    /// `methodArities` (see `extract::DefRecord::method_params`). A
1057    /// position naming a type parameter of the enclosing method or class is
1058    /// recorded as `"*"`; a parameter carrying the `this` modifier is
1059    /// written `"this <descriptor>"`. A `delegate` def carries exactly one
1060    /// entry here, keyed `"Invoke"`. An `OrderedMap` for the same reason
1061    /// `methodArities` is one: the serialized key order is significant.
1062    /// Appended LAST of all, after `methodArities`, omitted when empty.
1063    /// Joined the schema with the v18 cache bump: a v17 fragment read back
1064    /// carries none, and every overload-shape/`this`-marker/callee-slot
1065    /// lookup this field backs would silently see no candidates.
1066    #[serde(
1067        default,
1068        rename = "methodParams",
1069        skip_serializing_if = "OrderedMap::is_empty"
1070    )]
1071    pub method_params: OrderedMap<Vec<Vec<String>>>,
1072    #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
1073    /// The end line value.
1074    pub end_line: usize,
1075}
1076
1077/// One declared type fact: the type NAME, plus its top-level
1078/// type-argument descriptors when the declaration carried any. Field order
1079/// (`type`, `args`) is significant, and `args` is omitted when absent.
1080#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1081pub struct FragFact {
1082    #[serde(rename = "type")]
1083    /// The type name value.
1084    pub type_name: String,
1085    #[serde(default, skip_serializing_if = "Option::is_none")]
1086    /// The args value.
1087    pub args: Option<Vec<String>>,
1088}
1089
1090/// One `extensionMethods` entry. Serialized field order (`name`, `thisType`,
1091/// `arityMin`, `arityMax`, `thisArgs`) is significant, and serde emits struct
1092/// fields in declaration order. The two arity halves are NOT optional: they joined the
1093/// schema with the v6 cache rename, so no fragment this reader can meet is
1094/// missing them. `arity_max` is signed because -1 is the unbounded-`params`
1095/// sentinel.
1096#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1097pub struct FragExtensionMethod {
1098    /// The name value.
1099    pub name: String,
1100    #[serde(rename = "thisType")]
1101    /// The this type value.
1102    pub this_type: String,
1103    #[serde(rename = "arityMin")]
1104    /// The arity min value.
1105    pub arity_min: usize,
1106    #[serde(rename = "arityMax")]
1107    /// The arity max value.
1108    pub arity_max: i64,
1109    /// Present only when the this-parameter type is generic (the key is omitted
1110    /// key otherwise), so a non-generic entry keeps four fields exactly.
1111    #[serde(default, rename = "thisArgs", skip_serializing_if = "Option::is_none")]
1112    pub this_args: Option<Vec<String>>,
1113}
1114
1115/// One untyped lambda parameter's callee slot (see extract.rs's `LambdaSlot`).
1116///
1117/// Field order (`owner`, `member`, `argCount`, `argIndex`,
1118/// `arity`, `index`) is significant: serde emits struct fields in
1119/// declaration order under `#[serde(rename_all = "camelCase")]`.
1120#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1121#[serde(rename_all = "camelCase")]
1122pub struct FragLambdaSlot {
1123    /// The callee's type-name text.
1124    pub owner: String,
1125    /// The callee method name.
1126    pub member: String,
1127    /// The argument count of the callee invocation.
1128    pub arg_count: usize,
1129    /// The position of the lambda among the callee invocation's arguments.
1130    pub arg_index: usize,
1131    /// The lambda's own parameter count.
1132    pub arity: usize,
1133    /// This parameter's position inside the lambda's parameter list.
1134    pub index: usize,
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1138#[serde(untagged)]
1139/// Represents `FragUsing`.
1140pub enum FragUsing {
1141    /// The value value.
1142    Alias {
1143        /// The alias name.
1144        alias: String,
1145        /// The aliased target.
1146        target: String,
1147        /// Whether the directive is global.
1148        global: bool,
1149    },
1150    /// The value value.
1151    Plain {
1152        /// The imported namespace text.
1153        text: String,
1154        /// Whether the directive is global.
1155        global: bool,
1156    },
1157}
1158
1159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1160/// Represents `FragRef`.
1161pub struct FragRef {
1162    /// The kind value.
1163    pub kind: String,
1164    /// The name value.
1165    pub name: String,
1166    #[serde(default, skip_serializing_if = "Option::is_none")]
1167    /// The qualified value.
1168    pub qualified: Option<String>,
1169    #[serde(default, skip_serializing_if = "Option::is_none")]
1170    /// The member value.
1171    pub member: Option<String>,
1172    /// The line value.
1173    pub line: usize,
1174    /// Always serialized, including when `null` (imports refs) -- NOT
1175    /// `skip_serializing_if`, unlike `qualified`/`member` which are omitted
1176    /// entirely when absent. See extract.rs's RefRecord doc comment.
1177    pub namespace: Option<String>,
1178    #[serde(
1179        default,
1180        rename = "typeArgCount",
1181        skip_serializing_if = "Option::is_none"
1182    )]
1183    /// The type arg count value.
1184    pub type_arg_count: Option<usize>,
1185    /// Type-certainty flag (see extract.rs's RefRecord). Serialized last and
1186    /// only when `true`; an absent key reads back as false, which also makes
1187    /// an older fragment JSON parse safely.
1188    #[serde(default, skip_serializing_if = "is_false")]
1189    pub generic: bool,
1190    /// Receiver fact (see extract.rs's RefRecord). Appended AFTER `generic`,
1191    /// and set only when a fact actually fired -- so it serializes last and
1192    /// only when present, and an absent key reads back as "no fact", the safe
1193    /// default.
1194    #[serde(
1195        default,
1196        rename = "receiverType",
1197        skip_serializing_if = "Option::is_none"
1198    )]
1199    pub receiver_type: Option<String>,
1200    /// The callee arg count (see extract.rs's RefRecord). Appended AFTER
1201    /// `receiverType`, set only when the member access was the callee of an
1202    /// invocation -- so it serializes last and only when present, and an
1203    /// absent key reads back as "not a call", which is what keeps a property
1204    /// read out of the extension tier.
1205    #[serde(default, rename = "argCount", skip_serializing_if = "Option::is_none")]
1206    pub arg_count: Option<usize>,
1207    /// Receiver generic-arg descriptors (see extract.rs's RefRecord). Appended
1208    /// LAST, after `argCount`, set only when the receiver's DECLARED type was
1209    /// generic -- an absent key reads back as "not generic", which is what
1210    /// makes a generic-vs-non-generic pairing fail to unify.
1211    #[serde(
1212        default,
1213        rename = "receiverArgs",
1214        skip_serializing_if = "Option::is_none"
1215    )]
1216    pub receiver_args: Option<Vec<String>>,
1217    /// Enclosing-type stack (see extract.rs's RefRecord). Appended LAST, after
1218    /// `receiverArgs`, and set only when non-empty. A Vec rather than an Option
1219    /// because empty and absent mean the same thing here -- a ref at namespace
1220    /// level and an older cached fragment both read back as "no enclosing
1221    /// type", which is what keeps them off the step.
1222    #[serde(default, rename = "outerTypes", skip_serializing_if = "Vec::is_empty")]
1223    pub outer_types: Vec<String>,
1224    /// Generic-arg descriptors for a 'ctor-param' ref (see extract.rs's
1225    /// RefRecord). Appended LAST of all, after `outerTypes`, and set only when
1226    /// the parameter's type was generic.
1227    #[serde(default, skip_serializing_if = "Option::is_none")]
1228    pub args: Option<Vec<String>>,
1229    /// (See extract.rs's RefRecord.) The type whose PROPERTY the
1230    /// qualifier's last segment is, for a two-segment chain whose head the
1231    /// enclosing scope could type. Appended after `args`, and never present
1232    /// alongside `receiverType`.
1233    #[serde(
1234        default,
1235        rename = "receiverPropertyOwner",
1236        skip_serializing_if = "Option::is_none"
1237    )]
1238    pub receiver_property_owner: Option<String>,
1239    /// (See extract.rs's RefRecord.) The type whose METHOD a
1240    /// `var x = Q.M(...)` initializer called, and that method's name. Appended
1241    /// LAST of all, always as a pair, and never alongside `receiverType`: an
1242    /// absent pair reads back as "no call fact", which is what leaves the local
1243    /// taken-but-unknown.
1244    #[serde(
1245        default,
1246        rename = "receiverCallOwner",
1247        skip_serializing_if = "Option::is_none"
1248    )]
1249    pub receiver_call_owner: Option<String>,
1250    #[serde(
1251        default,
1252        rename = "receiverCallMember",
1253        skip_serializing_if = "Option::is_none"
1254    )]
1255    /// The receiver call member value.
1256    pub receiver_call_member: Option<String>,
1257    /// `true` for a `base.` qualifier -- the member lookup starts at the
1258    /// enclosing type's bases and never considers the enclosing type
1259    /// itself (see `extract.rs`'s `RefRecord`). Appended LAST of all, and
1260    /// omitted when `false` -- an absent key reads back as `false`, the
1261    /// same as a plain `this.` receiver and as every ref kind that never
1262    /// sets it, and also what makes a v15 cached fragment parse safely.
1263    #[serde(default, rename = "receiverBase", skip_serializing_if = "is_false")]
1264    pub receiver_base: bool,
1265    /// `true` when `receiverCallOwner`/`receiverCallMember` came from an
1266    /// AWAITED call (see `extract.rs`'s `RefRecord`). Appended LAST of all,
1267    /// after `receiverBase`, and omitted when `false` -- an absent key reads
1268    /// back as `false`, the same as an unawaited call fact and as every ref
1269    /// kind that never sets it, which is also what lets a cached fragment
1270    /// from before this field existed parse safely with no cache-version
1271    /// bump: it simply reads back as "not awaited", the same answer the
1272    /// resolver gave before this field existed.
1273    #[serde(default, rename = "receiverAwaited", skip_serializing_if = "is_false")]
1274    pub receiver_awaited: bool,
1275    /// `true` when this ref's qualifier is a bare identifier for which the
1276    /// enclosing MEMBER's own fact table (locals, parameters, lambda
1277    /// parameters, patterns, `out` designations) holds ANY entry for the
1278    /// name, typed or taken-but-unknown (see `extract.rs`'s `RefRecord` and
1279    /// `Scope::has_local_fact`). Read ONLY by the bare-identifier
1280    /// field/property fallback: a member-scoped name always shadows a
1281    /// same-named field, whether or not anything vouches for its type, so
1282    /// that fallback never runs when this is `true`. `false` for every
1283    /// dotted or generic qualifier, for `this.`/`base.` (never asked of the
1284    /// enclosing scope's local table at all), and for every ref kind but
1285    /// `uses-member`. Appended LAST of all, after `receiverAwaited`, and
1286    /// omitted when `false` -- an absent key reads back as `false`, the
1287    /// same as every ref kind that never sets it, which is also what lets a
1288    /// cached fragment from before this field existed parse safely with no
1289    /// cache-version bump.
1290    #[serde(default, rename = "receiverLocal", skip_serializing_if = "is_false")]
1291    pub receiver_local: bool,
1292    /// Set when this ref's qualifier is an untyped lambda parameter whose
1293    /// type is a delegate parameter of some OTHER callee (see extract.rs's
1294    /// `RefRecord`). Never present alongside `receiverType` or
1295    /// `receiverCallOwner`. Appended LAST of all, after `receiverLocal`,
1296    /// omitted when absent. Joined the schema with the v18 cache bump: a
1297    /// v17 fragment read back carries none, and every untyped-lambda
1298    /// callee-slot lookup this field backs would silently see no
1299    /// candidate.
1300    #[serde(
1301        default,
1302        rename = "receiverLambda",
1303        skip_serializing_if = "Option::is_none"
1304    )]
1305    pub receiver_lambda: Option<FragLambdaSlot>,
1306}
1307
1308fn is_false(b: &bool) -> bool {
1309    !*b
1310}
1311
1312fn is_zero(n: &usize) -> bool {
1313    *n == 0
1314}
1315
1316/// One declared member, with the line its own NAME token sits on.
1317/// Field order (`name`, `kind`, `line`, `owner`) is significant, and `owner`
1318/// is omitted when empty -- which is how
1319/// a markup or resource key, owned by no C# type, serializes with three fields.
1320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1321pub struct FragName {
1322    /// The name value.
1323    pub name: String,
1324    /// The kind value.
1325    pub kind: String,
1326    /// The line value.
1327    pub line: usize,
1328    #[serde(default, skip_serializing_if = "String::is_empty")]
1329    /// The owner value.
1330    pub owner: String,
1331}
1332
1333#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1334/// Represents `Fragment`.
1335pub struct Fragment {
1336    /// The defs value.
1337    pub defs: Vec<FragDef>,
1338    /// The usings value.
1339    pub usings: Vec<FragUsing>,
1340    /// The refs value.
1341    pub refs: Vec<FragRef>,
1342    /// Appended LAST, after `refs`. Always serialized, like its
1343    /// three siblings: a fragment's top-level arrays are a fixed shape, and
1344    /// only the fields INSIDE a record follow the omit-when-empty rule.
1345    #[serde(default)]
1346    pub names: Vec<FragName>,
1347}
1348
1349/// The two shapes a cached fragment can have. Each rel is keyed to whichever
1350/// shape the file's grammar produced; the `ts: 1` tag is what tells them
1351/// apart, on disk and at the
1352/// resolver's door. Untagged, and TS FIRST: a C#/markup fragment carries no
1353/// `ts` key (so the TS arm always fails on it) and a TS fragment carries no
1354/// `usings` (so the C# arm always fails on it) -- the discrimination is total
1355/// in both directions, never a first-match-wins guess.
1356#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1357#[serde(untagged)]
1358pub enum AnyFragment {
1359    /// Represents `Ts`.
1360    Ts(extract::TsFragment),
1361    /// Represents `Cs`.
1362    Cs(Fragment),
1363}
1364
1365impl From<Fragment> for AnyFragment {
1366    fn from(f: Fragment) -> Self {
1367        AnyFragment::Cs(f)
1368    }
1369}
1370
1371impl From<extract::TsFragment> for AnyFragment {
1372    fn from(f: extract::TsFragment) -> Self {
1373        AnyFragment::Ts(f)
1374    }
1375}
1376
1377/// Build a graph fragment from this crate's extractor output.
1378/// `Extraction.purpose` has no fragment
1379/// counterpart -- the fragment is graph-only.
1380#[allow(
1381    clippy::too_many_lines,
1382    reason = "one ordered field-by-field translation from Extraction into Fragment; splitting it would separate fields that must stay mapped consistently"
1383)]
1384pub fn fragment_from_extraction(e: &extract::Extraction) -> Fragment {
1385    Fragment {
1386        defs: e
1387            .defs
1388            .iter()
1389            .map(|d| FragDef {
1390                id: d.id.clone(),
1391                name: d.name.clone(),
1392                namespace: d.namespace.clone(),
1393                kind: d.kind.clone(),
1394                line: d.line,
1395                methods: d.methods.clone(),
1396                properties: d.properties.clone(),
1397                fields: d.fields.clone(),
1398                method_returns: {
1399                    let mut m = OrderedMap::new();
1400                    for (name, returns) in &d.method_returns {
1401                        m.insert(name.clone(), returns.clone());
1402                    }
1403                    m
1404                },
1405                extension_methods: d
1406                    .extension_methods
1407                    .iter()
1408                    .map(|e| FragExtensionMethod {
1409                        name: e.name.clone(),
1410                        this_type: e.this_type.clone(),
1411                        arity_min: e.arity_min,
1412                        arity_max: e.arity_max,
1413                        this_args: e.this_args.clone(),
1414                    })
1415                    .collect(),
1416                bases: d.bases.clone(),
1417                type_params: d.type_params.clone(),
1418                base_generic_args: {
1419                    let mut m = OrderedMap::new();
1420                    for (name, args) in &d.base_generic_args {
1421                        m.insert(name.clone(), args.clone());
1422                    }
1423                    m
1424                },
1425                test_methods: d.test_methods.clone(),
1426                property_types: {
1427                    let mut m = OrderedMap::new();
1428                    for (name, fact) in &d.property_types {
1429                        m.insert(
1430                            name.clone(),
1431                            FragFact {
1432                                type_name: fact.type_name.clone(),
1433                                args: fact.args.clone(),
1434                            },
1435                        );
1436                    }
1437                    m
1438                },
1439                field_types: {
1440                    let mut m = OrderedMap::new();
1441                    for (name, fact) in &d.field_types {
1442                        m.insert(
1443                            name.clone(),
1444                            FragFact {
1445                                type_name: fact.type_name.clone(),
1446                                args: fact.args.clone(),
1447                            },
1448                        );
1449                    }
1450                    m
1451                },
1452                method_return_args: {
1453                    let mut m = OrderedMap::new();
1454                    for (name, args) in &d.method_return_args {
1455                        m.insert(name.clone(), args.clone());
1456                    }
1457                    m
1458                },
1459                non_public_methods: d.non_public_methods.clone(),
1460                method_arities: {
1461                    let mut m = OrderedMap::new();
1462                    for (name, ranges) in &d.method_arities {
1463                        m.insert(name.clone(), ranges.clone());
1464                    }
1465                    m
1466                },
1467                method_params: {
1468                    let mut m = OrderedMap::new();
1469                    for (name, overloads) in &d.method_params {
1470                        m.insert(name.clone(), overloads.clone());
1471                    }
1472                    m
1473                },
1474                end_line: d.end_line,
1475            })
1476            .collect(),
1477        usings: e
1478            .usings
1479            .iter()
1480            .map(|u| match u {
1481                extract::UsingRecord::Alias {
1482                    alias,
1483                    target,
1484                    global,
1485                } => FragUsing::Alias {
1486                    alias: alias.clone(),
1487                    target: target.clone(),
1488                    global: *global,
1489                },
1490                extract::UsingRecord::Plain { text, global } => FragUsing::Plain {
1491                    text: text.clone(),
1492                    global: *global,
1493                },
1494            })
1495            .collect(),
1496        refs: e
1497            .refs
1498            .iter()
1499            .map(|r| FragRef {
1500                kind: r.kind.clone(),
1501                name: r.name.clone(),
1502                qualified: r.qualified.clone(),
1503                member: r.member.clone(),
1504                line: r.line,
1505                namespace: r.namespace.clone(),
1506                type_arg_count: r.type_arg_count,
1507                generic: r.generic,
1508                receiver_type: r.receiver_type.clone(),
1509                arg_count: r.arg_count,
1510                receiver_args: r.receiver_args.clone(),
1511                outer_types: r.outer_types.clone(),
1512                args: r.args.clone(),
1513                receiver_property_owner: r.receiver_property_owner.clone(),
1514                receiver_call_owner: r.receiver_call_owner.clone(),
1515                receiver_call_member: r.receiver_call_member.clone(),
1516                receiver_base: r.receiver_base,
1517                receiver_awaited: r.receiver_awaited,
1518                receiver_local: r.receiver_local,
1519                receiver_lambda: r.receiver_lambda.as_ref().map(|s| FragLambdaSlot {
1520                    owner: s.owner.clone(),
1521                    member: s.member.clone(),
1522                    arg_count: s.arg_count,
1523                    arg_index: s.arg_index,
1524                    arity: s.arity,
1525                    index: s.index,
1526                }),
1527            })
1528            .collect(),
1529        names: e
1530            .names
1531            .iter()
1532            .map(|n| FragName {
1533                name: n.name.clone(),
1534                kind: n.kind.clone(),
1535                line: n.line,
1536                owner: n.owner.clone(),
1537            })
1538            .collect(),
1539    }
1540}
1541
1542/// Markup and resource files never reach the extractor: a
1543/// line scan reads them instead (see markup.rs). They still get a fragment even
1544/// when they declare nothing, because the fragments index is what tells `map`
1545/// whether the graph's input set changed, and a file missing from it reads as a
1546/// permanent mismatch. `usings` is always empty: XAML has no using-directive
1547/// equivalent, and the `xmlns:` prefix declarations that stand in for one are
1548/// already resolved into fully-qualified ref names by the scan.
1549pub fn markup_fragment(root: &Path, rel: &str) -> Option<Fragment> {
1550    // Lossy read: an invalid byte becomes U+FFFD rather than dropping the
1551    // file.
1552    let text = String::from_utf8_lossy(&fs::read(root.join(rel)).ok()?).into_owned();
1553    let facts = crate::markup::markup_facts(rel, &text);
1554    Some(Fragment {
1555        defs: facts
1556            .defs
1557            .into_iter()
1558            .map(|d| FragDef {
1559                id: d.id,
1560                name: d.name,
1561                namespace: d.namespace,
1562                kind: d.kind,
1563                line: d.line,
1564                methods: Vec::new(),
1565                properties: Vec::new(),
1566                fields: Vec::new(),
1567                method_returns: OrderedMap::new(),
1568                extension_methods: Vec::new(),
1569                bases: Vec::new(),
1570                type_params: Vec::new(),
1571                base_generic_args: OrderedMap::new(),
1572                test_methods: Vec::new(),
1573                property_types: OrderedMap::new(),
1574                field_types: OrderedMap::new(),
1575                method_return_args: OrderedMap::new(),
1576                non_public_methods: Vec::new(),
1577                method_arities: OrderedMap::new(),
1578                method_params: OrderedMap::new(),
1579                end_line: d.line,
1580            })
1581            .collect(),
1582        usings: Vec::new(),
1583        refs: facts
1584            .refs
1585            .into_iter()
1586            .map(|r| FragRef {
1587                kind: r.kind,
1588                name: r.name,
1589                qualified: r.qualified,
1590                member: r.member,
1591                line: r.line,
1592                // A markup ref site has no enclosing namespace of its own, and
1593                // the empty string is what a C# ref at file scope carries too --
1594                // never `None`, which is the 'imports' spelling.
1595                namespace: Some(String::new()),
1596                type_arg_count: Some(0),
1597                generic: false,
1598                receiver_type: None,
1599                arg_count: None,
1600                receiver_args: None,
1601                outer_types: Vec::new(),
1602                args: None,
1603                receiver_property_owner: None,
1604                receiver_call_owner: None,
1605                receiver_call_member: None,
1606                receiver_base: false,
1607                receiver_awaited: false,
1608                receiver_local: false,
1609                receiver_lambda: None,
1610            })
1611            .collect(),
1612        names: facts
1613            .names
1614            .into_iter()
1615            .map(|n| FragName {
1616                name: n.name,
1617                kind: n.kind,
1618                line: n.line,
1619                owner: n.owner,
1620            })
1621            .collect(),
1622    })
1623}
1624
1625#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1626/// Represents `FragmentCacheEntry`.
1627pub struct FragmentCacheEntry {
1628    /// The mtime value.
1629    pub mtime: i64,
1630    /// The fragment value.
1631    pub fragment: AnyFragment,
1632}
1633
1634fn read_fragments_cache(root: &Path) -> OrderedMap<FragmentCacheEntry> {
1635    match fs::read_to_string(fragments_cache_path(root)) {
1636        Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1637        Err(_) => OrderedMap::default(),
1638    }
1639}
1640
1641fn write_fragments_cache(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1642    atomic_write_json(&fragments_cache_path(root), cache)
1643}
1644
1645/// Read the fragments index (rel -> mtime). Used for the per-file reuse
1646/// decision, independent of the graph rebuild.
1647pub fn read_fragments_index(root: &Path) -> OrderedMap<i64> {
1648    match fs::read_to_string(fragments_index_path(root)) {
1649        Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1650        Err(_) => OrderedMap::default(),
1651    }
1652}
1653
1654fn write_fragments_index(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1655    let mut index = OrderedMap::new();
1656    for (rel, entry) in cache.iter() {
1657        index.insert(rel.clone(), entry.mtime);
1658    }
1659    atomic_write_json(&fragments_index_path(root), &index)
1660}
1661
1662/// Whether the fragments index is stale for the given graph files (any mtime
1663/// mismatch, or a differing file count).
1664pub fn index_is_stale(index: &OrderedMap<i64>, graph_files: &[GraphFile]) -> bool {
1665    graph_files
1666        .iter()
1667        .any(|f| index.get(&f.rel) != Some(&f.mtime))
1668        || index.len() != graph_files.len()
1669}
1670
1671// ---------------------------------------------------------------------------
1672// `devscout map`'s cache-then-resolve-then-write cycle.
1673// ---------------------------------------------------------------------------
1674
1675/// One file that contributes a graph fragment: C# (defs, refs, member names),
1676/// markup (`x:Class`/`x:Name`, `.resw` keys -- names only) or TS/JS
1677/// (imports, exported declarations, call/JSX/dispatch references).
1678#[derive(Debug, Clone)]
1679pub struct GraphFile {
1680    /// The rel value.
1681    pub rel: String,
1682    /// The mtime value.
1683    pub mtime: i64,
1684}
1685
1686#[derive(Debug)]
1687/// Represents `RebuildOutcome`.
1688pub enum RebuildOutcome {
1689    /// Represents `NotRebuilt`.
1690    NotRebuilt,
1691    /// Represents `Rebuilt`.
1692    Rebuilt(Graph),
1693}
1694
1695/// Whether the graph.json already on disk was written at the CURRENT schema
1696/// version. Reads the first 64 bytes and compares the literal
1697/// `{"schema_version":N,` prefix rather than deserializing: `schema_version`
1698/// is the first key `Graph` serializes, the artifact can be tens of
1699/// megabytes, and this runs on the fast path whose whole point is not opening
1700/// it. Anything else -- an older version, an unreadable or truncated file --
1701/// answers false and costs a rebuild, which is the safe direction.
1702fn graph_schema_is_current(root: &Path) -> bool {
1703    let Ok(mut file) = fs::File::open(graph_json_path(root)) else {
1704        return false;
1705    };
1706    let mut head = [0u8; 64];
1707    let mut filled = 0;
1708    while filled < head.len() {
1709        match file.read(&mut head[filled..]) {
1710            Ok(0) => break,
1711            Ok(n) => filled += n,
1712            Err(_) => return false,
1713        }
1714    }
1715    head[..filled].starts_with(format!("{{\"schema_version\":{GRAPH_SCHEMA_VERSION},").as_bytes())
1716}
1717
1718// Mirrors the model's units into the staleness sidecar. No model means the
1719// repo declares no `.csproj`, and then the file must NOT exist: an empty
1720// `[]` left behind would be indistinguishable from "no model" on the read
1721// side, and the sidecar's whole job is telling those two apart.
1722fn write_project_units(
1723    root: &Path,
1724    model: Option<&crate::project::ProjectModel>,
1725) -> io::Result<()> {
1726    match model {
1727        Some(m) => atomic_write_json(&project_units_path(root), &crate::project::graph_units(m)),
1728        None => {
1729            let path = project_units_path(root);
1730            match fs::remove_file(&path) {
1731                Ok(()) => Ok(()),
1732                Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1733                Err(e) => Err(e),
1734            }
1735        }
1736    }
1737}
1738
1739/// `fresh_fragments`: fragments this run's extractor produced for files that
1740/// needed reparsing (the same set that got a fresh purpose signature in the
1741/// real `devscout map` flow -- `mapcmd::map_repo` assembles it). `changed`: from the
1742/// caller's own `indexIsStale`-equivalent check against `csFiles`, passed in
1743/// rather than recomputed so the unchanged path never opens any graph file.
1744/// `model`: the repo's discovered `.csproj` projects, or `None` when it
1745/// declares none -- serialized into `graph.units` and mirrored into the
1746/// `project_units_path` staleness sidecar. The caller is expected to have
1747/// already folded `project::sidecar_differs` into `changed`; this function
1748/// only writes the sidecar, it never decides on it.
1749pub fn rebuild_graph(
1750    root: &Path,
1751    graph_files: &[GraphFile],
1752    fresh_fragments: &HashMap<String, AnyFragment>,
1753    changed: bool,
1754    model: Option<&crate::project::ProjectModel>,
1755) -> io::Result<RebuildOutcome> {
1756    if !changed && graph_json_path(root).exists() && graph_schema_is_current(root) {
1757        return Ok(RebuildOutcome::NotRebuilt);
1758    }
1759
1760    let cache = read_fragments_cache(root);
1761    // Split at the door: the two shapes share no field beyond
1762    // `defs`, and letting one resolver see the other's names would resolve a
1763    // C# type reference onto a same-named TypeScript const. Built as two vecs
1764    // here rather than one mixed vec split later so no fragment is cloned a
1765    // third time on the rebuild path.
1766    let mut merged_cs: Vec<(String, Fragment)> = Vec::new();
1767    let mut merged_ts: Vec<(String, extract::TsFragment)> = Vec::new();
1768    let mut new_cache: OrderedMap<FragmentCacheEntry> = OrderedMap::new();
1769    for f in graph_files {
1770        let fragment = match cache.get(&f.rel) {
1771            Some(entry) if entry.mtime == f.mtime => Some(entry.fragment.clone()),
1772            _ => fresh_fragments.get(&f.rel).cloned(),
1773        };
1774        let Some(fragment) = fragment else { continue };
1775        match &fragment {
1776            AnyFragment::Cs(c) => merged_cs.push((f.rel.clone(), c.clone())),
1777            AnyFragment::Ts(t) => merged_ts.push((f.rel.clone(), t.clone())),
1778        }
1779        new_cache.insert(
1780            f.rel.clone(),
1781            FragmentCacheEntry {
1782                mtime: f.mtime,
1783                fragment,
1784            },
1785        );
1786    }
1787
1788    let graph = crate::resolve::resolve_graph_with_model(root, &merged_cs, &merged_ts, model);
1789    write_graph(root, &graph)?;
1790    write_project_units(root, model)?;
1791    write_fragments_cache(root, &new_cache)?;
1792    write_fragments_index(root, &new_cache)?;
1793    remove_superseded_caches(root);
1794    Ok(RebuildOutcome::Rebuilt(graph))
1795}
1796
1797#[cfg(test)]
1798mod tests {
1799    use super::*;
1800    use std::time::{SystemTime, UNIX_EPOCH};
1801
1802    fn temp_dir(label: &str) -> PathBuf {
1803        let nanos = SystemTime::now()
1804            .duration_since(UNIX_EPOCH)
1805            .unwrap()
1806            .as_nanos();
1807        let dir = std::env::temp_dir().join(format!("scout-graph-test-{label}-{nanos}"));
1808        fs::create_dir_all(&dir).unwrap();
1809        dir
1810    }
1811
1812    // --- OrderedMap: order + round-trip -------------------------------
1813
1814    #[test]
1815    fn ordered_map_preserves_insertion_order_through_json_round_trip() {
1816        let mut m: OrderedMap<i64> = OrderedMap::new();
1817        m.insert("z.cs".to_string(), 3);
1818        m.insert("a.cs".to_string(), 1);
1819        m.insert("m.cs".to_string(), 2);
1820        let json = serde_json::to_string(&m).unwrap();
1821        assert_eq!(json, r#"{"z.cs":3,"a.cs":1,"m.cs":2}"#);
1822
1823        let reparsed: OrderedMap<i64> = serde_json::from_str(&json).unwrap();
1824        let keys: Vec<&String> = reparsed.iter().map(|(k, _)| k).collect();
1825        assert_eq!(keys, vec!["z.cs", "a.cs", "m.cs"]);
1826    }
1827
1828    #[test]
1829    fn ordered_map_insert_overwrite_keeps_original_position() {
1830        let mut m: OrderedMap<i64> = OrderedMap::new();
1831        m.insert("a".to_string(), 1);
1832        m.insert("b".to_string(), 2);
1833        m.insert("a".to_string(), 99);
1834        let keys: Vec<&String> = m.iter().map(|(k, _)| k).collect();
1835        assert_eq!(keys, vec!["a", "b"]);
1836        assert_eq!(m.get("a"), Some(&99));
1837    }
1838
1839    // --- Percent1: number-shaped formatting -------------------------
1840
1841    #[test]
1842    fn percent1_whole_value_serializes_without_decimal_point() {
1843        let p = Percent1::from_ratio(2, 10); // 20.0 -> "20"
1844        assert_eq!(serde_json::to_string(&p).unwrap(), "20");
1845    }
1846
1847    #[test]
1848    fn percent1_fractional_value_serializes_with_one_decimal_digit() {
1849        let p = Percent1::from_ratio(1, 3); // 33.333... -> round to 33.3
1850        assert_eq!(serde_json::to_string(&p).unwrap(), "33.3");
1851    }
1852
1853    #[test]
1854    fn percent1_zero_denominator_is_zero() {
1855        assert_eq!(Percent1::from_ratio(0, 0), Percent1::zero());
1856        assert_eq!(serde_json::to_string(&Percent1::zero()).unwrap(), "0");
1857    }
1858
1859    // --- FragDef's new keys, appended last, omitted when empty ----------------------
1860
1861    fn frag_def(
1862        methods: &[&str],
1863        properties: &[&str],
1864        fields: &[&str],
1865        method_returns: &[(&str, &str)],
1866        extension_methods: &[(&str, &str, usize, i64)],
1867    ) -> FragDef {
1868        frag_def_with_bases(
1869            methods,
1870            properties,
1871            fields,
1872            method_returns,
1873            extension_methods,
1874            &[],
1875        )
1876    }
1877
1878    fn frag_def_with_bases(
1879        methods: &[&str],
1880        properties: &[&str],
1881        fields: &[&str],
1882        method_returns: &[(&str, &str)],
1883        extension_methods: &[(&str, &str, usize, i64)],
1884        bases: &[&str],
1885    ) -> FragDef {
1886        let mut mr = OrderedMap::new();
1887        for (k, v) in method_returns {
1888            mr.insert((*k).to_string(), (*v).to_string());
1889        }
1890        FragDef {
1891            id: "App.Facts.Widget".into(),
1892            name: "Widget".into(),
1893            namespace: "App.Facts".into(),
1894            kind: "class".into(),
1895            line: 3,
1896            methods: methods.iter().map(|s| s.to_string()).collect(),
1897            properties: properties.iter().map(|s| s.to_string()).collect(),
1898            fields: fields.iter().map(|s| s.to_string()).collect(),
1899            method_returns: mr,
1900            extension_methods: extension_methods
1901                .iter()
1902                .map(|(n, t, lo, hi)| FragExtensionMethod {
1903                    name: (*n).to_string(),
1904                    this_type: (*t).to_string(),
1905                    arity_min: *lo,
1906                    arity_max: *hi,
1907                    this_args: None,
1908                })
1909                .collect(),
1910            bases: bases.iter().map(|s| s.to_string()).collect(),
1911            type_params: Vec::new(),
1912            base_generic_args: OrderedMap::new(),
1913            property_types: OrderedMap::new(),
1914            field_types: OrderedMap::new(),
1915            method_return_args: OrderedMap::new(),
1916            non_public_methods: Vec::new(),
1917            method_arities: OrderedMap::new(),
1918            method_params: OrderedMap::new(),
1919            test_methods: Vec::new(),
1920            end_line: 0,
1921        }
1922    }
1923
1924    #[test]
1925    fn frag_def_appends_properties_fields_and_method_returns_last_in_that_order() {
1926        let json = serde_json::to_string(&frag_def(
1927            &["GetAsync"],
1928            &["Prefix"],
1929            &["_log"],
1930            &[("GetAsync", "Task")],
1931            &[],
1932        ))
1933        .unwrap();
1934        assert_eq!(
1935            json,
1936            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["GetAsync"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"GetAsync":"Task"}}"#
1937        );
1938    }
1939
1940    #[test]
1941    fn frag_def_omits_all_three_new_keys_when_the_type_declares_none_of_them() {
1942        let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1943        assert_eq!(
1944            json,
1945            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"]}"#,
1946            "pre-stage-2 bytes preserved exactly"
1947        );
1948    }
1949
1950    // --- extensionMethods lands AFTER methodReturns, entry keys
1951    // in (name, thisType, arityMin, arityMax, thisArgs) order, omitted when
1952    // empty; `bases` lands after extensionMethods -------------------------
1953
1954    #[test]
1955    fn frag_def_appends_extension_methods_after_method_returns_with_the_arity_range_last() {
1956        let json = serde_json::to_string(&frag_def(
1957            &["Go"],
1958            &["Prefix"],
1959            &["_log"],
1960            &[("Go", "Task")],
1961            &[
1962                ("Render", "Widget", 0, 0),
1963                ("Render", "Widget", 2, 3),
1964                ("Trim", "string", 0, -1),
1965            ],
1966        ))
1967        .unwrap();
1968        assert_eq!(
1969            json,
1970            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"Go":"Task"},"extensionMethods":[{"name":"Render","thisType":"Widget","arityMin":0,"arityMax":0},{"name":"Render","thisType":"Widget","arityMin":2,"arityMax":3},{"name":"Trim","thisType":"string","arityMin":0,"arityMax":-1}]}"#
1971        );
1972    }
1973
1974    #[test]
1975    fn frag_def_this_args_lands_after_arity_max_and_bases_after_extension_methods() {
1976        let mut d = frag_def_with_bases(
1977            &["Go"],
1978            &[],
1979            &[],
1980            &[],
1981            &[("Each", "List", 0, 0)],
1982            &["BaseWidget", "IWidget"],
1983        );
1984        d.extension_methods[0].this_args = Some(vec!["Widget".into()]);
1985        let json = serde_json::to_string(&d).unwrap();
1986        assert_eq!(
1987            json,
1988            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"extensionMethods":[{"name":"Each","thisType":"List","arityMin":0,"arityMax":0,"thisArgs":["Widget"]}],"bases":["BaseWidget","IWidget"]}"#
1989        );
1990        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1991        assert_eq!(
1992            reparsed.bases,
1993            vec!["BaseWidget".to_string(), "IWidget".to_string()]
1994        );
1995        assert_eq!(
1996            reparsed.extension_methods[0].this_args,
1997            Some(vec!["Widget".to_string()])
1998        );
1999    }
2000
2001    #[test]
2002    fn frag_def_omits_bases_when_the_type_lists_none() {
2003        let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
2004        assert!(
2005            !json.contains("bases"),
2006            "an empty base list must be omitted entirely: {json}"
2007        );
2008        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2009        assert!(
2010            reparsed.bases.is_empty(),
2011            "an absent key reads back as \"lists none\""
2012        );
2013    }
2014
2015    // --- test-coverage stage: testMethods lands AFTER bases, omitted when
2016    // the type declares no tests ------------------------------------------
2017
2018    #[test]
2019    fn frag_def_appends_test_methods_last_after_bases() {
2020        let mut d = frag_def_with_bases(&["Go"], &[], &[], &[], &[], &["BaseWidget"]);
2021        d.test_methods = vec!["TotalsAnEmptyOrder".into(), "TotalsALineItem".into()];
2022        let json = serde_json::to_string(&d).unwrap();
2023        assert_eq!(
2024            json,
2025            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"bases":["BaseWidget"],"testMethods":["TotalsAnEmptyOrder","TotalsALineItem"]}"#
2026        );
2027        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2028        assert_eq!(
2029            reparsed.test_methods,
2030            vec![
2031                "TotalsAnEmptyOrder".to_string(),
2032                "TotalsALineItem".to_string()
2033            ]
2034        );
2035    }
2036
2037    #[test]
2038    fn frag_def_omits_test_methods_when_the_type_declares_no_tests() {
2039        let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
2040        assert!(
2041            !json.contains("testMethods"),
2042            "pre-stage bytes preserved exactly: {json}"
2043        );
2044        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2045        assert!(
2046            reparsed.test_methods.is_empty(),
2047            "an absent key reads back as \"declares no tests\""
2048        );
2049    }
2050
2051    #[test]
2052    fn frag_def_omits_extension_methods_when_the_type_declares_none() {
2053        let json =
2054            serde_json::to_string(&frag_def(&["Go"], &[], &[], &[("Go", "Task")], &[])).unwrap();
2055        assert!(
2056            !json.contains("extensionMethods"),
2057            "pre-stage-3 bytes preserved exactly: {json}"
2058        );
2059        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2060        assert!(
2061            reparsed.extension_methods.is_empty(),
2062            "an absent key reads back as \"declares none\""
2063        );
2064    }
2065
2066    #[test]
2067    fn frag_def_method_returns_serializes_in_first_declaration_order_not_sorted() {
2068        // A BTreeMap here would emit Alpha before Zebra -- the required order
2069        // is insertion (first-declaration) order.
2070        let json = serde_json::to_string(&frag_def(
2071            &[],
2072            &[],
2073            &[],
2074            &[("Zebra", "Z"), ("Alpha", "A")],
2075            &[],
2076        ))
2077        .unwrap();
2078        assert!(
2079            json.ends_with(r#""methodReturns":{"Zebra":"Z","Alpha":"A"}}"#),
2080            "insertion order, not sorted: {json}"
2081        );
2082        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2083        let keys: Vec<&String> = reparsed.method_returns.iter().map(|(k, _)| k).collect();
2084        assert_eq!(
2085            keys,
2086            vec!["Zebra", "Alpha"],
2087            "and the order survives a round trip"
2088        );
2089    }
2090
2091    // --- propertyTypes lands AFTER testMethods, omitted when the
2092    // type declares no typed property ---------------------------------------
2093
2094    #[test]
2095    fn frag_def_appends_property_types_last_after_test_methods() {
2096        let mut d =
2097            frag_def_with_bases(&["Go"], &["Dial", "Slots"], &[], &[], &[], &["BaseWidget"]);
2098        d.test_methods = vec!["TotalsALineItem".into()];
2099        d.property_types.insert(
2100            "Dial".into(),
2101            FragFact {
2102                type_name: "Gauge".into(),
2103                args: None,
2104            },
2105        );
2106        d.property_types.insert(
2107            "Slots".into(),
2108            FragFact {
2109                type_name: "Toolbox".into(),
2110                args: Some(vec!["Gadget".into()]),
2111            },
2112        );
2113        let json = serde_json::to_string(&d).unwrap();
2114        assert_eq!(
2115            json,
2116            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"properties":["Dial","Slots"],"bases":["BaseWidget"],"testMethods":["TotalsALineItem"],"propertyTypes":{"Dial":{"type":"Gauge"},"Slots":{"type":"Toolbox","args":["Gadget"]}}}"#
2117        );
2118        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2119        let keys: Vec<&String> = reparsed.property_types.iter().map(|(k, _)| k).collect();
2120        assert_eq!(
2121            keys,
2122            vec!["Dial", "Slots"],
2123            "source order survives a round trip, like methodReturns"
2124        );
2125    }
2126
2127    #[test]
2128    fn frag_def_omits_property_types_when_no_property_carries_a_fact() {
2129        let json = serde_json::to_string(&frag_def(&["Go"], &["Label"], &[], &[], &[])).unwrap();
2130        assert!(
2131            !json.contains("propertyTypes"),
2132            "pre-propertyTypes bytes preserved exactly: {json}"
2133        );
2134        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
2135        assert!(
2136            reparsed.property_types.is_empty(),
2137            "an absent key reads back as \"no property vouches for a type\""
2138        );
2139    }
2140
2141    // --- FragRef's receiverType then argCount, appended
2142    // after generic in that order ------------------------------------------
2143
2144    fn frag_ref(generic: bool, receiver_type: Option<&str>, arg_count: Option<usize>) -> FragRef {
2145        FragRef {
2146            kind: "uses-member".into(),
2147            name: "repo".into(),
2148            qualified: None,
2149            member: Some("Save".into()),
2150            line: 7,
2151            namespace: Some("App.Shape".into()),
2152            type_arg_count: None,
2153            generic,
2154            receiver_type: receiver_type.map(String::from),
2155            arg_count,
2156            receiver_args: None,
2157            outer_types: Vec::new(),
2158            args: None,
2159            receiver_property_owner: None,
2160            receiver_call_owner: None,
2161            receiver_call_member: None,
2162            receiver_base: false,
2163            receiver_awaited: false,
2164            receiver_local: false,
2165            receiver_lambda: None,
2166        }
2167    }
2168
2169    #[test]
2170    fn frag_ref_receiver_args_is_appended_last_after_arg_count() {
2171        let r = FragRef {
2172            receiver_args: Some(vec!["FutureState".into(), "*".into()]),
2173            ..frag_ref(false, Some("Binder"), Some(1))
2174        };
2175        let json = serde_json::to_string(&r).unwrap();
2176        assert_eq!(
2177            json,
2178            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState","*"]}"#
2179        );
2180        let reparsed: FragRef = serde_json::from_str(&json).unwrap();
2181        assert_eq!(
2182            reparsed.receiver_args,
2183            Some(vec!["FutureState".to_string(), "*".to_string()])
2184        );
2185    }
2186
2187    #[test]
2188    fn frag_ref_receiver_type_then_arg_count_are_appended_last_after_generic() {
2189        let json = serde_json::to_string(&frag_ref(true, Some("IRepo"), Some(2))).unwrap();
2190        assert_eq!(
2191            json,
2192            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","generic":true,"receiverType":"IRepo","argCount":2}"#
2193        );
2194    }
2195
2196    #[test]
2197    fn frag_ref_arg_count_zero_still_serializes_it() {
2198        // The guard is presence (argCount may be 0), never truthiness -- a
2199        // zero-argument call is a real call and its 0 is what matches an
2200        // arity-0 extension.
2201        let json = serde_json::to_string(&frag_ref(false, Some("IRepo"), Some(0))).unwrap();
2202        assert!(
2203            json.ends_with(r#""receiverType":"IRepo","argCount":0}"#),
2204            "argCount 0 must survive: {json}"
2205        );
2206    }
2207
2208    #[test]
2209    fn frag_ref_without_a_fact_keeps_its_pre_stage_2_bytes() {
2210        let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
2211        assert_eq!(
2212            json,
2213            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape"}"#
2214        );
2215        let reparsed: FragRef = serde_json::from_str(&json).unwrap();
2216        assert_eq!(
2217            reparsed.receiver_type, None,
2218            "an absent key reads back as no fact"
2219        );
2220        assert_eq!(
2221            reparsed.arg_count, None,
2222            "and an absent argCount reads back as \"not a call\""
2223        );
2224        assert_eq!(
2225            reparsed.receiver_args, None,
2226            "and an absent receiverArgs reads back as \"not generic\""
2227        );
2228    }
2229
2230    // --- Def: also_in omission ------------------------------------------
2231
2232    #[test]
2233    fn def_without_also_in_omits_the_key() {
2234        let d = Def {
2235            id: "Ns.Type".into(),
2236            name: "Type".into(),
2237            namespace: "Ns".into(),
2238            kind: "class".into(),
2239            file: "Ns/Type.cs".into(),
2240            line: 3,
2241            methods: vec![],
2242            test_methods: vec![],
2243            also_in: vec![],
2244            end_line: 0,
2245        };
2246        let json = serde_json::to_string(&d).unwrap();
2247        assert!(
2248            !json.contains("also_in"),
2249            "empty also_in must be omitted: {json}"
2250        );
2251    }
2252
2253    #[test]
2254    fn def_end_line_serializes_last_and_defaults_when_absent() {
2255        let mut d = Def {
2256            id: "Ns.Type".into(),
2257            name: "Type".into(),
2258            namespace: "Ns".into(),
2259            kind: "class".into(),
2260            file: "Ns/Type.cs".into(),
2261            line: 3,
2262            methods: vec![],
2263            test_methods: vec![],
2264            also_in: vec![],
2265            end_line: 0,
2266        };
2267        assert!(!serde_json::to_string(&d).unwrap().contains("endLine"));
2268        d.end_line = 9;
2269        assert!(serde_json::to_string(&d)
2270            .unwrap()
2271            .ends_with(",\"endLine\":9}"));
2272    }
2273
2274    #[test]
2275    fn def_with_also_in_includes_it_after_methods() {
2276        let d = Def {
2277            id: "Ns.Type".into(),
2278            name: "Type".into(),
2279            namespace: "Ns".into(),
2280            kind: "class".into(),
2281            file: "Ns/Type.cs".into(),
2282            line: 3,
2283            methods: vec!["M".into()],
2284            test_methods: vec![],
2285            also_in: vec![AlsoIn {
2286                file: "Ns/Type.Extra.cs".into(),
2287                line: 5,
2288            }],
2289            end_line: 0,
2290        };
2291        let json = serde_json::to_string(&d).unwrap();
2292        let methods_pos = json.find("\"methods\"").unwrap();
2293        let also_in_pos = json.find("\"also_in\"").unwrap();
2294        assert!(
2295            methods_pos < also_in_pos,
2296            "also_in must come after methods: {json}"
2297        );
2298    }
2299
2300    // --- test-coverage stage: the def ROW keeps testMethods, between
2301    // methods and also_in ---------------------------------------------------
2302
2303    #[test]
2304    fn def_row_places_test_methods_between_methods_and_also_in() {
2305        let d = Def {
2306            id: "Ns.TypeTests".into(),
2307            name: "TypeTests".into(),
2308            namespace: "Ns".into(),
2309            kind: "class".into(),
2310            file: "Ns/TypeTests.cs".into(),
2311            line: 3,
2312            methods: vec!["M".into()],
2313            test_methods: vec!["Fact1".into()],
2314            also_in: vec![AlsoIn {
2315                file: "Ns/TypeTests.Extra.cs".into(),
2316                line: 5,
2317            }],
2318            end_line: 0,
2319        };
2320        let json = serde_json::to_string(&d).unwrap();
2321        assert_eq!(
2322            json,
2323            r#"{"id":"Ns.TypeTests","name":"TypeTests","namespace":"Ns","kind":"class","file":"Ns/TypeTests.cs","line":3,"methods":["M"],"testMethods":["Fact1"],"also_in":[{"file":"Ns/TypeTests.Extra.cs","line":5}]}"#
2324        );
2325    }
2326
2327    #[test]
2328    fn def_row_omits_test_methods_when_the_def_declares_no_tests() {
2329        let d = Def {
2330            id: "Ns.Type".into(),
2331            name: "Type".into(),
2332            namespace: "Ns".into(),
2333            kind: "class".into(),
2334            file: "Ns/Type.cs".into(),
2335            line: 3,
2336            methods: vec!["M".into()],
2337            test_methods: vec![],
2338            also_in: vec![],
2339            end_line: 0,
2340        };
2341        let json = serde_json::to_string(&d).unwrap();
2342        assert!(
2343            !json.contains("testMethods"),
2344            "empty testMethods must be omitted: {json}"
2345        );
2346    }
2347
2348    // --- test-coverage stage: stats gains test_def_count, LAST -------------
2349
2350    #[test]
2351    fn stats_appends_test_def_count_last_and_always_writes_it() {
2352        let stats = Stats {
2353            def_count: 2,
2354            file_count: 1,
2355            edges_by_kind: EdgesByKind::default(),
2356            ambiguous_count: 0,
2357            ambiguous_pct: Percent1::zero(),
2358            unresolved_external_count: 0,
2359            heuristic_edge_count: 0,
2360            test_def_count: 0,
2361            heuristic_by_tier: HeuristicByTier::default(),
2362            ts: None,
2363        };
2364        let json = serde_json::to_string(&stats).unwrap();
2365        assert!(
2366            json.ends_with(
2367                r#""heuristic_edge_count":0,"test_def_count":0,"heuristic_by_tier":{"ext":0,"guess":0}}"#
2368            ),
2369            "heuristic_by_tier is LAST, after test_def_count, and both its keys are always written: {json}"
2370        );
2371    }
2372
2373    /// The same append-last rule for a TS repo, which is the only tree where
2374    /// the two optional tail keys can both appear: `ts` was added first and
2375    /// `heuristic_by_tier` after it, so `heuristic_by_tier` still ends the
2376    /// block and `ts` sits between it and `test_def_count`. A reader diffing a
2377    /// TS graph against an older one sees each new fact appended, never
2378    /// inserted ahead of an older key.
2379    #[test]
2380    fn stats_keeps_heuristic_by_tier_last_even_when_a_ts_block_is_present() {
2381        let stats = Stats {
2382            def_count: 0,
2383            file_count: 0,
2384            edges_by_kind: EdgesByKind::default(),
2385            ambiguous_count: 0,
2386            ambiguous_pct: Percent1::zero(),
2387            unresolved_external_count: 0,
2388            heuristic_edge_count: 0,
2389            test_def_count: 0,
2390            heuristic_by_tier: HeuristicByTier::default(),
2391            ts: Some(crate::tsgraph::TsStats {
2392                ts_file_count: 1,
2393                ts_def_count: 2,
2394                external_import_count: 3,
2395                unresolved_ref_count: 4,
2396            }),
2397        };
2398        let json = serde_json::to_string(&stats).unwrap();
2399        assert!(
2400            json.ends_with(concat!(
2401                r#""test_def_count":0,"#,
2402                r#""ts":{"ts_file_count":1,"ts_def_count":2,"external_import_count":3,"unresolved_ref_count":4},"#,
2403                r#""heuristic_by_tier":{"ext":0,"guess":0}}"#
2404            )),
2405            "key order must be test_def_count, ts, heuristic_by_tier: {json}"
2406        );
2407    }
2408
2409    // --- Edge: shape-per-kind, tag first ---------------------------------
2410
2411    #[test]
2412    fn imports_edge_has_target_not_to() {
2413        let e = Edge::Imports {
2414            from_file: "F.cs".into(),
2415            from_line: 1,
2416            target: "System".into(),
2417        };
2418        let json = serde_json::to_string(&e).unwrap();
2419        assert_eq!(
2420            json,
2421            r#"{"kind":"imports","from_file":"F.cs","from_line":1,"target":"System"}"#
2422        );
2423    }
2424
2425    #[test]
2426    fn uses_type_edge_shape() {
2427        let e = Edge::UsesType {
2428            from_file: "F.cs".into(),
2429            from_line: 1,
2430            to: "Ns.T".into(),
2431            to_file: "Ns/T.cs".into(),
2432            heuristic: false,
2433        };
2434        let json = serde_json::to_string(&e).unwrap();
2435        assert_eq!(
2436            json,
2437            r#"{"kind":"uses-type","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
2438        );
2439    }
2440
2441    // The serialized shape in one assertion: `heuristic` is the LAST key, it
2442    // appears only when set, and a precise edge of the same kind is
2443    // byte-for-byte what it was before the tag existed.
2444    #[test]
2445    fn heuristic_flag_is_appended_last_and_omitted_when_false() {
2446        let precise = Edge::UsesMember {
2447            from_file: "F.cs".into(),
2448            from_line: 1,
2449            to: "Ns.T".into(),
2450            to_file: "Ns/T.cs".into(),
2451            heuristic: false,
2452            tier: None,
2453            member: None,
2454        };
2455        assert_eq!(
2456            serde_json::to_string(&precise).unwrap(),
2457            r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
2458        );
2459        let guess = Edge::UsesMember {
2460            from_file: "F.cs".into(),
2461            from_line: 1,
2462            to: "Ns.T".into(),
2463            to_file: "Ns/T.cs".into(),
2464            heuristic: true,
2465            tier: Some(HeuristicTier::Guess),
2466            member: Some("M".into()),
2467        };
2468        assert_eq!(
2469            serde_json::to_string(&guess).unwrap(),
2470            r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true,"tier":"guess","member":"M"}"#
2471        );
2472        let ext = Edge::UsesMember {
2473            from_file: "F.cs".into(),
2474            from_line: 1,
2475            to: "Ns.T".into(),
2476            to_file: "Ns/T.cs".into(),
2477            heuristic: true,
2478            tier: Some(HeuristicTier::Ext),
2479            member: Some("M".into()),
2480        };
2481        assert_eq!(
2482            serde_json::to_string(&ext).unwrap(),
2483            r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true,"tier":"ext","member":"M"}"#
2484        );
2485        // And it reads back: an edge written by either runtime round-trips
2486        // with the flag intact, absent meaning precise -- and now with its
2487        // tier and member intact too, which is what makes an older graph.json
2488        // (neither key written) still parse, as two `None`s.
2489        assert_eq!(
2490            serde_json::from_str::<Edge>(&serde_json::to_string(&guess).unwrap()).unwrap(),
2491            guess
2492        );
2493        assert_eq!(
2494            serde_json::from_str::<Edge>(&serde_json::to_string(&ext).unwrap()).unwrap(),
2495            ext
2496        );
2497        assert_eq!(
2498            serde_json::from_str::<Edge>(&serde_json::to_string(&precise).unwrap()).unwrap(),
2499            precise
2500        );
2501    }
2502
2503    // The append ORDER, pinned on its own: `heuristic`, then `tier`, then
2504    // `member`, each omitted when it has nothing to say. A precise edge that
2505    // does name its member -- which is every precise uses-member edge the
2506    // resolver emits -- carries `member` and nothing else, so its bytes gain
2507    // exactly one key over the pre-tier shape.
2508    #[test]
2509    fn uses_member_edge_appends_tier_then_member_after_heuristic_and_omits_both_when_precise() {
2510        let precise = Edge::uses_member(
2511            "F.cs".into(),
2512            1,
2513            "Ns.T".into(),
2514            "Ns/T.cs".into(),
2515            Some("M".into()),
2516            None,
2517        );
2518        assert_eq!(
2519            serde_json::to_string(&precise).unwrap(),
2520            r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","member":"M"}"#
2521        );
2522        assert!(!precise.is_heuristic(), "no tier means no guess");
2523        assert_eq!(precise.tier(), None);
2524
2525        for (tier, word) in [(HeuristicTier::Ext, "ext"), (HeuristicTier::Guess, "guess")] {
2526            let e = Edge::uses_member(
2527                "F.cs".into(),
2528                1,
2529                "Ns.T".into(),
2530                "Ns/T.cs".into(),
2531                Some("M".into()),
2532                Some(tier),
2533            );
2534            assert_eq!(
2535                serde_json::to_string(&e).unwrap(),
2536                format!(
2537                    r#"{{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true,"tier":"{word}","member":"M"}}"#
2538                ),
2539                "heuristic, then tier, then member"
2540            );
2541            // The constructor is what makes the flag and the tier one fact:
2542            // pass a tier and the edge is a guess, pass none and it is not.
2543            assert!(e.is_heuristic());
2544            assert_eq!(e.tier(), Some(tier));
2545        }
2546
2547        // The two kinds that carry the flag but no tier answer `None` rather
2548        // than guessing on the reader's behalf.
2549        assert_eq!(
2550            Edge::UsesType {
2551                from_file: "F.cs".into(),
2552                from_line: 1,
2553                to: "Ns.T".into(),
2554                to_file: "Ns/T.cs".into(),
2555                heuristic: true,
2556            }
2557            .tier(),
2558            None
2559        );
2560    }
2561
2562    #[test]
2563    fn ambiguous_edge_shape() {
2564        let e = Edge::Ambiguous {
2565            origin: "uses-type".into(),
2566            from_file: "F.cs".into(),
2567            from_line: 4,
2568            raw: "Money".into(),
2569            candidates: vec![Candidate {
2570                id: "A.Money".into(),
2571                file: "A/Money.cs".into(),
2572            }],
2573            candidate_count: 2,
2574        };
2575        let json = serde_json::to_string(&e).unwrap();
2576        assert_eq!(
2577            json,
2578            r#"{"kind":"ambiguous","origin":"uses-type","from_file":"F.cs","from_line":4,"raw":"Money","candidates":[{"id":"A.Money","file":"A/Money.cs"}],"candidate_count":2}"#
2579        );
2580    }
2581
2582    // --- graph.json: `units` is appended after `names` --------------------
2583
2584    fn empty_stats() -> Stats {
2585        Stats {
2586            def_count: 0,
2587            file_count: 0,
2588            edges_by_kind: EdgesByKind::default(),
2589            ambiguous_count: 0,
2590            ambiguous_pct: Percent1::zero(),
2591            unresolved_external_count: 0,
2592            heuristic_edge_count: 0,
2593            test_def_count: 0,
2594            heuristic_by_tier: HeuristicByTier::default(),
2595            ts: None,
2596        }
2597    }
2598
2599    // The two halves of the `units` contract in one place: a graph whose
2600    // repo declares no project must serialize with NO `units` key at all
2601    // (that is what keeps every csproj-less tree byte-identical to what it
2602    // was), and one that does must carry `units` LAST -- after `names` --
2603    // with each row keyed `id`, `name`, `refs`, `test` in that order and the
2604    // last two omitted at their empty/false value. Byte literals on purpose:
2605    // a golden recomputed by the code under test proves nothing.
2606    #[test]
2607    fn graph_omits_units_when_empty_and_appends_them_after_names_otherwise() {
2608        let mut g = Graph {
2609            schema_version: GRAPH_SCHEMA_VERSION,
2610            built_at_head: None,
2611            defs: Vec::new(),
2612            edges: Vec::new(),
2613            stats: empty_stats(),
2614            names: vec![GraphName {
2615                name: "A".to_string(),
2616                kind: "class".to_string(),
2617                file: "A/A.cs".to_string(),
2618                line: 1,
2619                owner: String::new(),
2620            }],
2621            units: Vec::new(),
2622        };
2623
2624        const WITHOUT_UNITS: &str = concat!(
2625            r#"{"schema_version":2,"built_at_head":null,"defs":[],"edges":[],"stats":{"def_count":0,"#,
2626            r#""file_count":0,"edges_by_kind":{"inherits":0,"uses-type":0,"imports":0,"uses-member":0,"#,
2627            r#""ctor-di":0},"ambiguous_count":0,"ambiguous_pct":0,"unresolved_external_count":0,"#,
2628            r#""heuristic_edge_count":0,"test_def_count":0,"heuristic_by_tier":{"ext":0,"guess":0}},"#,
2629            r#""names":[{"name":"A","kind":"class","file":"A/A.cs","line":1}]}"#,
2630        );
2631        assert_eq!(
2632            serde_json::to_string(&g).unwrap(),
2633            WITHOUT_UNITS,
2634            "an empty unit list must not emit a `units` key at all"
2635        );
2636
2637        g.units = vec![
2638            GraphUnit {
2639                id: "A/A.csproj".to_string(),
2640                name: "A".to_string(),
2641                refs: vec!["B/B.csproj".to_string()],
2642                test: false,
2643            },
2644            GraphUnit {
2645                id: "B/B.csproj".to_string(),
2646                name: "B".to_string(),
2647                refs: Vec::new(),
2648                test: false,
2649            },
2650            GraphUnit {
2651                id: "T/T.Tests.csproj".to_string(),
2652                name: "T.Tests".to_string(),
2653                refs: vec!["A/A.csproj".to_string()],
2654                test: true,
2655            },
2656        ];
2657
2658        const WITH_UNITS: &str = concat!(
2659            r#""names":[{"name":"A","kind":"class","file":"A/A.cs","line":1}],"#,
2660            r#""units":[{"id":"A/A.csproj","name":"A","refs":["B/B.csproj"]},"#,
2661            r#"{"id":"B/B.csproj","name":"B"},"#,
2662            r#"{"id":"T/T.Tests.csproj","name":"T.Tests","refs":["A/A.csproj"],"test":true}]}"#,
2663        );
2664        let json = serde_json::to_string(&g).unwrap();
2665        assert_eq!(
2666            json,
2667            format!(
2668                "{}{}",
2669                WITHOUT_UNITS
2670                    .strip_suffix(
2671                        r#""names":[{"name":"A","kind":"class","file":"A/A.cs","line":1}]}"#
2672                    )
2673                    .unwrap(),
2674                WITH_UNITS
2675            ),
2676            "`units` is appended after `names` and changes nothing before it"
2677        );
2678
2679        // And a graph.json written before `units` existed still reads back --
2680        // the field defaults rather than failing the parse.
2681        let reparsed: Graph = serde_json::from_str(WITHOUT_UNITS).unwrap();
2682        assert!(reparsed.units.is_empty());
2683        let round_tripped: Graph = serde_json::from_str(&json).unwrap();
2684        assert_eq!(round_tripped.units, g.units);
2685    }
2686
2687    // --- Stats: fixed edges_by_kind order ---------------------------------
2688
2689    #[test]
2690    fn edges_by_kind_field_order_is_fixed_not_alphabetical() {
2691        let s = EdgesByKind {
2692            inherits: 1,
2693            uses_type: 2,
2694            imports: 3,
2695            uses_member: 4,
2696            ctor_di: 5,
2697            ..Default::default()
2698        };
2699        let json = serde_json::to_string(&s).unwrap();
2700        assert_eq!(
2701            json,
2702            r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5}"#
2703        );
2704    }
2705
2706    // --- The cache holds two fragment shapes -------------------
2707
2708    // The untagged discrimination is what keeps one cache file able to hold
2709    // both shapes: read the wrong arm and a whole repo's fragments come back
2710    // silently empty. Both directions are pinned here, on the exact
2711    // serialized bytes.
2712    #[test]
2713    fn any_fragment_round_trips_both_shapes_and_never_reads_one_as_the_other() {
2714        let cs = AnyFragment::Cs(Fragment {
2715            defs: Vec::new(),
2716            usings: Vec::new(),
2717            refs: Vec::new(),
2718            names: Vec::new(),
2719        });
2720        let cs_json = serde_json::to_string(&cs).unwrap();
2721        assert_eq!(cs_json, r#"{"defs":[],"usings":[],"refs":[],"names":[]}"#);
2722        assert!(matches!(
2723            serde_json::from_str::<AnyFragment>(&cs_json).unwrap(),
2724            AnyFragment::Cs(_)
2725        ));
2726
2727        let ts = AnyFragment::Ts(extract::TsFragment {
2728            ts: 1,
2729            defs: vec![extract::TsFragmentDef {
2730                name: "x".into(),
2731                kind: "const".into(),
2732                line: 1,
2733                end_line: 1,
2734            }],
2735            imports: Vec::new(),
2736            reexports: Vec::new(),
2737            refs: Vec::new(),
2738            default: None,
2739        });
2740        let ts_json = serde_json::to_string(&ts).unwrap();
2741        assert_eq!(
2742            ts_json,
2743            r#"{"ts":1,"defs":[{"name":"x","kind":"const","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[]}"#
2744        );
2745        assert!(matches!(
2746            serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2747            AnyFragment::Ts(_)
2748        ));
2749        assert_eq!(
2750            serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2751            ts,
2752            "and round-trips to the same value"
2753        );
2754    }
2755
2756    // The four TS counts land AFTER `ctor-di`, in this order, and
2757    // only when the repo carries a TS fragment at all: a C#-only repo's stats
2758    // block keeps the exact bytes the assertion above pins.
2759    #[test]
2760    fn edges_by_kind_appends_the_four_ts_counts_after_ctor_di_when_present() {
2761        let s = EdgesByKind {
2762            inherits: 1,
2763            uses_type: 2,
2764            imports: 3,
2765            uses_member: 4,
2766            ctor_di: 5,
2767            import: Some(6),
2768            call: Some(7),
2769            jsx_use: Some(8),
2770            dispatch: Some(9),
2771        };
2772        let json = serde_json::to_string(&s).unwrap();
2773        assert_eq!(
2774            json,
2775            r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5,"import":6,"call":7,"jsx-use":8,"dispatch":9}"#
2776        );
2777    }
2778
2779    // --- atomic writes: crash-sim -----------------------------------
2780
2781    #[test]
2782    fn atomic_write_leaves_target_intact_when_only_a_stray_tmp_exists() {
2783        let dir = temp_dir("atomic-crash");
2784        let target = dir.join("graph.json");
2785        fs::write(&target, b"{\"old\":true}").unwrap();
2786        // Simulate a crashed prior write: a tmp file was created but the
2787        // process died before the rename -- the target must be completely
2788        // unaffected by the stray file's mere presence.
2789        fs::write(dir.join(".graph.json.tmp.99999.7"), b"GARBAGE-PARTIAL").unwrap();
2790        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"old\":true}");
2791    }
2792
2793    #[test]
2794    fn atomic_write_replaces_target_and_leaves_no_tmp_file_behind() {
2795        let dir = temp_dir("atomic-clean");
2796        let target = dir.join("graph.json");
2797        fs::write(&target, b"{\"old\":true}").unwrap();
2798        atomic_write_bytes(&target, b"{\"new\":true}").unwrap();
2799        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"new\":true}");
2800        let leftover: Vec<_> = fs::read_dir(&dir)
2801            .unwrap()
2802            .filter_map(|e| e.ok())
2803            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
2804            .collect();
2805        assert!(
2806            leftover.is_empty(),
2807            "no tmp file should remain: {leftover:?}"
2808        );
2809    }
2810
2811    #[test]
2812    fn atomic_write_creates_parent_directories() {
2813        let dir = temp_dir("atomic-mkdir");
2814        let target = dir.join("nested").join("deeper").join("graph.json");
2815        atomic_write_bytes(&target, b"{}").unwrap();
2816        assert_eq!(fs::read_to_string(&target).unwrap(), "{}");
2817    }
2818
2819    // --- rebuild_graph: unchanged path never touches the graph -----------
2820
2821    #[test]
2822    fn rebuild_graph_skips_when_unchanged_and_graph_already_exists() {
2823        let dir = temp_dir("rebuild-unchanged");
2824        fs::create_dir_all(graph_dir(&dir)).unwrap();
2825        fs::write(graph_json_path(&dir), b"{\"schema_version\":2,\"built_at_head\":null,\"defs\":[],\"edges\":[],\"stats\":{\"def_count\":0,\"file_count\":0,\"edges_by_kind\":{\"inherits\":0,\"uses-type\":0,\"imports\":0,\"uses-member\":0},\"ambiguous_count\":0,\"ambiguous_pct\":0,\"unresolved_external_count\":0}}").unwrap();
2826        let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false, None).unwrap();
2827        assert!(matches!(outcome, RebuildOutcome::NotRebuilt));
2828    }
2829
2830    // The other half of that fast path: "nothing changed" is not enough on
2831    // its own. A graph.json written by a build that predates the current
2832    // schema is missing facts every reader now expects, so it is rebuilt
2833    // even though not one fragment moved -- the ONLY thing separating this
2834    // case from the one above is the version in its first bytes.
2835    #[test]
2836    fn rebuild_graph_rebuilds_when_the_existing_graph_carries_an_older_schema_version() {
2837        let dir = temp_dir("rebuild-old-schema");
2838        fs::create_dir_all(graph_dir(&dir)).unwrap();
2839        fs::write(graph_json_path(&dir), b"{\"schema_version\":1,\"built_at_head\":null,\"defs\":[],\"edges\":[],\"stats\":{\"def_count\":0,\"file_count\":0,\"edges_by_kind\":{\"inherits\":0,\"uses-type\":0,\"imports\":0,\"uses-member\":0},\"ambiguous_count\":0,\"ambiguous_pct\":0,\"unresolved_external_count\":0}}").unwrap();
2840        let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false, None).unwrap();
2841        let RebuildOutcome::Rebuilt(graph) = outcome else {
2842            panic!("an older-schema graph must be rebuilt on the unchanged path");
2843        };
2844        assert_eq!(graph.schema_version, GRAPH_SCHEMA_VERSION);
2845        assert!(
2846            fs::read_to_string(graph_json_path(&dir))
2847                .unwrap()
2848                .starts_with(r#"{"schema_version":2,"#),
2849            "and the rebuilt artifact carries the current version on disk"
2850        );
2851    }
2852
2853    // A truncated or unreadable artifact answers the same way an older one
2854    // does -- rebuild -- rather than being trusted or panicking.
2855    #[test]
2856    fn rebuild_graph_rebuilds_when_the_existing_graph_is_too_short_to_carry_a_version() {
2857        let dir = temp_dir("rebuild-truncated");
2858        fs::create_dir_all(graph_dir(&dir)).unwrap();
2859        fs::write(graph_json_path(&dir), b"{").unwrap();
2860        assert!(!graph_schema_is_current(&dir));
2861        assert!(matches!(
2862            rebuild_graph(&dir, &[], &HashMap::new(), false, None).unwrap(),
2863            RebuildOutcome::Rebuilt(_)
2864        ));
2865    }
2866
2867    #[test]
2868    fn rebuild_graph_reuses_a_cached_fragment_at_matching_mtime() {
2869        let dir = temp_dir("rebuild-cache-hit");
2870        let fragment = Fragment {
2871            defs: vec![FragDef {
2872                id: "A".into(),
2873                name: "A".into(),
2874                namespace: "".into(),
2875                kind: "class".into(),
2876                line: 1,
2877                methods: vec![],
2878                properties: vec![],
2879                fields: vec![],
2880                method_returns: OrderedMap::new(),
2881                extension_methods: vec![],
2882                bases: vec![],
2883                type_params: vec![],
2884                base_generic_args: OrderedMap::new(),
2885                test_methods: vec![],
2886                property_types: OrderedMap::new(),
2887                field_types: OrderedMap::new(),
2888                method_return_args: OrderedMap::new(),
2889                non_public_methods: vec![],
2890                method_arities: OrderedMap::new(),
2891                method_params: OrderedMap::new(),
2892                end_line: 1,
2893            }],
2894            usings: vec![],
2895            refs: vec![],
2896            names: vec![],
2897        };
2898        // First build: nothing cached, comes from fresh_fragments.
2899        let mut fresh = HashMap::new();
2900        fresh.insert("A.cs".to_string(), AnyFragment::Cs(fragment.clone()));
2901        let graph_files = vec![GraphFile {
2902            rel: "A.cs".to_string(),
2903            mtime: 111,
2904        }];
2905        let first = rebuild_graph(&dir, &graph_files, &fresh, true, None).unwrap();
2906        assert!(matches!(first, RebuildOutcome::Rebuilt(_)));
2907
2908        // Second build: same mtime, EMPTY fresh_fragments -- must reuse the
2909        // cache, not silently drop the file from the graph.
2910        let empty: HashMap<String, AnyFragment> = HashMap::new();
2911        let second = rebuild_graph(&dir, &graph_files, &empty, true, None).unwrap();
2912        match second {
2913            RebuildOutcome::Rebuilt(g) => {
2914                assert_eq!(g.defs.len(), 1, "cached fragment must still be used")
2915            }
2916            RebuildOutcome::NotRebuilt => panic!("changed=true must always rebuild"),
2917        }
2918    }
2919
2920    // --- The v13 cache generation --------------------------
2921
2922    #[test]
2923    fn rebuild_graph_writes_the_v13_caches_and_deletes_every_superseded_generation() {
2924        let dir = temp_dir("rebuild-v13");
2925        fs::create_dir_all(graph_dir(&dir)).unwrap();
2926        // The v9 pair joined this list when fragments gained def
2927        // `typeParams`/`baseGenericArgs` and the `ctor-param` ref kind, so a v9
2928        // fragment read back today carries none and every ctor-DI fact would be
2929        // missing from the graph. The v10 pair joined when a markup
2930        // fragment gained the `x:Class` def and its element/binding refs, so a
2931        // v10 markup fragment read back carries names only and every XAML
2932        // declaration and instantiation would be missing from the graph. The v11
2933        // pair joined when a `.ts/.tsx/.js/.jsx` file gained a reference
2934        // fragment where it previously had none at all, so a v11 cache read back
2935        // would leave every TS/JS rel looking like a file the worker never saw.
2936        // This side records no TS fragment yet, but the cache generation is
2937        // shared -- two writers putting two generations into one git dir would
2938        // have each delete the other's cache on every map. The v12 pair joined
2939        // when defs gained propertyTypes and refs
2940        // gained receiverPropertyOwner and the receiverCallOwner/
2941        // receiverCallMember pair, so a v12 fragment read back carries none and
2942        // every property hop and every var-from-invocation receiver would
2943        // silently stay unresolved.
2944        assert_eq!(SUPERSEDED_CACHE_FILES.len(), 34, "v1..v17 pairs");
2945        for stale in SUPERSEDED_CACHE_FILES {
2946            fs::write(graph_dir(&dir).join(stale), b"{}").unwrap();
2947        }
2948
2949        let fragment = Fragment {
2950            defs: vec![FragDef {
2951                id: "App.A".into(),
2952                name: "A".into(),
2953                namespace: "App".into(),
2954                kind: "class".into(),
2955                line: 1,
2956                methods: vec![],
2957                properties: vec![],
2958                fields: vec![],
2959                method_returns: OrderedMap::new(),
2960                extension_methods: vec![],
2961                bases: vec![],
2962                type_params: vec![],
2963                base_generic_args: OrderedMap::new(),
2964                test_methods: vec![],
2965                property_types: OrderedMap::new(),
2966                field_types: OrderedMap::new(),
2967                method_return_args: OrderedMap::new(),
2968                non_public_methods: vec![],
2969                method_arities: OrderedMap::new(),
2970                method_params: OrderedMap::new(),
2971                end_line: 1,
2972            }],
2973            usings: vec![],
2974            refs: vec![],
2975            names: vec![],
2976        };
2977        let mut fresh = HashMap::new();
2978        fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
2979        let graph_files = vec![GraphFile {
2980            rel: "src/A.cs".to_string(),
2981            mtime: 222,
2982        }];
2983        rebuild_graph(&dir, &graph_files, &fresh, true, None).unwrap();
2984
2985        assert!(
2986            graph_dir(&dir).join("fragments-v18.json").exists(),
2987            "the v18 payload cache is what gets written"
2988        );
2989        assert!(
2990            graph_dir(&dir).join("fragments-index-v18.json").exists(),
2991            "and its mtime-only index alongside it"
2992        );
2993        for stale in SUPERSEDED_CACHE_FILES {
2994            assert!(
2995                !graph_dir(&dir).join(stale).exists(),
2996                "{stale} must be deleted -- rename IS the invalidation"
2997            );
2998        }
2999    }
3000
3001    // --- The v18 cache generation --------------------------
3002
3003    #[test]
3004    fn fragments_cache_v18_supersedes_v17() {
3005        let dir = temp_dir("fragments-cache-v18-paths");
3006        assert_eq!(
3007            fragments_cache_path(&dir),
3008            graph_dir(&dir).join("fragments-v18.json")
3009        );
3010        assert_eq!(
3011            fragments_index_path(&dir),
3012            graph_dir(&dir).join("fragments-index-v18.json")
3013        );
3014        assert!(
3015            SUPERSEDED_CACHE_FILES.contains(&"fragments-v17.json"),
3016            "v17 joined the superseded list when the v18 bump landed"
3017        );
3018        assert!(
3019            SUPERSEDED_CACHE_FILES.contains(&"fragments-index-v17.json"),
3020            "its index pairs with it, same as every other generation"
3021        );
3022
3023        let dir = temp_dir("rebuild-v18");
3024        fs::create_dir_all(graph_dir(&dir)).unwrap();
3025        fs::write(graph_dir(&dir).join("fragments-v17.json"), b"{}").unwrap();
3026        fs::write(graph_dir(&dir).join("fragments-index-v17.json"), b"{}").unwrap();
3027
3028        let fragment = Fragment {
3029            defs: vec![],
3030            usings: vec![],
3031            refs: vec![],
3032            names: vec![],
3033        };
3034        let mut fresh = HashMap::new();
3035        fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
3036        let graph_files = vec![GraphFile {
3037            rel: "src/A.cs".to_string(),
3038            mtime: 1,
3039        }];
3040        rebuild_graph(&dir, &graph_files, &fresh, true, None).unwrap();
3041
3042        assert!(graph_dir(&dir).join("fragments-v18.json").exists());
3043        assert!(graph_dir(&dir).join("fragments-index-v18.json").exists());
3044        assert!(
3045            !graph_dir(&dir).join("fragments-v17.json").exists(),
3046            "the v17 pair is deleted -- rename IS the invalidation"
3047        );
3048        assert!(!graph_dir(&dir).join("fragments-index-v16.json").exists());
3049    }
3050
3051    // --- v8: FragRef's outerTypes, appended last -----------------------------
3052
3053    #[test]
3054    fn frag_ref_outer_types_is_appended_last_after_receiver_args() {
3055        let r = FragRef {
3056            receiver_args: Some(vec!["FutureState".into()]),
3057            outer_types: vec!["Outer".into(), "Inner".into()],
3058            ..frag_ref(false, Some("Binder"), Some(1))
3059        };
3060        let json = serde_json::to_string(&r).unwrap();
3061        assert_eq!(
3062            json,
3063            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState"],"outerTypes":["Outer","Inner"]}"#
3064        );
3065        let reparsed: FragRef = serde_json::from_str(&json).unwrap();
3066        assert_eq!(
3067            reparsed.outer_types,
3068            vec!["Outer".to_string(), "Inner".to_string()]
3069        );
3070    }
3071
3072    // --- The two chain facts, appended after outerTypes ----
3073
3074    #[test]
3075    fn frag_ref_property_owner_and_call_pair_are_appended_last_and_omitted_when_absent() {
3076        let hop = FragRef {
3077            qualified: Some("head.Dial".into()),
3078            name: "Dial".into(),
3079            receiver_property_owner: Some("Widget".into()),
3080            ..frag_ref(false, None, Some(0))
3081        };
3082        assert_eq!(
3083            serde_json::to_string(&hop).unwrap(),
3084            r#"{"kind":"uses-member","name":"Dial","qualified":"head.Dial","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverPropertyOwner":"Widget"}"#
3085        );
3086
3087        let from_call = FragRef {
3088            receiver_call_owner: Some("Factory".into()),
3089            receiver_call_member: Some("Make".into()),
3090            ..frag_ref(false, None, Some(0))
3091        };
3092        assert_eq!(
3093            serde_json::to_string(&from_call).unwrap(),
3094            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverCallOwner":"Factory","receiverCallMember":"Make"}"#
3095        );
3096
3097        // An older cached fragment carries neither key, and absent must
3098        // read back as "no chain fact" -- which is what leaves such a ref
3099        // exactly where it was before this generation.
3100        let plain = serde_json::to_string(&frag_ref(false, Some("Binder"), Some(1))).unwrap();
3101        assert!(
3102            !plain.contains("receiverPropertyOwner") && !plain.contains("receiverCall"),
3103            "{plain}"
3104        );
3105        let reparsed: FragRef = serde_json::from_str(&plain).unwrap();
3106        assert_eq!(reparsed.receiver_property_owner, None);
3107        assert_eq!(reparsed.receiver_call_owner, None);
3108        assert_eq!(reparsed.receiver_call_member, None);
3109    }
3110
3111    #[test]
3112    fn frag_ref_an_empty_or_absent_outer_types_serializes_to_nothing_and_reads_back_empty() {
3113        let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
3114        assert!(
3115            !json.contains("outerTypes"),
3116            "a namespace-level ref keeps its exact pre-v8 bytes: {json}"
3117        );
3118        // A pre-v8 cached fragment has no key at all, and absent must mean the
3119        // same thing as empty -- which is what keeps it off the nested step.
3120        let pre_v8: FragRef = serde_json::from_str(
3121            r#"{"kind":"uses-type","name":"Widget","line":3,"namespace":"App.Core"}"#,
3122        )
3123        .unwrap();
3124        assert!(pre_v8.outer_types.is_empty());
3125    }
3126
3127    // --- v18: FragDef's methodParams and FragRef's receiverLambda -----------
3128
3129    #[test]
3130    fn fragment_round_trip_keeps_method_params_and_receiver_lambda() {
3131        let mut method_params = OrderedMap::new();
3132        method_params.insert(
3133            "Register".to_string(),
3134            vec![
3135                vec!["Action<Options>".to_string()],
3136                vec!["string".to_string(), "Func<Options,bool>".to_string()],
3137            ],
3138        );
3139        let d = FragDef {
3140            method_params: method_params.clone(),
3141            ..frag_def(&[], &[], &[], &[], &[])
3142        };
3143        let d_json = serde_json::to_string(&d).unwrap();
3144        assert!(
3145            d_json.ends_with(
3146                r#""methodParams":{"Register":[["Action<Options>"],["string","Func<Options,bool>"]]}}"#
3147            ),
3148            "methodParams is appended last, after methodArities: {d_json}"
3149        );
3150        let d_reparsed: FragDef = serde_json::from_str(&d_json).unwrap();
3151        assert_eq!(d_reparsed.method_params, method_params);
3152
3153        // Absent when empty, and an absent key deserializes back to empty --
3154        // the safe default for every fragment cached before this field
3155        // existed.
3156        let d_plain_json = serde_json::to_string(&frag_def(&[], &[], &[], &[], &[])).unwrap();
3157        assert!(!d_plain_json.contains("methodParams"), "{d_plain_json}");
3158        let d_plain_reparsed: FragDef = serde_json::from_str(&d_plain_json).unwrap();
3159        assert!(d_plain_reparsed.method_params.is_empty());
3160
3161        let slot = FragLambdaSlot {
3162            owner: "Registrar".to_string(),
3163            member: "Register".to_string(),
3164            arg_count: 1,
3165            arg_index: 0,
3166            arity: 1,
3167            index: 0,
3168        };
3169        let r = FragRef {
3170            receiver_lambda: Some(slot.clone()),
3171            ..frag_ref(false, None, None)
3172        };
3173        let r_json = serde_json::to_string(&r).unwrap();
3174        assert!(
3175            r_json.ends_with(
3176                r#""receiverLambda":{"owner":"Registrar","member":"Register","argCount":1,"argIndex":0,"arity":1,"index":0}}"#
3177            ),
3178            "receiverLambda is appended last, after receiverLocal: {r_json}"
3179        );
3180        let r_reparsed: FragRef = serde_json::from_str(&r_json).unwrap();
3181        assert_eq!(r_reparsed.receiver_lambda, Some(slot));
3182
3183        // Absent when `None`, and an absent key deserializes back to `None`.
3184        let r_plain_json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
3185        assert!(!r_plain_json.contains("receiverLambda"), "{r_plain_json}");
3186        let r_plain_reparsed: FragRef = serde_json::from_str(&r_plain_json).unwrap();
3187        assert_eq!(r_plain_reparsed.receiver_lambda, None);
3188    }
3189}