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-v15.json, fragments-index-v15.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//   - `stats.edges_by_kind` has a FIXED key order (inherits, uses-type,
32//     imports, uses-member, ctor-di) -- not
33//     alphabetical, not insertion order of first edge seen. A plain struct
34//     with that declared field order reproduces it.
35//   - `stats.ambiguous_pct` is `round(x*1000)/10`: a number that prints
36//     WITHOUT a decimal point when whole (`20`, not `20.0`) and with
37//     exactly one decimal digit otherwise (`33.3`). Rust's f64 Serialize
38//     (ryu-backed) does not reproduce this -- see `Percent1` below, which
39//     stores tenths as an integer and branches serialize_i64/serialize_f64
40//     so whole values come out as bare integers, without needing serde_json's
41//     `arbitrary_precision`/`raw_value` features (neither is enabled).
42//   - fragments.json / fragments-index.json are objects keyed by
43//     repo-relative path, in file-walk order (fragments-index.json is
44//     rebuilt by iterating the same cache object fragments.json used).
45//     A `HashMap` has no serialization order guarantee and `serde_json`'s
46//     default `Map` (no `preserve_order` feature, which is off here) sorts
47//     by key -- neither matches. `OrderedMap<V>` below hand-rolls
48//     Serialize/Deserialize (using serde's own `SerializeMap` / `MapAccess`
49//     traits, not a bespoke JSON writer) to preserve insertion order.
50//   - `built_at_head` records `git -C <root> rev-parse HEAD` (`null` on any
51//     failure incl. no commits yet). `manifest.rs` already owns that
52//     (`manifest::git_head`) -- `resolve_graph` (resolve.rs) calls THAT, so
53//     this module does not duplicate it.
54
55use std::collections::HashMap;
56use std::fmt;
57use std::fs;
58use std::io;
59use std::marker::PhantomData;
60use std::path::{Path, PathBuf};
61use std::sync::atomic::{AtomicU64, Ordering};
62
63use serde::de::{MapAccess, Visitor};
64use serde::ser::SerializeMap;
65use serde::{Deserialize, Deserializer, Serialize, Serializer};
66
67use crate::extract;
68use crate::repo::{git_common_dir, scout_dir};
69
70// ---------------------------------------------------------------------------
71// Path resolution (private helpers for the graph directory and cache paths).
72// ---------------------------------------------------------------------------
73
74// The def/ref graph lives in the git COMMON dir (shared by every linked
75// worktree, alongside the manifest), one level down; a non-git root falls
76// back to `<root>/.scout/graph/`.
77fn graph_dir(root: &Path) -> PathBuf {
78    match git_common_dir(root) {
79        Some(common) => common.join("scout").join("graph"),
80        None => scout_dir(root).join("graph"),
81    }
82}
83
84/// Path to the graph artifact (`graph.json`) for `root`.
85pub fn graph_json_path(root: &Path) -> PathBuf {
86    graph_dir(root).join("graph.json")
87}
88
89// The version in both cache filenames is the fragment SCHEMA version, bumped
90// whenever the extractor starts recording something old cached fragments
91// lack -- v10 added def `type_params`/`base_generic_args` and the new
92// `ctor-param` ref kind (with its `args` field), v11 added markup graph
93// facts (a `.xaml` file's fragment now carries the `x:Class` def and its
94// element/`x:Bind` refs, where every cached markup fragment before it carried
95// names only), v12 added the TS/JS reference fragment, v13 added def
96// `propertyTypes` and ref `receiverPropertyOwner` plus the ref
97// `receiverCallOwner`/`receiverCallMember` pair, plus the foreach
98// element-type fact -- no new field (it settles into the existing
99// `receiver_type`), but a cached fragment from before it can still disagree
100// with a fresh one for the same unchanged file, so it rides the same bump
101// rather than skipping it. The rename IS the invalidation mechanism:
102// pre-bump caches stop being found, every file reparses
103// once, no reader carries version-compat logic. Writers delete every
104// superseded generation (see `remove_superseded_caches`).
105fn fragments_cache_path(root: &Path) -> PathBuf {
106    graph_dir(root).join("fragments-v15.json")
107}
108
109fn fragments_index_path(root: &Path) -> PathBuf {
110    graph_dir(root).join("fragments-index-v15.json")
111}
112
113// Every generation below the current one, not just the immediately previous:
114// a repo mapped last under v1 and never since would otherwise keep its v1
115// pair forever.
116const SUPERSEDED_CACHE_FILES: &[&str] = &[
117    "fragments.json",
118    "fragments-index.json",
119    "fragments-v2.json",
120    "fragments-index-v2.json",
121    "fragments-v3.json",
122    "fragments-index-v3.json",
123    "fragments-v4.json",
124    "fragments-index-v4.json",
125    "fragments-v5.json",
126    "fragments-index-v5.json",
127    "fragments-v6.json",
128    "fragments-index-v6.json",
129    "fragments-v7.json",
130    "fragments-index-v7.json",
131    "fragments-v8.json",
132    "fragments-index-v8.json",
133    "fragments-v9.json",
134    "fragments-index-v9.json",
135    "fragments-v10.json",
136    "fragments-index-v10.json",
137    "fragments-v11.json",
138    "fragments-index-v11.json",
139    "fragments-v12.json",
140    "fragments-index-v12.json",
141    "fragments-v13.json",
142    "fragments-index-v13.json",
143    "fragments-v14.json",
144    "fragments-index-v14.json",
145];
146
147fn remove_superseded_caches(root: &Path) {
148    for name in SUPERSEDED_CACHE_FILES {
149        // Best-effort cleanup only -- a leftover superseded file is inert.
150        let _ = fs::remove_file(graph_dir(root).join(name));
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Atomic writes -- tmp file in the SAME directory as the target (so
156// the final `rename` is same-filesystem and therefore atomic), unique name
157// so concurrent writers (this process's own sequential graph/fragments/index
158// writes, or a genuinely concurrent process) never collide. Writing to a
159// tmp file and renaming is a deliberate upgrade over an in-place write --
160// output BYTES are unaffected, only the write's crash-safety.
161// ---------------------------------------------------------------------------
162
163static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
164
165fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> io::Result<()> {
166    let dir = path.parent().unwrap_or_else(|| Path::new("."));
167    fs::create_dir_all(dir)?;
168    let file_name = path
169        .file_name()
170        .and_then(|n| n.to_str())
171        .unwrap_or("artifact");
172    let counter = TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
173    let tmp_path = dir.join(format!(".{file_name}.tmp.{}.{counter}", std::process::id()));
174    let write_result = fs::write(&tmp_path, bytes);
175    if write_result.is_err() {
176        let _ = fs::remove_file(&tmp_path);
177        write_result?;
178    }
179    fs::rename(&tmp_path, path)?;
180    Ok(())
181}
182
183fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> io::Result<()> {
184    let bytes = serde_json::to_vec(value).map_err(io::Error::other)?;
185    atomic_write_bytes(path, &bytes)
186}
187
188// ---------------------------------------------------------------------------
189// Insertion-ordered string-keyed map -- backs fragments.json's `{rel:
190// {mtime, fragment}}` and fragments-index.json's `{rel: mtime}`. A HashMap
191// has no defined iteration order and serde_json's default Map serializer
192// (the `preserve_order` feature is not enabled) sorts by key; neither
193// matches the required file-walk-order construction. Built on serde's own
194// SerializeMap/MapAccess traits,
195// not a hand-rolled JSON writer -- serde_json still owns the actual byte
196// encoding.
197// ---------------------------------------------------------------------------
198
199#[derive(Debug, Clone, PartialEq)]
200/// Represents `OrderedMap`.
201pub struct OrderedMap<V> {
202    entries: Vec<(String, V)>,
203    index: HashMap<String, usize>,
204}
205
206impl<V> OrderedMap<V> {
207    /// Creates an empty insertion-ordered map.
208    pub fn new() -> Self {
209        Self {
210            entries: Vec::new(),
211            index: HashMap::new(),
212        }
213    }
214
215    /// Insert, or overwrite in place when the key already exists --
216    /// preserves that key's ORIGINAL position, like assigning to an existing
217    /// key on a JSON object.
218    pub fn insert(&mut self, key: String, value: V) {
219        match self.index.get(&key) {
220            Some(&i) => self.entries[i].1 = value,
221            None => {
222                self.index.insert(key.clone(), self.entries.len());
223                self.entries.push((key, value));
224            }
225        }
226    }
227
228    /// Returns the value associated with `key`, if present.
229    pub fn get(&self, key: &str) -> Option<&V> {
230        self.index.get(key).map(|&i| &self.entries[i].1)
231    }
232
233    /// Returns the number of entries.
234    pub fn len(&self) -> usize {
235        self.entries.len()
236    }
237
238    /// Returns whether the map contains no entries.
239    pub fn is_empty(&self) -> bool {
240        self.entries.is_empty()
241    }
242
243    /// Iterates over entries in insertion order.
244    pub fn iter(&self) -> impl Iterator<Item = (&String, &V)> {
245        self.entries.iter().map(|(k, v)| (k, v))
246    }
247}
248
249impl<V> Default for OrderedMap<V> {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl<V: Serialize> Serialize for OrderedMap<V> {
256    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
257        let mut map = serializer.serialize_map(Some(self.entries.len()))?;
258        for (k, v) in &self.entries {
259            map.serialize_entry(k, v)?;
260        }
261        map.end()
262    }
263}
264
265impl<'de, V: Deserialize<'de>> Deserialize<'de> for OrderedMap<V> {
266    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
267        struct OrderedMapVisitor<V>(PhantomData<V>);
268
269        impl<'de, V: Deserialize<'de>> Visitor<'de> for OrderedMapVisitor<V> {
270            type Value = OrderedMap<V>;
271
272            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
273                f.write_str("a JSON object")
274            }
275
276            fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
277                let mut out = OrderedMap::new();
278                while let Some((k, v)) = map.next_entry::<String, V>()? {
279                    out.insert(k, v);
280                }
281                Ok(out)
282            }
283        }
284
285        deserializer.deserialize_map(OrderedMapVisitor(PhantomData))
286    }
287}
288
289// ---------------------------------------------------------------------------
290// `stats.ambiguous_pct` -- see module header. Stored as tenths (an i64) so
291// the "is this whole?" branch is exact integer arithmetic, never a float
292// epsilon comparison.
293// ---------------------------------------------------------------------------
294
295#[derive(Debug, Clone, Copy, PartialEq, Eq)]
296/// Represents `Percent1`.
297pub struct Percent1(pub i64);
298
299impl Percent1 {
300    /// Returns a zero percentage value.
301    pub fn zero() -> Self {
302        Percent1(0)
303    }
304
305    /// `ambiguous_pct` as `round((ambiguous / attempts) * 1000) / 10`, or 0
306    /// when there are no attempts. Takes the pre-division inputs so the
307    /// rounding happens exactly once.
308    pub fn from_ratio(numerator: usize, denominator: usize) -> Self {
309        if denominator == 0 {
310            return Percent1::zero();
311        }
312        let raw = (numerator as f64 / denominator as f64) * 1000.0;
313        Percent1(raw.round() as i64)
314    }
315}
316
317impl Serialize for Percent1 {
318    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
319        if self.0 % 10 == 0 {
320            serializer.serialize_i64(self.0 / 10)
321        } else {
322            serializer.serialize_f64(self.0 as f64 / 10.0)
323        }
324    }
325}
326
327impl<'de> Deserialize<'de> for Percent1 {
328    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
329        let v = f64::deserialize(deserializer)?;
330        Ok(Percent1((v * 10.0).round() as i64))
331    }
332}
333
334// ---------------------------------------------------------------------------
335// graph.json schema.
336// ---------------------------------------------------------------------------
337
338#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
339/// Represents `AlsoIn`.
340pub struct AlsoIn {
341    /// The file value.
342    pub file: String,
343    /// The line value.
344    pub line: usize,
345}
346
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
348/// Represents `Def`.
349pub struct Def {
350    /// The id value.
351    pub id: String,
352    /// The name value.
353    pub name: String,
354    /// The namespace value.
355    pub namespace: String,
356    /// The kind value.
357    pub kind: String,
358    /// The file value.
359    pub file: String,
360    /// The line value.
361    pub line: usize,
362    /// The methods value.
363    pub methods: Vec<String>,
364    /// Test-coverage stage -- the one member fact that SURVIVES onto disk
365    /// (`properties`/`fields`/`extensionMethods`/`bases` are resolution inputs
366    /// only, stripped before the row is written): `devscout tests` reads it back
367    /// out of graph.json. Positioned after `methods` and before `also_in`, and
368    /// omitted when empty, so a def declaring no tests keeps its exact
369    /// pre-stage bytes.
370    #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
371    pub test_methods: Vec<String>,
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    /// The also in value.
374    pub also_in: Vec<AlsoIn>,
375    #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
376    /// The end line value.
377    pub end_line: usize,
378}
379
380#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381/// Represents `Candidate`.
382pub struct Candidate {
383    /// The id value.
384    pub id: String,
385    /// The file value.
386    pub file: String,
387}
388
389/// The guess tag, appended LAST on every edge kind that can
390/// carry it. `heuristic: true` is set only on an edge a heuristic tier
391/// emitted and never writes `heuristic: false`, so this side pairs
392/// `skip_serializing_if` with `default`: absent == precise, and a precise
393/// edge's bytes are exactly what they were before the tag existed. Only
394/// the resolver's heuristic tiers set it today (both
395/// `uses-member`), but the flag lives on all three targeted kinds because
396/// the query layer's heuristic adjacency is kind-keyed, so a future tier
397/// tagging a `uses-type` edge needs no schema change.
398#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399#[serde(tag = "kind")]
400pub enum Edge {
401    #[serde(rename = "inherits")]
402    /// Represents `Inherits`.
403    Inherits {
404        /// The value value.
405        from_file: String,
406        /// The value value.
407        from_line: usize,
408        /// The value value.
409        to: String,
410        /// The value value.
411        to_file: String,
412        #[serde(default, skip_serializing_if = "is_false")]
413        /// The value value.
414        heuristic: bool,
415    },
416    #[serde(rename = "uses-type")]
417    /// Represents `UsesType`.
418    UsesType {
419        /// The value value.
420        from_file: String,
421        /// The value value.
422        from_line: usize,
423        /// The value value.
424        to: String,
425        /// The value value.
426        to_file: String,
427        #[serde(default, skip_serializing_if = "is_false")]
428        /// The value value.
429        heuristic: bool,
430    },
431    #[serde(rename = "uses-member")]
432    /// Represents `UsesMember`.
433    UsesMember {
434        /// The value value.
435        from_file: String,
436        /// The value value.
437        from_line: usize,
438        /// The value value.
439        to: String,
440        /// The value value.
441        to_file: String,
442        #[serde(default, skip_serializing_if = "is_false")]
443        /// The value value.
444        heuristic: bool,
445    },
446    #[serde(rename = "imports")]
447    /// The value value.
448    Imports {
449        /// The source file.
450        from_file: String,
451        /// The source line.
452        from_line: usize,
453        /// The imported target.
454        target: String,
455    },
456    #[serde(rename = "ambiguous")]
457    /// Represents `Ambiguous`.
458    Ambiguous {
459        /// The value value.
460        origin: String,
461        /// The value value.
462        from_file: String,
463        /// The value value.
464        from_line: usize,
465        /// The value value.
466        raw: String,
467        /// The value value.
468        candidates: Vec<Candidate>,
469        /// The value value.
470        candidate_count: usize,
471    },
472    /// Constructor-parameter DI resolution (see resolve.rs's
473    /// `resolve_ctor_param`). Field order (`kind`, `from_file`, `from_line`,
474    /// `iface`, `resolution`, `args`, `to`, `candidates`) is significant --
475    /// it fixes the serialized field order.
476    /// `iface` is the injected type's bare identifier; `args` its closed
477    /// generic arguments, present only when it has any; `to` the resolved
478    /// implementation's def id, present only for 'plain'/'closed'/
479    /// 'open-generic'; `candidates` the capped, sorted tie list, present only
480    /// for 'ambiguous'.
481    #[serde(rename = "ctor-di")]
482    CtorDi {
483        /// The value value.
484        from_file: String,
485        /// The value value.
486        from_line: usize,
487        /// The value value.
488        iface: String,
489        /// The value value.
490        resolution: String,
491        #[serde(default, skip_serializing_if = "Option::is_none")]
492        /// The value value.
493        args: Option<Vec<String>>,
494        #[serde(default, skip_serializing_if = "Option::is_none")]
495        /// The value value.
496        to: Option<String>,
497        #[serde(default, skip_serializing_if = "Vec::is_empty")]
498        /// The value value.
499        candidates: Vec<Candidate>,
500    },
501    /// A TS/TSX module import. Distinct from `imports` (C#'s
502    /// `using` directive, which names a NAMESPACE and resolves to no file):
503    /// `target` is the specifier as written and `to_file` the file it
504    /// resolved to. `via` is appended LAST and present only on a
505    /// barrel-followed edge, naming the module the source literally imported
506    /// so a reader can tell the routing table apart from the dependency.
507    #[serde(rename = "import")]
508    Import {
509        /// The value value.
510        from_file: String,
511        /// The value value.
512        from_line: usize,
513        /// The value value.
514        target: String,
515        /// The value value.
516        to_file: String,
517        #[serde(default, skip_serializing_if = "Option::is_none")]
518        /// The value value.
519        via: Option<String>,
520    },
521    /// A call or `new` naming an imported or locally-exported
522    /// declaration.
523    #[serde(rename = "call")]
524    /// The value value.
525    Call {
526        /// The source file.
527        from_file: String,
528        /// The source line.
529        from_line: usize,
530        /// The target definition identifier.
531        to: String,
532        /// The target file.
533        to_file: String,
534    },
535    /// A JSX tag naming a component declaration.
536    #[serde(rename = "jsx-use")]
537    /// The value value.
538    JsxUse {
539        /// The source file.
540        from_file: String,
541        /// The source line.
542        from_line: usize,
543        /// The target definition identifier.
544        to: String,
545        /// The target file.
546        to_file: String,
547    },
548    /// An action creator or thunk handed to a dispatching call
549    /// (`dispatch(...)`, `ofType(...)`).
550    #[serde(rename = "dispatch")]
551    /// The value value.
552    Dispatch {
553        /// The source file.
554        from_file: String,
555        /// The source line.
556        from_line: usize,
557        /// The target definition identifier.
558        to: String,
559        /// The target file.
560        to_file: String,
561    },
562}
563
564#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
565/// Represents `EdgesByKind`.
566pub struct EdgesByKind {
567    /// The inherits value.
568    pub inherits: usize,
569    #[serde(rename = "uses-type")]
570    /// The uses type value.
571    pub uses_type: usize,
572    /// The imports value.
573    pub imports: usize,
574    #[serde(rename = "uses-member")]
575    /// The uses member value.
576    pub uses_member: usize,
577    /// Appended LAST, matching the `ctor-di` slot in the fixed key order.
578    #[serde(rename = "ctor-di")]
579    pub ctor_di: usize,
580    /// The four TS edge counts, appended after `ctor-di` in this exact order
581    /// and ONLY when the repo carries a TS fragment at all. A C#-only repo's
582    /// stats block omits them entirely -- which is why these are `Option` and
583    /// not a plain `0`.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub import: Option<usize>,
586    #[serde(default, skip_serializing_if = "Option::is_none")]
587    /// The call value.
588    pub call: Option<usize>,
589    #[serde(rename = "jsx-use", default, skip_serializing_if = "Option::is_none")]
590    /// The jsx use value.
591    pub jsx_use: Option<usize>,
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    /// The dispatch value.
594    pub dispatch: Option<usize>,
595}
596
597#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598/// Represents `Stats`.
599pub struct Stats {
600    /// The def count value.
601    pub def_count: usize,
602    /// The file count value.
603    pub file_count: usize,
604    /// The edges by kind value.
605    pub edges_by_kind: EdgesByKind,
606    /// The ambiguous count value.
607    pub ambiguous_count: usize,
608    /// The ambiguous pct value.
609    pub ambiguous_pct: Percent1,
610    /// The unresolved external count value.
611    pub unresolved_external_count: usize,
612    /// Appended LAST, and always serialized (unlike the edge flag above): the
613    /// key is written unconditionally, so a graph with no guesses still
614    /// carries `"heuristic_edge_count":0`. `default` is for the READ side only
615    /// -- a graph.json written before this counter existed has no such key,
616    /// and it must read back as 0 rather than fail to parse.
617    #[serde(default)]
618    pub heuristic_edge_count: usize,
619    /// Test-coverage stage -- appended LAST and always serialized, like the
620    /// stage-4 counter above it. Counts merged DEF ROWS, not fragment entries,
621    /// so a partial test class split across two files is one test def.
622    #[serde(default)]
623    pub test_def_count: usize,
624    /// The TS resolver's own four counters, appended LAST inside
625    /// `stats` and omitted entirely when the repo carries no TS fragment (the
626    /// same omit-when-empty rule every other appended fact follows).
627    #[serde(default, skip_serializing_if = "Option::is_none")]
628    pub ts: Option<crate::tsgraph::TsStats>,
629}
630
631/// One row of the full name index. Field order (`name`, `kind`,
632/// `file`, `line`, `owner`) is significant; `owner` carries the
633/// declaring type's def id for a member and is omitted for a type, an enum
634/// member, and every markup or resource key.
635#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
636pub struct GraphName {
637    /// The name value.
638    pub name: String,
639    /// The kind value.
640    pub kind: String,
641    /// The file value.
642    pub file: String,
643    /// The line value.
644    pub line: usize,
645    #[serde(default, skip_serializing_if = "String::is_empty")]
646    /// The owner value.
647    pub owner: String,
648}
649
650#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
651/// Represents `Graph`.
652pub struct Graph {
653    /// The schema version value.
654    pub schema_version: u32,
655    /// The built at head value.
656    pub built_at_head: Option<String>,
657    /// The defs value.
658    pub defs: Vec<Def>,
659    /// The edges value.
660    pub edges: Vec<Edge>,
661    /// The stats value.
662    pub stats: Stats,
663    /// Appended LAST, after `stats`, and omitted when empty: the
664    /// house rule for every added field, and what keeps a graph built over a
665    /// set that declares no name byte-identical to what it was.
666    #[serde(default, skip_serializing_if = "Vec::is_empty")]
667    pub names: Vec<GraphName>,
668}
669
670/// Reads and deserializes the repository graph, returning `None` on failure.
671pub fn read_graph(root: &Path) -> Option<Graph> {
672    let text = fs::read_to_string(graph_json_path(root)).ok()?;
673    serde_json::from_str(&text).ok()
674}
675
676fn write_graph(root: &Path, graph: &Graph) -> io::Result<()> {
677    atomic_write_json(&graph_json_path(root), graph)
678}
679
680// ---------------------------------------------------------------------------
681// Fragments cache schema -- the RAW (unresolved) per-file extraction output,
682// keyed by repo-relative path. Mirrors extract.rs's DefRecord/UsingRecord/
683// RefRecord field-for-field; the serde impls live here rather than on the
684// extractor's own types.
685// ---------------------------------------------------------------------------
686
687/// Field order is significant: id, name, namespace, kind, line, methods, then
688/// the member-fact additions in that exact order -- properties, fields,
689/// methodReturns -- and after those extensionMethods, appended LAST. Each is
690/// omitted entirely when empty, so a type declaring none of them serializes
691/// exactly as it did before those additions.
692#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
693pub struct FragDef {
694    /// The id value.
695    pub id: String,
696    /// The name value.
697    pub name: String,
698    /// The namespace value.
699    pub namespace: String,
700    /// The kind value.
701    pub kind: String,
702    /// The line value.
703    pub line: usize,
704    /// The methods value.
705    pub methods: Vec<String>,
706    #[serde(default, skip_serializing_if = "Vec::is_empty")]
707    /// The properties value.
708    pub properties: Vec<String>,
709    #[serde(default, skip_serializing_if = "Vec::is_empty")]
710    /// The fields value.
711    pub fields: Vec<String>,
712    /// Member name -> declared return type NAME, in FIRST-DECLARATION SOURCE
713    /// ORDER -- deliberately `OrderedMap`, never a `BTreeMap`: this is an
714    /// ordered pair list, and sorting the keys here would break the
715    /// serialized bytes.
716    #[serde(
717        default,
718        rename = "methodReturns",
719        skip_serializing_if = "OrderedMap::is_empty"
720    )]
721    pub method_returns: OrderedMap<String>,
722    /// The extension methods this type declares, in source
723    /// order, deduped by (name, thisType, arityMin, arityMax). Appended after
724    /// `method_returns`, and omitted entirely when empty, so a type declaring
725    /// none keeps its exact prior bytes.
726    #[serde(
727        default,
728        rename = "extensionMethods",
729        skip_serializing_if = "Vec::is_empty"
730    )]
731    pub extension_methods: Vec<FragExtensionMethod>,
732    /// DIRECT base-type identifiers, source
733    /// order, deduped. Appended LAST, after `extension_methods`, omitted when
734    /// empty. A resolution input only: `resolve_graph` strips it (along with
735    /// properties/fields/extensionMethods) before graph.json's def rows, so
736    /// the on-disk def bytes are unchanged.
737    #[serde(default, skip_serializing_if = "Vec::is_empty")]
738    pub bases: Vec<String>,
739    /// The declaring type's own type-parameter names, empty for
740    /// every non-generic declaration. Appended after `bases`, omitted when
741    /// empty. A resolution input, like `bases`: the ctor-DI resolver's
742    /// "is this def itself an open-generic implementation" signal.
743    #[serde(default, rename = "typeParams", skip_serializing_if = "Vec::is_empty")]
744    pub type_params: Vec<String>,
745    /// Per base name that carried a type-argument list, that
746    /// list's generic-arg descriptors relative to `type_params` (`OrderedMap`
747    /// for the same reason `method_returns` is one: the serialized key order
748    /// is significant). Appended after `type_params`, omitted when
749    /// empty.
750    #[serde(
751        default,
752        rename = "baseGenericArgs",
753        skip_serializing_if = "OrderedMap::is_empty"
754    )]
755    pub base_generic_args: OrderedMap<Vec<String>>,
756    /// The methods a test framework would DISCOVER as
757    /// tests, source order, deduped. Appended LAST, after `baseGenericArgs`,
758    /// omitted when empty. Unlike its neighbours this one is NOT stripped by
759    /// `resolve_graph`: it is what `devscout tests` answers from.
760    #[serde(default, rename = "testMethods", skip_serializing_if = "Vec::is_empty")]
761    pub test_methods: Vec<String>,
762    /// Property name -> declared type fact, in source order and
763    /// under the same dedup as `properties`. An `OrderedMap` for the same
764    /// reason `method_returns` is one: the serialized key order is significant.
765    /// Appended LAST, after `test_methods`, omitted when empty. A
766    /// resolution input only, like `properties`/`fields`.
767    #[serde(
768        default,
769        rename = "propertyTypes",
770        skip_serializing_if = "OrderedMap::is_empty"
771    )]
772    pub property_types: OrderedMap<FragFact>,
773    #[serde(default, rename = "endLine", skip_serializing_if = "is_zero")]
774    /// The end line value.
775    pub end_line: usize,
776}
777
778/// One declared type fact: the type NAME, plus its top-level
779/// type-argument descriptors when the declaration carried any. Field order
780/// (`type`, `args`) is significant, and `args` is omitted when absent.
781#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
782pub struct FragFact {
783    #[serde(rename = "type")]
784    /// The type name value.
785    pub type_name: String,
786    #[serde(default, skip_serializing_if = "Option::is_none")]
787    /// The args value.
788    pub args: Option<Vec<String>>,
789}
790
791/// One `extensionMethods` entry. Serialized field order (`name`, `thisType`,
792/// `arityMin`, `arityMax`, `thisArgs`) is significant, and serde emits struct
793/// fields in declaration order. The two arity halves are NOT optional: they joined the
794/// schema with the v6 cache rename, so no fragment this reader can meet is
795/// missing them. `arity_max` is signed because -1 is the unbounded-`params`
796/// sentinel.
797#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
798pub struct FragExtensionMethod {
799    /// The name value.
800    pub name: String,
801    #[serde(rename = "thisType")]
802    /// The this type value.
803    pub this_type: String,
804    #[serde(rename = "arityMin")]
805    /// The arity min value.
806    pub arity_min: usize,
807    #[serde(rename = "arityMax")]
808    /// The arity max value.
809    pub arity_max: i64,
810    /// Present only when the this-parameter type is generic (the key is omitted
811    /// key otherwise), so a non-generic entry keeps four fields exactly.
812    #[serde(default, rename = "thisArgs", skip_serializing_if = "Option::is_none")]
813    pub this_args: Option<Vec<String>>,
814}
815
816#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
817#[serde(untagged)]
818/// Represents `FragUsing`.
819pub enum FragUsing {
820    /// The value value.
821    Alias {
822        /// The alias name.
823        alias: String,
824        /// The aliased target.
825        target: String,
826        /// Whether the directive is global.
827        global: bool,
828    },
829    /// The value value.
830    Plain {
831        /// The imported namespace text.
832        text: String,
833        /// Whether the directive is global.
834        global: bool,
835    },
836}
837
838#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
839/// Represents `FragRef`.
840pub struct FragRef {
841    /// The kind value.
842    pub kind: String,
843    /// The name value.
844    pub name: String,
845    #[serde(default, skip_serializing_if = "Option::is_none")]
846    /// The qualified value.
847    pub qualified: Option<String>,
848    #[serde(default, skip_serializing_if = "Option::is_none")]
849    /// The member value.
850    pub member: Option<String>,
851    /// The line value.
852    pub line: usize,
853    /// Always serialized, including when `null` (imports refs) -- NOT
854    /// `skip_serializing_if`, unlike `qualified`/`member` which are omitted
855    /// entirely when absent. See extract.rs's RefRecord doc comment.
856    pub namespace: Option<String>,
857    #[serde(
858        default,
859        rename = "typeArgCount",
860        skip_serializing_if = "Option::is_none"
861    )]
862    /// The type arg count value.
863    pub type_arg_count: Option<usize>,
864    /// Type-certainty flag (see extract.rs's RefRecord). Serialized last and
865    /// only when `true`; an absent key reads back as false, which also makes
866    /// an older fragment JSON parse safely.
867    #[serde(default, skip_serializing_if = "is_false")]
868    pub generic: bool,
869    /// Receiver fact (see extract.rs's RefRecord). Appended AFTER `generic`,
870    /// and set only when a fact actually fired -- so it serializes last and
871    /// only when present, and an absent key reads back as "no fact", the safe
872    /// default.
873    #[serde(
874        default,
875        rename = "receiverType",
876        skip_serializing_if = "Option::is_none"
877    )]
878    pub receiver_type: Option<String>,
879    /// The callee arg count (see extract.rs's RefRecord). Appended AFTER
880    /// `receiverType`, set only when the member access was the callee of an
881    /// invocation -- so it serializes last and only when present, and an
882    /// absent key reads back as "not a call", which is what keeps a property
883    /// read out of the extension tier.
884    #[serde(default, rename = "argCount", skip_serializing_if = "Option::is_none")]
885    pub arg_count: Option<usize>,
886    /// Receiver generic-arg descriptors (see extract.rs's RefRecord). Appended
887    /// LAST, after `argCount`, set only when the receiver's DECLARED type was
888    /// generic -- an absent key reads back as "not generic", which is what
889    /// makes a generic-vs-non-generic pairing fail to unify.
890    #[serde(
891        default,
892        rename = "receiverArgs",
893        skip_serializing_if = "Option::is_none"
894    )]
895    pub receiver_args: Option<Vec<String>>,
896    /// Enclosing-type stack (see extract.rs's RefRecord). Appended LAST, after
897    /// `receiverArgs`, and set only when non-empty. A Vec rather than an Option
898    /// because empty and absent mean the same thing here -- a ref at namespace
899    /// level and an older cached fragment both read back as "no enclosing
900    /// type", which is what keeps them off the step.
901    #[serde(default, rename = "outerTypes", skip_serializing_if = "Vec::is_empty")]
902    pub outer_types: Vec<String>,
903    /// Generic-arg descriptors for a 'ctor-param' ref (see extract.rs's
904    /// RefRecord). Appended LAST of all, after `outerTypes`, and set only when
905    /// the parameter's type was generic.
906    #[serde(default, skip_serializing_if = "Option::is_none")]
907    pub args: Option<Vec<String>>,
908    /// (See extract.rs's RefRecord.) The type whose PROPERTY the
909    /// qualifier's last segment is, for a two-segment chain whose head the
910    /// enclosing scope could type. Appended after `args`, and never present
911    /// alongside `receiverType`.
912    #[serde(
913        default,
914        rename = "receiverPropertyOwner",
915        skip_serializing_if = "Option::is_none"
916    )]
917    pub receiver_property_owner: Option<String>,
918    /// (See extract.rs's RefRecord.) The type whose METHOD a
919    /// `var x = Q.M(...)` initializer called, and that method's name. Appended
920    /// LAST of all, always as a pair, and never alongside `receiverType`: an
921    /// absent pair reads back as "no call fact", which is what leaves the local
922    /// taken-but-unknown.
923    #[serde(
924        default,
925        rename = "receiverCallOwner",
926        skip_serializing_if = "Option::is_none"
927    )]
928    pub receiver_call_owner: Option<String>,
929    #[serde(
930        default,
931        rename = "receiverCallMember",
932        skip_serializing_if = "Option::is_none"
933    )]
934    /// The receiver call member value.
935    pub receiver_call_member: Option<String>,
936}
937
938fn is_false(b: &bool) -> bool {
939    !*b
940}
941
942fn is_zero(n: &usize) -> bool {
943    *n == 0
944}
945
946/// One declared member, with the line its own NAME token sits on.
947/// Field order (`name`, `kind`, `line`, `owner`) is significant, and `owner`
948/// is omitted when empty -- which is how
949/// a markup or resource key, owned by no C# type, serializes with three fields.
950#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
951pub struct FragName {
952    /// The name value.
953    pub name: String,
954    /// The kind value.
955    pub kind: String,
956    /// The line value.
957    pub line: usize,
958    #[serde(default, skip_serializing_if = "String::is_empty")]
959    /// The owner value.
960    pub owner: String,
961}
962
963#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
964/// Represents `Fragment`.
965pub struct Fragment {
966    /// The defs value.
967    pub defs: Vec<FragDef>,
968    /// The usings value.
969    pub usings: Vec<FragUsing>,
970    /// The refs value.
971    pub refs: Vec<FragRef>,
972    /// Appended LAST, after `refs`. Always serialized, like its
973    /// three siblings: a fragment's top-level arrays are a fixed shape, and
974    /// only the fields INSIDE a record follow the omit-when-empty rule.
975    #[serde(default)]
976    pub names: Vec<FragName>,
977}
978
979/// The two shapes a cached fragment can have. Each rel is keyed to whichever
980/// shape the file's grammar produced; the `ts: 1` tag is what tells them
981/// apart, on disk and at the
982/// resolver's door. Untagged, and TS FIRST: a C#/markup fragment carries no
983/// `ts` key (so the TS arm always fails on it) and a TS fragment carries no
984/// `usings` (so the C# arm always fails on it) -- the discrimination is total
985/// in both directions, never a first-match-wins guess.
986#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
987#[serde(untagged)]
988pub enum AnyFragment {
989    /// Represents `Ts`.
990    Ts(extract::TsFragment),
991    /// Represents `Cs`.
992    Cs(Fragment),
993}
994
995impl From<Fragment> for AnyFragment {
996    fn from(f: Fragment) -> Self {
997        AnyFragment::Cs(f)
998    }
999}
1000
1001impl From<extract::TsFragment> for AnyFragment {
1002    fn from(f: extract::TsFragment) -> Self {
1003        AnyFragment::Ts(f)
1004    }
1005}
1006
1007/// Build a graph fragment from this crate's extractor output.
1008/// `Extraction.purpose` has no fragment
1009/// counterpart -- the fragment is graph-only.
1010pub fn fragment_from_extraction(e: &extract::Extraction) -> Fragment {
1011    Fragment {
1012        defs: e
1013            .defs
1014            .iter()
1015            .map(|d| FragDef {
1016                id: d.id.clone(),
1017                name: d.name.clone(),
1018                namespace: d.namespace.clone(),
1019                kind: d.kind.clone(),
1020                line: d.line,
1021                methods: d.methods.clone(),
1022                properties: d.properties.clone(),
1023                fields: d.fields.clone(),
1024                method_returns: {
1025                    let mut m = OrderedMap::new();
1026                    for (name, returns) in &d.method_returns {
1027                        m.insert(name.clone(), returns.clone());
1028                    }
1029                    m
1030                },
1031                extension_methods: d
1032                    .extension_methods
1033                    .iter()
1034                    .map(|e| FragExtensionMethod {
1035                        name: e.name.clone(),
1036                        this_type: e.this_type.clone(),
1037                        arity_min: e.arity_min,
1038                        arity_max: e.arity_max,
1039                        this_args: e.this_args.clone(),
1040                    })
1041                    .collect(),
1042                bases: d.bases.clone(),
1043                type_params: d.type_params.clone(),
1044                base_generic_args: {
1045                    let mut m = OrderedMap::new();
1046                    for (name, args) in &d.base_generic_args {
1047                        m.insert(name.clone(), args.clone());
1048                    }
1049                    m
1050                },
1051                test_methods: d.test_methods.clone(),
1052                property_types: {
1053                    let mut m = OrderedMap::new();
1054                    for (name, fact) in &d.property_types {
1055                        m.insert(
1056                            name.clone(),
1057                            FragFact {
1058                                type_name: fact.type_name.clone(),
1059                                args: fact.args.clone(),
1060                            },
1061                        );
1062                    }
1063                    m
1064                },
1065                end_line: d.end_line,
1066            })
1067            .collect(),
1068        usings: e
1069            .usings
1070            .iter()
1071            .map(|u| match u {
1072                extract::UsingRecord::Alias {
1073                    alias,
1074                    target,
1075                    global,
1076                } => FragUsing::Alias {
1077                    alias: alias.clone(),
1078                    target: target.clone(),
1079                    global: *global,
1080                },
1081                extract::UsingRecord::Plain { text, global } => FragUsing::Plain {
1082                    text: text.clone(),
1083                    global: *global,
1084                },
1085            })
1086            .collect(),
1087        refs: e
1088            .refs
1089            .iter()
1090            .map(|r| FragRef {
1091                kind: r.kind.clone(),
1092                name: r.name.clone(),
1093                qualified: r.qualified.clone(),
1094                member: r.member.clone(),
1095                line: r.line,
1096                namespace: r.namespace.clone(),
1097                type_arg_count: r.type_arg_count,
1098                generic: r.generic,
1099                receiver_type: r.receiver_type.clone(),
1100                arg_count: r.arg_count,
1101                receiver_args: r.receiver_args.clone(),
1102                outer_types: r.outer_types.clone(),
1103                args: r.args.clone(),
1104                receiver_property_owner: r.receiver_property_owner.clone(),
1105                receiver_call_owner: r.receiver_call_owner.clone(),
1106                receiver_call_member: r.receiver_call_member.clone(),
1107            })
1108            .collect(),
1109        names: e
1110            .names
1111            .iter()
1112            .map(|n| FragName {
1113                name: n.name.clone(),
1114                kind: n.kind.clone(),
1115                line: n.line,
1116                owner: n.owner.clone(),
1117            })
1118            .collect(),
1119    }
1120}
1121
1122/// Markup and resource files never reach the extractor: a
1123/// line scan reads them instead (see markup.rs). They still get a fragment even
1124/// when they declare nothing, because the fragments index is what tells `map`
1125/// whether the graph's input set changed, and a file missing from it reads as a
1126/// permanent mismatch. `usings` is always empty: XAML has no using-directive
1127/// equivalent, and the `xmlns:` prefix declarations that stand in for one are
1128/// already resolved into fully-qualified ref names by the scan.
1129pub fn markup_fragment(root: &Path, rel: &str) -> Option<Fragment> {
1130    // Lossy read: an invalid byte becomes U+FFFD rather than dropping the
1131    // file.
1132    let text = String::from_utf8_lossy(&fs::read(root.join(rel)).ok()?).into_owned();
1133    let facts = crate::markup::markup_facts(rel, &text);
1134    Some(Fragment {
1135        defs: facts
1136            .defs
1137            .into_iter()
1138            .map(|d| FragDef {
1139                id: d.id,
1140                name: d.name,
1141                namespace: d.namespace,
1142                kind: d.kind,
1143                line: d.line,
1144                methods: Vec::new(),
1145                properties: Vec::new(),
1146                fields: Vec::new(),
1147                method_returns: OrderedMap::new(),
1148                extension_methods: Vec::new(),
1149                bases: Vec::new(),
1150                type_params: Vec::new(),
1151                base_generic_args: OrderedMap::new(),
1152                test_methods: Vec::new(),
1153                property_types: OrderedMap::new(),
1154                end_line: d.line,
1155            })
1156            .collect(),
1157        usings: Vec::new(),
1158        refs: facts
1159            .refs
1160            .into_iter()
1161            .map(|r| FragRef {
1162                kind: r.kind,
1163                name: r.name,
1164                qualified: r.qualified,
1165                member: r.member,
1166                line: r.line,
1167                // A markup ref site has no enclosing namespace of its own, and
1168                // the empty string is what a C# ref at file scope carries too --
1169                // never `None`, which is the 'imports' spelling.
1170                namespace: Some(String::new()),
1171                type_arg_count: Some(0),
1172                generic: false,
1173                receiver_type: None,
1174                arg_count: None,
1175                receiver_args: None,
1176                outer_types: Vec::new(),
1177                args: None,
1178                receiver_property_owner: None,
1179                receiver_call_owner: None,
1180                receiver_call_member: None,
1181            })
1182            .collect(),
1183        names: facts
1184            .names
1185            .into_iter()
1186            .map(|n| FragName {
1187                name: n.name,
1188                kind: n.kind,
1189                line: n.line,
1190                owner: n.owner,
1191            })
1192            .collect(),
1193    })
1194}
1195
1196#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1197/// Represents `FragmentCacheEntry`.
1198pub struct FragmentCacheEntry {
1199    /// The mtime value.
1200    pub mtime: i64,
1201    /// The fragment value.
1202    pub fragment: AnyFragment,
1203}
1204
1205fn read_fragments_cache(root: &Path) -> OrderedMap<FragmentCacheEntry> {
1206    match fs::read_to_string(fragments_cache_path(root)) {
1207        Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1208        Err(_) => OrderedMap::default(),
1209    }
1210}
1211
1212fn write_fragments_cache(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1213    atomic_write_json(&fragments_cache_path(root), cache)
1214}
1215
1216/// Read the fragments index (rel -> mtime). Used for the per-file reuse
1217/// decision, independent of the graph rebuild.
1218pub fn read_fragments_index(root: &Path) -> OrderedMap<i64> {
1219    match fs::read_to_string(fragments_index_path(root)) {
1220        Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
1221        Err(_) => OrderedMap::default(),
1222    }
1223}
1224
1225fn write_fragments_index(root: &Path, cache: &OrderedMap<FragmentCacheEntry>) -> io::Result<()> {
1226    let mut index = OrderedMap::new();
1227    for (rel, entry) in cache.iter() {
1228        index.insert(rel.clone(), entry.mtime);
1229    }
1230    atomic_write_json(&fragments_index_path(root), &index)
1231}
1232
1233/// Whether the fragments index is stale for the given graph files (any mtime
1234/// mismatch, or a differing file count).
1235pub fn index_is_stale(index: &OrderedMap<i64>, graph_files: &[GraphFile]) -> bool {
1236    graph_files
1237        .iter()
1238        .any(|f| index.get(&f.rel) != Some(&f.mtime))
1239        || index.len() != graph_files.len()
1240}
1241
1242// ---------------------------------------------------------------------------
1243// `devscout map`'s cache-then-resolve-then-write cycle.
1244// ---------------------------------------------------------------------------
1245
1246/// One file that contributes a graph fragment: C# (defs, refs, member names),
1247/// markup (`x:Class`/`x:Name`, `.resw` keys -- names only) or TS/JS
1248/// (imports, exported declarations, call/JSX/dispatch references).
1249#[derive(Debug, Clone)]
1250pub struct GraphFile {
1251    /// The rel value.
1252    pub rel: String,
1253    /// The mtime value.
1254    pub mtime: i64,
1255}
1256
1257#[derive(Debug)]
1258/// Represents `RebuildOutcome`.
1259pub enum RebuildOutcome {
1260    /// Represents `NotRebuilt`.
1261    NotRebuilt,
1262    /// Represents `Rebuilt`.
1263    Rebuilt(Graph),
1264}
1265
1266/// `fresh_fragments`: fragments this run's extractor produced for files that
1267/// needed reparsing (the same set that got a fresh purpose signature in the
1268/// real `devscout map` flow -- `mapcmd::map_repo` assembles it). `changed`: from the
1269/// caller's own `indexIsStale`-equivalent check against `csFiles`, passed in
1270/// rather than recomputed so the unchanged path never opens any graph file.
1271pub fn rebuild_graph(
1272    root: &Path,
1273    graph_files: &[GraphFile],
1274    fresh_fragments: &HashMap<String, AnyFragment>,
1275    changed: bool,
1276) -> io::Result<RebuildOutcome> {
1277    if !changed && graph_json_path(root).exists() {
1278        return Ok(RebuildOutcome::NotRebuilt);
1279    }
1280
1281    let cache = read_fragments_cache(root);
1282    // Split at the door: the two shapes share no field beyond
1283    // `defs`, and letting one resolver see the other's names would resolve a
1284    // C# type reference onto a same-named TypeScript const. Built as two vecs
1285    // here rather than one mixed vec split later so no fragment is cloned a
1286    // third time on the rebuild path.
1287    let mut merged_cs: Vec<(String, Fragment)> = Vec::new();
1288    let mut merged_ts: Vec<(String, extract::TsFragment)> = Vec::new();
1289    let mut new_cache: OrderedMap<FragmentCacheEntry> = OrderedMap::new();
1290    for f in graph_files {
1291        let fragment = match cache.get(&f.rel) {
1292            Some(entry) if entry.mtime == f.mtime => Some(entry.fragment.clone()),
1293            _ => fresh_fragments.get(&f.rel).cloned(),
1294        };
1295        let Some(fragment) = fragment else { continue };
1296        match &fragment {
1297            AnyFragment::Cs(c) => merged_cs.push((f.rel.clone(), c.clone())),
1298            AnyFragment::Ts(t) => merged_ts.push((f.rel.clone(), t.clone())),
1299        }
1300        new_cache.insert(
1301            f.rel.clone(),
1302            FragmentCacheEntry {
1303                mtime: f.mtime,
1304                fragment,
1305            },
1306        );
1307    }
1308
1309    let graph = crate::resolve::resolve_graph_with_ts(root, &merged_cs, &merged_ts);
1310    write_graph(root, &graph)?;
1311    write_fragments_cache(root, &new_cache)?;
1312    write_fragments_index(root, &new_cache)?;
1313    remove_superseded_caches(root);
1314    Ok(RebuildOutcome::Rebuilt(graph))
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320    use std::time::{SystemTime, UNIX_EPOCH};
1321
1322    fn temp_dir(label: &str) -> PathBuf {
1323        let nanos = SystemTime::now()
1324            .duration_since(UNIX_EPOCH)
1325            .unwrap()
1326            .as_nanos();
1327        let dir = std::env::temp_dir().join(format!("scout-graph-test-{label}-{nanos}"));
1328        fs::create_dir_all(&dir).unwrap();
1329        dir
1330    }
1331
1332    // --- OrderedMap: order + round-trip -------------------------------
1333
1334    #[test]
1335    fn ordered_map_preserves_insertion_order_through_json_round_trip() {
1336        let mut m: OrderedMap<i64> = OrderedMap::new();
1337        m.insert("z.cs".to_string(), 3);
1338        m.insert("a.cs".to_string(), 1);
1339        m.insert("m.cs".to_string(), 2);
1340        let json = serde_json::to_string(&m).unwrap();
1341        assert_eq!(json, r#"{"z.cs":3,"a.cs":1,"m.cs":2}"#);
1342
1343        let reparsed: OrderedMap<i64> = serde_json::from_str(&json).unwrap();
1344        let keys: Vec<&String> = reparsed.iter().map(|(k, _)| k).collect();
1345        assert_eq!(keys, vec!["z.cs", "a.cs", "m.cs"]);
1346    }
1347
1348    #[test]
1349    fn ordered_map_insert_overwrite_keeps_original_position() {
1350        let mut m: OrderedMap<i64> = OrderedMap::new();
1351        m.insert("a".to_string(), 1);
1352        m.insert("b".to_string(), 2);
1353        m.insert("a".to_string(), 99);
1354        let keys: Vec<&String> = m.iter().map(|(k, _)| k).collect();
1355        assert_eq!(keys, vec!["a", "b"]);
1356        assert_eq!(m.get("a"), Some(&99));
1357    }
1358
1359    // --- Percent1: number-shaped formatting -------------------------
1360
1361    #[test]
1362    fn percent1_whole_value_serializes_without_decimal_point() {
1363        let p = Percent1::from_ratio(2, 10); // 20.0 -> "20"
1364        assert_eq!(serde_json::to_string(&p).unwrap(), "20");
1365    }
1366
1367    #[test]
1368    fn percent1_fractional_value_serializes_with_one_decimal_digit() {
1369        let p = Percent1::from_ratio(1, 3); // 33.333... -> round to 33.3
1370        assert_eq!(serde_json::to_string(&p).unwrap(), "33.3");
1371    }
1372
1373    #[test]
1374    fn percent1_zero_denominator_is_zero() {
1375        assert_eq!(Percent1::from_ratio(0, 0), Percent1::zero());
1376        assert_eq!(serde_json::to_string(&Percent1::zero()).unwrap(), "0");
1377    }
1378
1379    // --- FragDef's new keys, appended last, omitted when empty ----------------------
1380
1381    fn frag_def(
1382        methods: &[&str],
1383        properties: &[&str],
1384        fields: &[&str],
1385        method_returns: &[(&str, &str)],
1386        extension_methods: &[(&str, &str, usize, i64)],
1387    ) -> FragDef {
1388        frag_def_with_bases(
1389            methods,
1390            properties,
1391            fields,
1392            method_returns,
1393            extension_methods,
1394            &[],
1395        )
1396    }
1397
1398    fn frag_def_with_bases(
1399        methods: &[&str],
1400        properties: &[&str],
1401        fields: &[&str],
1402        method_returns: &[(&str, &str)],
1403        extension_methods: &[(&str, &str, usize, i64)],
1404        bases: &[&str],
1405    ) -> FragDef {
1406        let mut mr = OrderedMap::new();
1407        for (k, v) in method_returns {
1408            mr.insert((*k).to_string(), (*v).to_string());
1409        }
1410        FragDef {
1411            id: "App.Facts.Widget".into(),
1412            name: "Widget".into(),
1413            namespace: "App.Facts".into(),
1414            kind: "class".into(),
1415            line: 3,
1416            methods: methods.iter().map(|s| s.to_string()).collect(),
1417            properties: properties.iter().map(|s| s.to_string()).collect(),
1418            fields: fields.iter().map(|s| s.to_string()).collect(),
1419            method_returns: mr,
1420            extension_methods: extension_methods
1421                .iter()
1422                .map(|(n, t, lo, hi)| FragExtensionMethod {
1423                    name: (*n).to_string(),
1424                    this_type: (*t).to_string(),
1425                    arity_min: *lo,
1426                    arity_max: *hi,
1427                    this_args: None,
1428                })
1429                .collect(),
1430            bases: bases.iter().map(|s| s.to_string()).collect(),
1431            type_params: Vec::new(),
1432            base_generic_args: OrderedMap::new(),
1433            property_types: OrderedMap::new(),
1434            test_methods: Vec::new(),
1435            end_line: 0,
1436        }
1437    }
1438
1439    #[test]
1440    fn frag_def_appends_properties_fields_and_method_returns_last_in_that_order() {
1441        let json = serde_json::to_string(&frag_def(
1442            &["GetAsync"],
1443            &["Prefix"],
1444            &["_log"],
1445            &[("GetAsync", "Task")],
1446            &[],
1447        ))
1448        .unwrap();
1449        assert_eq!(
1450            json,
1451            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["GetAsync"],"properties":["Prefix"],"fields":["_log"],"methodReturns":{"GetAsync":"Task"}}"#
1452        );
1453    }
1454
1455    #[test]
1456    fn frag_def_omits_all_three_new_keys_when_the_type_declares_none_of_them() {
1457        let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1458        assert_eq!(
1459            json,
1460            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"]}"#,
1461            "pre-stage-2 bytes preserved exactly"
1462        );
1463    }
1464
1465    // --- extensionMethods lands AFTER methodReturns, entry keys
1466    // in (name, thisType, arityMin, arityMax, thisArgs) order, omitted when
1467    // empty; `bases` lands after extensionMethods -------------------------
1468
1469    #[test]
1470    fn frag_def_appends_extension_methods_after_method_returns_with_the_arity_range_last() {
1471        let json = serde_json::to_string(&frag_def(
1472            &["Go"],
1473            &["Prefix"],
1474            &["_log"],
1475            &[("Go", "Task")],
1476            &[
1477                ("Render", "Widget", 0, 0),
1478                ("Render", "Widget", 2, 3),
1479                ("Trim", "string", 0, -1),
1480            ],
1481        ))
1482        .unwrap();
1483        assert_eq!(
1484            json,
1485            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}]}"#
1486        );
1487    }
1488
1489    #[test]
1490    fn frag_def_this_args_lands_after_arity_max_and_bases_after_extension_methods() {
1491        let mut d = frag_def_with_bases(
1492            &["Go"],
1493            &[],
1494            &[],
1495            &[],
1496            &[("Each", "List", 0, 0)],
1497            &["BaseWidget", "IWidget"],
1498        );
1499        d.extension_methods[0].this_args = Some(vec!["Widget".into()]);
1500        let json = serde_json::to_string(&d).unwrap();
1501        assert_eq!(
1502            json,
1503            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"]}"#
1504        );
1505        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1506        assert_eq!(
1507            reparsed.bases,
1508            vec!["BaseWidget".to_string(), "IWidget".to_string()]
1509        );
1510        assert_eq!(
1511            reparsed.extension_methods[0].this_args,
1512            Some(vec!["Widget".to_string()])
1513        );
1514    }
1515
1516    #[test]
1517    fn frag_def_omits_bases_when_the_type_lists_none() {
1518        let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1519        assert!(
1520            !json.contains("bases"),
1521            "an empty base list must be omitted entirely: {json}"
1522        );
1523        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1524        assert!(
1525            reparsed.bases.is_empty(),
1526            "an absent key reads back as \"lists none\""
1527        );
1528    }
1529
1530    // --- test-coverage stage: testMethods lands AFTER bases, omitted when
1531    // the type declares no tests ------------------------------------------
1532
1533    #[test]
1534    fn frag_def_appends_test_methods_last_after_bases() {
1535        let mut d = frag_def_with_bases(&["Go"], &[], &[], &[], &[], &["BaseWidget"]);
1536        d.test_methods = vec!["TotalsAnEmptyOrder".into(), "TotalsALineItem".into()];
1537        let json = serde_json::to_string(&d).unwrap();
1538        assert_eq!(
1539            json,
1540            r#"{"id":"App.Facts.Widget","name":"Widget","namespace":"App.Facts","kind":"class","line":3,"methods":["Go"],"bases":["BaseWidget"],"testMethods":["TotalsAnEmptyOrder","TotalsALineItem"]}"#
1541        );
1542        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1543        assert_eq!(
1544            reparsed.test_methods,
1545            vec![
1546                "TotalsAnEmptyOrder".to_string(),
1547                "TotalsALineItem".to_string()
1548            ]
1549        );
1550    }
1551
1552    #[test]
1553    fn frag_def_omits_test_methods_when_the_type_declares_no_tests() {
1554        let json = serde_json::to_string(&frag_def(&["Go"], &[], &[], &[], &[])).unwrap();
1555        assert!(
1556            !json.contains("testMethods"),
1557            "pre-stage bytes preserved exactly: {json}"
1558        );
1559        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1560        assert!(
1561            reparsed.test_methods.is_empty(),
1562            "an absent key reads back as \"declares no tests\""
1563        );
1564    }
1565
1566    #[test]
1567    fn frag_def_omits_extension_methods_when_the_type_declares_none() {
1568        let json =
1569            serde_json::to_string(&frag_def(&["Go"], &[], &[], &[("Go", "Task")], &[])).unwrap();
1570        assert!(
1571            !json.contains("extensionMethods"),
1572            "pre-stage-3 bytes preserved exactly: {json}"
1573        );
1574        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1575        assert!(
1576            reparsed.extension_methods.is_empty(),
1577            "an absent key reads back as \"declares none\""
1578        );
1579    }
1580
1581    #[test]
1582    fn frag_def_method_returns_serializes_in_first_declaration_order_not_sorted() {
1583        // A BTreeMap here would emit Alpha before Zebra -- the required order
1584        // is insertion (first-declaration) order.
1585        let json = serde_json::to_string(&frag_def(
1586            &[],
1587            &[],
1588            &[],
1589            &[("Zebra", "Z"), ("Alpha", "A")],
1590            &[],
1591        ))
1592        .unwrap();
1593        assert!(
1594            json.ends_with(r#""methodReturns":{"Zebra":"Z","Alpha":"A"}}"#),
1595            "insertion order, not sorted: {json}"
1596        );
1597        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1598        let keys: Vec<&String> = reparsed.method_returns.iter().map(|(k, _)| k).collect();
1599        assert_eq!(
1600            keys,
1601            vec!["Zebra", "Alpha"],
1602            "and the order survives a round trip"
1603        );
1604    }
1605
1606    // --- propertyTypes lands AFTER testMethods, omitted when the
1607    // type declares no typed property ---------------------------------------
1608
1609    #[test]
1610    fn frag_def_appends_property_types_last_after_test_methods() {
1611        let mut d =
1612            frag_def_with_bases(&["Go"], &["Dial", "Slots"], &[], &[], &[], &["BaseWidget"]);
1613        d.test_methods = vec!["TotalsALineItem".into()];
1614        d.property_types.insert(
1615            "Dial".into(),
1616            FragFact {
1617                type_name: "Gauge".into(),
1618                args: None,
1619            },
1620        );
1621        d.property_types.insert(
1622            "Slots".into(),
1623            FragFact {
1624                type_name: "Toolbox".into(),
1625                args: Some(vec!["Gadget".into()]),
1626            },
1627        );
1628        let json = serde_json::to_string(&d).unwrap();
1629        assert_eq!(
1630            json,
1631            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"]}}}"#
1632        );
1633        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1634        let keys: Vec<&String> = reparsed.property_types.iter().map(|(k, _)| k).collect();
1635        assert_eq!(
1636            keys,
1637            vec!["Dial", "Slots"],
1638            "source order survives a round trip, like methodReturns"
1639        );
1640    }
1641
1642    #[test]
1643    fn frag_def_omits_property_types_when_no_property_carries_a_fact() {
1644        let json = serde_json::to_string(&frag_def(&["Go"], &["Label"], &[], &[], &[])).unwrap();
1645        assert!(
1646            !json.contains("propertyTypes"),
1647            "pre-propertyTypes bytes preserved exactly: {json}"
1648        );
1649        let reparsed: FragDef = serde_json::from_str(&json).unwrap();
1650        assert!(
1651            reparsed.property_types.is_empty(),
1652            "an absent key reads back as \"no property vouches for a type\""
1653        );
1654    }
1655
1656    // --- FragRef's receiverType then argCount, appended
1657    // after generic in that order ------------------------------------------
1658
1659    fn frag_ref(generic: bool, receiver_type: Option<&str>, arg_count: Option<usize>) -> FragRef {
1660        FragRef {
1661            kind: "uses-member".into(),
1662            name: "repo".into(),
1663            qualified: None,
1664            member: Some("Save".into()),
1665            line: 7,
1666            namespace: Some("App.Shape".into()),
1667            type_arg_count: None,
1668            generic,
1669            receiver_type: receiver_type.map(String::from),
1670            arg_count,
1671            receiver_args: None,
1672            outer_types: Vec::new(),
1673            args: None,
1674            receiver_property_owner: None,
1675            receiver_call_owner: None,
1676            receiver_call_member: None,
1677        }
1678    }
1679
1680    #[test]
1681    fn frag_ref_receiver_args_is_appended_last_after_arg_count() {
1682        let r = FragRef {
1683            receiver_args: Some(vec!["FutureState".into(), "*".into()]),
1684            ..frag_ref(false, Some("Binder"), Some(1))
1685        };
1686        let json = serde_json::to_string(&r).unwrap();
1687        assert_eq!(
1688            json,
1689            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState","*"]}"#
1690        );
1691        let reparsed: FragRef = serde_json::from_str(&json).unwrap();
1692        assert_eq!(
1693            reparsed.receiver_args,
1694            Some(vec!["FutureState".to_string(), "*".to_string()])
1695        );
1696    }
1697
1698    #[test]
1699    fn frag_ref_receiver_type_then_arg_count_are_appended_last_after_generic() {
1700        let json = serde_json::to_string(&frag_ref(true, Some("IRepo"), Some(2))).unwrap();
1701        assert_eq!(
1702            json,
1703            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","generic":true,"receiverType":"IRepo","argCount":2}"#
1704        );
1705    }
1706
1707    #[test]
1708    fn frag_ref_arg_count_zero_still_serializes_it() {
1709        // The guard is presence (argCount may be 0), never truthiness -- a
1710        // zero-argument call is a real call and its 0 is what matches an
1711        // arity-0 extension.
1712        let json = serde_json::to_string(&frag_ref(false, Some("IRepo"), Some(0))).unwrap();
1713        assert!(
1714            json.ends_with(r#""receiverType":"IRepo","argCount":0}"#),
1715            "argCount 0 must survive: {json}"
1716        );
1717    }
1718
1719    #[test]
1720    fn frag_ref_without_a_fact_keeps_its_pre_stage_2_bytes() {
1721        let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
1722        assert_eq!(
1723            json,
1724            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape"}"#
1725        );
1726        let reparsed: FragRef = serde_json::from_str(&json).unwrap();
1727        assert_eq!(
1728            reparsed.receiver_type, None,
1729            "an absent key reads back as no fact"
1730        );
1731        assert_eq!(
1732            reparsed.arg_count, None,
1733            "and an absent argCount reads back as \"not a call\""
1734        );
1735        assert_eq!(
1736            reparsed.receiver_args, None,
1737            "and an absent receiverArgs reads back as \"not generic\""
1738        );
1739    }
1740
1741    // --- Def: also_in omission ------------------------------------------
1742
1743    #[test]
1744    fn def_without_also_in_omits_the_key() {
1745        let d = Def {
1746            id: "Ns.Type".into(),
1747            name: "Type".into(),
1748            namespace: "Ns".into(),
1749            kind: "class".into(),
1750            file: "Ns/Type.cs".into(),
1751            line: 3,
1752            methods: vec![],
1753            test_methods: vec![],
1754            also_in: vec![],
1755            end_line: 0,
1756        };
1757        let json = serde_json::to_string(&d).unwrap();
1758        assert!(
1759            !json.contains("also_in"),
1760            "empty also_in must be omitted: {json}"
1761        );
1762    }
1763
1764    #[test]
1765    fn def_end_line_serializes_last_and_defaults_when_absent() {
1766        let mut d = Def {
1767            id: "Ns.Type".into(),
1768            name: "Type".into(),
1769            namespace: "Ns".into(),
1770            kind: "class".into(),
1771            file: "Ns/Type.cs".into(),
1772            line: 3,
1773            methods: vec![],
1774            test_methods: vec![],
1775            also_in: vec![],
1776            end_line: 0,
1777        };
1778        assert!(!serde_json::to_string(&d).unwrap().contains("endLine"));
1779        d.end_line = 9;
1780        assert!(serde_json::to_string(&d)
1781            .unwrap()
1782            .ends_with(",\"endLine\":9}"));
1783    }
1784
1785    #[test]
1786    fn def_with_also_in_includes_it_after_methods() {
1787        let d = Def {
1788            id: "Ns.Type".into(),
1789            name: "Type".into(),
1790            namespace: "Ns".into(),
1791            kind: "class".into(),
1792            file: "Ns/Type.cs".into(),
1793            line: 3,
1794            methods: vec!["M".into()],
1795            test_methods: vec![],
1796            also_in: vec![AlsoIn {
1797                file: "Ns/Type.Extra.cs".into(),
1798                line: 5,
1799            }],
1800            end_line: 0,
1801        };
1802        let json = serde_json::to_string(&d).unwrap();
1803        let methods_pos = json.find("\"methods\"").unwrap();
1804        let also_in_pos = json.find("\"also_in\"").unwrap();
1805        assert!(
1806            methods_pos < also_in_pos,
1807            "also_in must come after methods: {json}"
1808        );
1809    }
1810
1811    // --- test-coverage stage: the def ROW keeps testMethods, between
1812    // methods and also_in ---------------------------------------------------
1813
1814    #[test]
1815    fn def_row_places_test_methods_between_methods_and_also_in() {
1816        let d = Def {
1817            id: "Ns.TypeTests".into(),
1818            name: "TypeTests".into(),
1819            namespace: "Ns".into(),
1820            kind: "class".into(),
1821            file: "Ns/TypeTests.cs".into(),
1822            line: 3,
1823            methods: vec!["M".into()],
1824            test_methods: vec!["Fact1".into()],
1825            also_in: vec![AlsoIn {
1826                file: "Ns/TypeTests.Extra.cs".into(),
1827                line: 5,
1828            }],
1829            end_line: 0,
1830        };
1831        let json = serde_json::to_string(&d).unwrap();
1832        assert_eq!(
1833            json,
1834            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}]}"#
1835        );
1836    }
1837
1838    #[test]
1839    fn def_row_omits_test_methods_when_the_def_declares_no_tests() {
1840        let d = Def {
1841            id: "Ns.Type".into(),
1842            name: "Type".into(),
1843            namespace: "Ns".into(),
1844            kind: "class".into(),
1845            file: "Ns/Type.cs".into(),
1846            line: 3,
1847            methods: vec!["M".into()],
1848            test_methods: vec![],
1849            also_in: vec![],
1850            end_line: 0,
1851        };
1852        let json = serde_json::to_string(&d).unwrap();
1853        assert!(
1854            !json.contains("testMethods"),
1855            "empty testMethods must be omitted: {json}"
1856        );
1857    }
1858
1859    // --- test-coverage stage: stats gains test_def_count, LAST -------------
1860
1861    #[test]
1862    fn stats_appends_test_def_count_last_and_always_writes_it() {
1863        let stats = Stats {
1864            def_count: 2,
1865            file_count: 1,
1866            edges_by_kind: EdgesByKind::default(),
1867            ambiguous_count: 0,
1868            ambiguous_pct: Percent1::zero(),
1869            unresolved_external_count: 0,
1870            heuristic_edge_count: 0,
1871            test_def_count: 0,
1872            ts: None,
1873        };
1874        let json = serde_json::to_string(&stats).unwrap();
1875        assert!(
1876            json.ends_with(r#""heuristic_edge_count":0,"test_def_count":0}"#),
1877            "test_def_count is LAST: {json}"
1878        );
1879    }
1880
1881    // --- Edge: shape-per-kind, tag first ---------------------------------
1882
1883    #[test]
1884    fn imports_edge_has_target_not_to() {
1885        let e = Edge::Imports {
1886            from_file: "F.cs".into(),
1887            from_line: 1,
1888            target: "System".into(),
1889        };
1890        let json = serde_json::to_string(&e).unwrap();
1891        assert_eq!(
1892            json,
1893            r#"{"kind":"imports","from_file":"F.cs","from_line":1,"target":"System"}"#
1894        );
1895    }
1896
1897    #[test]
1898    fn uses_type_edge_shape() {
1899        let e = Edge::UsesType {
1900            from_file: "F.cs".into(),
1901            from_line: 1,
1902            to: "Ns.T".into(),
1903            to_file: "Ns/T.cs".into(),
1904            heuristic: false,
1905        };
1906        let json = serde_json::to_string(&e).unwrap();
1907        assert_eq!(
1908            json,
1909            r#"{"kind":"uses-type","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
1910        );
1911    }
1912
1913    // The serialized shape in one assertion: `heuristic` is the LAST key, it
1914    // appears only when set, and a precise edge of the same kind is
1915    // byte-for-byte what it was before the tag existed.
1916    #[test]
1917    fn heuristic_flag_is_appended_last_and_omitted_when_false() {
1918        let precise = Edge::UsesMember {
1919            from_file: "F.cs".into(),
1920            from_line: 1,
1921            to: "Ns.T".into(),
1922            to_file: "Ns/T.cs".into(),
1923            heuristic: false,
1924        };
1925        assert_eq!(
1926            serde_json::to_string(&precise).unwrap(),
1927            r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs"}"#
1928        );
1929        let guess = Edge::UsesMember {
1930            from_file: "F.cs".into(),
1931            from_line: 1,
1932            to: "Ns.T".into(),
1933            to_file: "Ns/T.cs".into(),
1934            heuristic: true,
1935        };
1936        assert_eq!(
1937            serde_json::to_string(&guess).unwrap(),
1938            r#"{"kind":"uses-member","from_file":"F.cs","from_line":1,"to":"Ns.T","to_file":"Ns/T.cs","heuristic":true}"#
1939        );
1940        // And it reads back: an edge written by either runtime round-trips
1941        // with the flag intact, absent meaning precise.
1942        assert_eq!(
1943            serde_json::from_str::<Edge>(&serde_json::to_string(&guess).unwrap()).unwrap(),
1944            guess
1945        );
1946        assert_eq!(
1947            serde_json::from_str::<Edge>(&serde_json::to_string(&precise).unwrap()).unwrap(),
1948            precise
1949        );
1950    }
1951
1952    #[test]
1953    fn ambiguous_edge_shape() {
1954        let e = Edge::Ambiguous {
1955            origin: "uses-type".into(),
1956            from_file: "F.cs".into(),
1957            from_line: 4,
1958            raw: "Money".into(),
1959            candidates: vec![Candidate {
1960                id: "A.Money".into(),
1961                file: "A/Money.cs".into(),
1962            }],
1963            candidate_count: 2,
1964        };
1965        let json = serde_json::to_string(&e).unwrap();
1966        assert_eq!(
1967            json,
1968            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}"#
1969        );
1970    }
1971
1972    // --- Stats: fixed edges_by_kind order ---------------------------------
1973
1974    #[test]
1975    fn edges_by_kind_field_order_is_fixed_not_alphabetical() {
1976        let s = EdgesByKind {
1977            inherits: 1,
1978            uses_type: 2,
1979            imports: 3,
1980            uses_member: 4,
1981            ctor_di: 5,
1982            ..Default::default()
1983        };
1984        let json = serde_json::to_string(&s).unwrap();
1985        assert_eq!(
1986            json,
1987            r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5}"#
1988        );
1989    }
1990
1991    // --- The cache holds two fragment shapes -------------------
1992
1993    // The untagged discrimination is what keeps one cache file able to hold
1994    // both shapes: read the wrong arm and a whole repo's fragments come back
1995    // silently empty. Both directions are pinned here, on the exact
1996    // serialized bytes.
1997    #[test]
1998    fn any_fragment_round_trips_both_shapes_and_never_reads_one_as_the_other() {
1999        let cs = AnyFragment::Cs(Fragment {
2000            defs: Vec::new(),
2001            usings: Vec::new(),
2002            refs: Vec::new(),
2003            names: Vec::new(),
2004        });
2005        let cs_json = serde_json::to_string(&cs).unwrap();
2006        assert_eq!(cs_json, r#"{"defs":[],"usings":[],"refs":[],"names":[]}"#);
2007        assert!(matches!(
2008            serde_json::from_str::<AnyFragment>(&cs_json).unwrap(),
2009            AnyFragment::Cs(_)
2010        ));
2011
2012        let ts = AnyFragment::Ts(extract::TsFragment {
2013            ts: 1,
2014            defs: vec![extract::TsFragmentDef {
2015                name: "x".into(),
2016                kind: "const".into(),
2017                line: 1,
2018                end_line: 1,
2019            }],
2020            imports: Vec::new(),
2021            reexports: Vec::new(),
2022            refs: Vec::new(),
2023            default: None,
2024        });
2025        let ts_json = serde_json::to_string(&ts).unwrap();
2026        assert_eq!(
2027            ts_json,
2028            r#"{"ts":1,"defs":[{"name":"x","kind":"const","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[]}"#
2029        );
2030        assert!(matches!(
2031            serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2032            AnyFragment::Ts(_)
2033        ));
2034        assert_eq!(
2035            serde_json::from_str::<AnyFragment>(&ts_json).unwrap(),
2036            ts,
2037            "and round-trips to the same value"
2038        );
2039    }
2040
2041    // The four TS counts land AFTER `ctor-di`, in this order, and
2042    // only when the repo carries a TS fragment at all: a C#-only repo's stats
2043    // block keeps the exact bytes the assertion above pins.
2044    #[test]
2045    fn edges_by_kind_appends_the_four_ts_counts_after_ctor_di_when_present() {
2046        let s = EdgesByKind {
2047            inherits: 1,
2048            uses_type: 2,
2049            imports: 3,
2050            uses_member: 4,
2051            ctor_di: 5,
2052            import: Some(6),
2053            call: Some(7),
2054            jsx_use: Some(8),
2055            dispatch: Some(9),
2056        };
2057        let json = serde_json::to_string(&s).unwrap();
2058        assert_eq!(
2059            json,
2060            r#"{"inherits":1,"uses-type":2,"imports":3,"uses-member":4,"ctor-di":5,"import":6,"call":7,"jsx-use":8,"dispatch":9}"#
2061        );
2062    }
2063
2064    // --- atomic writes: crash-sim -----------------------------------
2065
2066    #[test]
2067    fn atomic_write_leaves_target_intact_when_only_a_stray_tmp_exists() {
2068        let dir = temp_dir("atomic-crash");
2069        let target = dir.join("graph.json");
2070        fs::write(&target, b"{\"old\":true}").unwrap();
2071        // Simulate a crashed prior write: a tmp file was created but the
2072        // process died before the rename -- the target must be completely
2073        // unaffected by the stray file's mere presence.
2074        fs::write(dir.join(".graph.json.tmp.99999.7"), b"GARBAGE-PARTIAL").unwrap();
2075        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"old\":true}");
2076    }
2077
2078    #[test]
2079    fn atomic_write_replaces_target_and_leaves_no_tmp_file_behind() {
2080        let dir = temp_dir("atomic-clean");
2081        let target = dir.join("graph.json");
2082        fs::write(&target, b"{\"old\":true}").unwrap();
2083        atomic_write_bytes(&target, b"{\"new\":true}").unwrap();
2084        assert_eq!(fs::read_to_string(&target).unwrap(), "{\"new\":true}");
2085        let leftover: Vec<_> = fs::read_dir(&dir)
2086            .unwrap()
2087            .filter_map(|e| e.ok())
2088            .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
2089            .collect();
2090        assert!(
2091            leftover.is_empty(),
2092            "no tmp file should remain: {leftover:?}"
2093        );
2094    }
2095
2096    #[test]
2097    fn atomic_write_creates_parent_directories() {
2098        let dir = temp_dir("atomic-mkdir");
2099        let target = dir.join("nested").join("deeper").join("graph.json");
2100        atomic_write_bytes(&target, b"{}").unwrap();
2101        assert_eq!(fs::read_to_string(&target).unwrap(), "{}");
2102    }
2103
2104    // --- rebuild_graph: unchanged path never touches the graph -----------
2105
2106    #[test]
2107    fn rebuild_graph_skips_when_unchanged_and_graph_already_exists() {
2108        let dir = temp_dir("rebuild-unchanged");
2109        fs::create_dir_all(graph_dir(&dir)).unwrap();
2110        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();
2111        let outcome = rebuild_graph(&dir, &[], &HashMap::new(), false).unwrap();
2112        assert!(matches!(outcome, RebuildOutcome::NotRebuilt));
2113    }
2114
2115    #[test]
2116    fn rebuild_graph_reuses_a_cached_fragment_at_matching_mtime() {
2117        let dir = temp_dir("rebuild-cache-hit");
2118        let fragment = Fragment {
2119            defs: vec![FragDef {
2120                id: "A".into(),
2121                name: "A".into(),
2122                namespace: "".into(),
2123                kind: "class".into(),
2124                line: 1,
2125                methods: vec![],
2126                properties: vec![],
2127                fields: vec![],
2128                method_returns: OrderedMap::new(),
2129                extension_methods: vec![],
2130                bases: vec![],
2131                type_params: vec![],
2132                base_generic_args: OrderedMap::new(),
2133                test_methods: vec![],
2134                property_types: OrderedMap::new(),
2135                end_line: 1,
2136            }],
2137            usings: vec![],
2138            refs: vec![],
2139            names: vec![],
2140        };
2141        // First build: nothing cached, comes from fresh_fragments.
2142        let mut fresh = HashMap::new();
2143        fresh.insert("A.cs".to_string(), AnyFragment::Cs(fragment.clone()));
2144        let graph_files = vec![GraphFile {
2145            rel: "A.cs".to_string(),
2146            mtime: 111,
2147        }];
2148        let first = rebuild_graph(&dir, &graph_files, &fresh, true).unwrap();
2149        assert!(matches!(first, RebuildOutcome::Rebuilt(_)));
2150
2151        // Second build: same mtime, EMPTY fresh_fragments -- must reuse the
2152        // cache, not silently drop the file from the graph.
2153        let empty: HashMap<String, AnyFragment> = HashMap::new();
2154        let second = rebuild_graph(&dir, &graph_files, &empty, true).unwrap();
2155        match second {
2156            RebuildOutcome::Rebuilt(g) => {
2157                assert_eq!(g.defs.len(), 1, "cached fragment must still be used")
2158            }
2159            RebuildOutcome::NotRebuilt => panic!("changed=true must always rebuild"),
2160        }
2161    }
2162
2163    // --- The v13 cache generation --------------------------
2164
2165    #[test]
2166    fn rebuild_graph_writes_the_v13_caches_and_deletes_every_superseded_generation() {
2167        let dir = temp_dir("rebuild-v13");
2168        fs::create_dir_all(graph_dir(&dir)).unwrap();
2169        // The v9 pair joined this list when fragments gained def
2170        // `typeParams`/`baseGenericArgs` and the `ctor-param` ref kind, so a v9
2171        // fragment read back today carries none and every ctor-DI fact would be
2172        // missing from the graph. The v10 pair joined when a markup
2173        // fragment gained the `x:Class` def and its element/binding refs, so a
2174        // v10 markup fragment read back carries names only and every XAML
2175        // declaration and instantiation would be missing from the graph. The v11
2176        // pair joined when a `.ts/.tsx/.js/.jsx` file gained a reference
2177        // fragment where it previously had none at all, so a v11 cache read back
2178        // would leave every TS/JS rel looking like a file the worker never saw.
2179        // This side records no TS fragment yet, but the cache generation is
2180        // shared -- two writers putting two generations into one git dir would
2181        // have each delete the other's cache on every map. The v12 pair joined
2182        // when defs gained propertyTypes and refs
2183        // gained receiverPropertyOwner and the receiverCallOwner/
2184        // receiverCallMember pair, so a v12 fragment read back carries none and
2185        // every property hop and every var-from-invocation receiver would
2186        // silently stay unresolved.
2187        assert_eq!(SUPERSEDED_CACHE_FILES.len(), 28, "v1..v14 pairs");
2188        for stale in SUPERSEDED_CACHE_FILES {
2189            fs::write(graph_dir(&dir).join(stale), b"{}").unwrap();
2190        }
2191
2192        let fragment = Fragment {
2193            defs: vec![FragDef {
2194                id: "App.A".into(),
2195                name: "A".into(),
2196                namespace: "App".into(),
2197                kind: "class".into(),
2198                line: 1,
2199                methods: vec![],
2200                properties: vec![],
2201                fields: vec![],
2202                method_returns: OrderedMap::new(),
2203                extension_methods: vec![],
2204                bases: vec![],
2205                type_params: vec![],
2206                base_generic_args: OrderedMap::new(),
2207                test_methods: vec![],
2208                property_types: OrderedMap::new(),
2209                end_line: 1,
2210            }],
2211            usings: vec![],
2212            refs: vec![],
2213            names: vec![],
2214        };
2215        let mut fresh = HashMap::new();
2216        fresh.insert("src/A.cs".to_string(), AnyFragment::Cs(fragment));
2217        let graph_files = vec![GraphFile {
2218            rel: "src/A.cs".to_string(),
2219            mtime: 222,
2220        }];
2221        rebuild_graph(&dir, &graph_files, &fresh, true).unwrap();
2222
2223        assert!(
2224            graph_dir(&dir).join("fragments-v15.json").exists(),
2225            "the v15 payload cache is what gets written"
2226        );
2227        assert!(
2228            graph_dir(&dir).join("fragments-index-v15.json").exists(),
2229            "and its mtime-only index alongside it"
2230        );
2231        for stale in SUPERSEDED_CACHE_FILES {
2232            assert!(
2233                !graph_dir(&dir).join(stale).exists(),
2234                "{stale} must be deleted -- rename IS the invalidation"
2235            );
2236        }
2237    }
2238
2239    // --- v8: FragRef's outerTypes, appended last -----------------------------
2240
2241    #[test]
2242    fn frag_ref_outer_types_is_appended_last_after_receiver_args() {
2243        let r = FragRef {
2244            receiver_args: Some(vec!["FutureState".into()]),
2245            outer_types: vec!["Outer".into(), "Inner".into()],
2246            ..frag_ref(false, Some("Binder"), Some(1))
2247        };
2248        let json = serde_json::to_string(&r).unwrap();
2249        assert_eq!(
2250            json,
2251            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","receiverType":"Binder","argCount":1,"receiverArgs":["FutureState"],"outerTypes":["Outer","Inner"]}"#
2252        );
2253        let reparsed: FragRef = serde_json::from_str(&json).unwrap();
2254        assert_eq!(
2255            reparsed.outer_types,
2256            vec!["Outer".to_string(), "Inner".to_string()]
2257        );
2258    }
2259
2260    // --- The two chain facts, appended after outerTypes ----
2261
2262    #[test]
2263    fn frag_ref_property_owner_and_call_pair_are_appended_last_and_omitted_when_absent() {
2264        let hop = FragRef {
2265            qualified: Some("head.Dial".into()),
2266            name: "Dial".into(),
2267            receiver_property_owner: Some("Widget".into()),
2268            ..frag_ref(false, None, Some(0))
2269        };
2270        assert_eq!(
2271            serde_json::to_string(&hop).unwrap(),
2272            r#"{"kind":"uses-member","name":"Dial","qualified":"head.Dial","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverPropertyOwner":"Widget"}"#
2273        );
2274
2275        let from_call = FragRef {
2276            receiver_call_owner: Some("Factory".into()),
2277            receiver_call_member: Some("Make".into()),
2278            ..frag_ref(false, None, Some(0))
2279        };
2280        assert_eq!(
2281            serde_json::to_string(&from_call).unwrap(),
2282            r#"{"kind":"uses-member","name":"repo","member":"Save","line":7,"namespace":"App.Shape","argCount":0,"receiverCallOwner":"Factory","receiverCallMember":"Make"}"#
2283        );
2284
2285        // An older cached fragment carries neither key, and absent must
2286        // read back as "no chain fact" -- which is what leaves such a ref
2287        // exactly where it was before this generation.
2288        let plain = serde_json::to_string(&frag_ref(false, Some("Binder"), Some(1))).unwrap();
2289        assert!(
2290            !plain.contains("receiverPropertyOwner") && !plain.contains("receiverCall"),
2291            "{plain}"
2292        );
2293        let reparsed: FragRef = serde_json::from_str(&plain).unwrap();
2294        assert_eq!(reparsed.receiver_property_owner, None);
2295        assert_eq!(reparsed.receiver_call_owner, None);
2296        assert_eq!(reparsed.receiver_call_member, None);
2297    }
2298
2299    #[test]
2300    fn frag_ref_an_empty_or_absent_outer_types_serializes_to_nothing_and_reads_back_empty() {
2301        let json = serde_json::to_string(&frag_ref(false, None, None)).unwrap();
2302        assert!(
2303            !json.contains("outerTypes"),
2304            "a namespace-level ref keeps its exact pre-v8 bytes: {json}"
2305        );
2306        // A pre-v8 cached fragment has no key at all, and absent must mean the
2307        // same thing as empty -- which is what keeps it off the nested step.
2308        let pre_v8: FragRef = serde_json::from_str(
2309            r#"{"kind":"uses-type","name":"Widget","line":3,"namespace":"App.Core"}"#,
2310        )
2311        .unwrap();
2312        assert!(pre_v8.outer_types.is_empty());
2313    }
2314}