Skip to main content

devscout_rs/
mapcmd.rs

1// Assemble devscout's `map`: walk -> parallel extract -> resolve -> artifacts,
2// incremental. Glues walk.rs, extract.rs, graph.rs (which calls resolve.rs
3// internally) and manifest.rs into a read-plan -> parallel-reparse -> merge ->
4// write cycle, with rayon doing the parse fan-out (parsing is the only part of a
5// cold map slow enough to need it -- resolution itself is pure map lookups, see
6// graph.rs's own doc comment on `rebuild_graph`).
7//
8// ## Scoped-merge behavior
9//
10// A scoped `map <dir>` MERGES into the manifest instead of replacing it whole. A
11// naive build that only iterates the scoped walk when constructing `entries`
12// would silently drop anything outside `dirs` that was in a PRIOR manifest -- a
13// data-destroying footgun (a full-repo manifest built once, then narrowed by one
14// `map src/` call, would permanently lose every entry outside `src/` on the next
15// write). This module partitions the prior manifest by scope membership BEFORE
16// touching anything (`is_within_scope`): in-scope entries go through the normal
17// reuse/reparse/prune cycle; out-of-scope entries are carried through untouched,
18// never counted in preserved/downgraded/added/removed/parsed_ast (this run made
19// no decision about them), and appended to the written manifest after the
20// in-scope entries in their original prior-manifest order. See `map_repo`'s merge
21// step and `MapReport::merged_out_of_scope`.
22//
23// ## Content-hash reuse (`MapOptions::hash_reuse`, the binary's default)
24//
25// Reuse keyed on a file's content hash instead of its mtime, so a fresh
26// git-worktree checkout (new mtimes, byte-identical content) does not force a
27// full reparse of every C# file -- exactly the case the shared,
28// git-common-dir-keyed manifest/graph was built to serve: every linked worktree
29// of one repo already shares one manifest and one graph, but a worktree that
30// never ran `devscout map` before would otherwise repeat the full parse cost on
31// first run because its files carry the checkout's OWN mtimes, not the mtimes
32// recorded by whichever worktree wrote the shared manifest first.
33//
34// Confined entirely to this module: when enabled, the SAME i64 slot used for
35// `mtime` (and this crate's `graph::GraphFile.mtime` / `graph::OrderedMap<i64>`
36// fragments-index) instead carries the first 8 bytes of the file's SHA-256
37// digest, big-endian, as a cache key -- not a timestamp. This is a deliberate,
38// documented repurposing of an existing i64 slot rather than a schema change (no
39// new manifest or graph field): a manifest or fragments-index written in
40// hash-reuse mode has NUMERICALLY MEANINGLESS `mtime` values by wall-clock
41// standards, by design. Collision risk (two distinct files' 8-byte SHA-256
42// prefixes colliding) is astronomically below any concern for a reuse cache key,
43// which this is -- not a content-addressing guarantee. See `hashkey::cache_key`
44// and `cache_key_for`.
45//
46// The BINARY defaults to hash reuse (`MapOptions::from_env`), with
47// `SCOUT_MTIME_REUSE=1` as the escape hatch back to mtime keying.
48// `MapOptions::default()` STAYS `hash_reuse: false` (mtime keying). Two
49// consequences worth knowing at the call site: (1) the first `map` over a
50// manifest written in the other mode cannot match any stored key, so it repays
51// one full reparse and then converges; (2) alternating the two modes against the
52// same repo flips the keys back and forth, costing a full reparse each crossing.
53//
54// ## Parallel extraction determinism
55//
56// rayon fans the parse-and-extract step out with `plan.par_iter().map(..)` over
57// `Vec<PlanEntry>` (an `IndexedParallelIterator`), collected into a
58// `Vec<Option<ParsedFile>>` that is the SAME LENGTH as `plan` -- one slot per
59// plan entry, `None` for anything that isn't reparsed. `collect()` on an indexed
60// rayon iterator reassembles results at their ORIGINAL index regardless of which
61// worker thread finished which file first (this, not any property of thread
62// scheduling, is what makes the collected `Vec`'s order deterministic). But this
63// module goes one step further and does not even lean on that alone: every
64// extraction result is immediately folded into a `HashMap<String, _>`
65// (order-erasing) BEFORE any artifact-affecting step consumes it, and every later
66// step (the manifest `entries` build, `graph::GraphFile` list, `resolve_graph`'s
67// eventual input via `graph::rebuild_graph`) iterates `plan` -- i.e. `files`,
68// i.e. walk order -- not the parallel result `Vec`. So artifact byte-order is
69// driven exclusively by walk order; a hypothetical rayon version that scrambled
70// `collect()` order would still produce byte-identical artifacts here, because
71// nothing downstream reads the parallel `Vec`'s position at all.
72//
73// The `--refresh` flag is a no-op alias: the manifest merge with the prior run
74// always happens regardless of it, and callers strip `--refresh` out of
75// `scope_dirs` before calling `map_repo`. This module does not parse CLI flags at
76// all -- that is cli.rs's job.
77
78use std::collections::{HashMap, HashSet};
79use std::fs;
80use std::io;
81use std::path::Path;
82use std::time::{Instant, UNIX_EPOCH};
83
84use rayon::prelude::*;
85
86use crate::extract;
87use crate::graph::{self, AnyFragment, Fragment, GraphFile};
88use crate::manifest::{self, Value};
89use crate::markup;
90use crate::parse;
91use crate::project;
92use crate::repo;
93use crate::walk;
94
95fn io_err<E: std::fmt::Display>(e: E) -> io::Error {
96    io::Error::other(e.to_string())
97}
98
99// Whether `rel` is a C# source file (`.cs`).
100fn is_csharp(rel: &str) -> bool {
101    rel.ends_with(".cs")
102}
103
104// The files that contribute a graph fragment: C# (defs, refs, member names),
105// markup (`x:Class`/`x:Name`, `.resw` keys -- names only) and TS/JS (imports,
106// exported declarations, call/JSX/dispatch references). The TS/JS arm is
107// `parse::ts_grammar_for`'s own extension gate rather than a second list -- a
108// file this predicate calls a graph source but the reparse dispatch below cannot
109// hand a grammar would be a rel `graph_files` names and `rebuild_graph` can never
110// find a fragment for, which `index_is_stale`'s length check turns into a
111// permanent rebuild.
112fn is_graph_source(rel: &str) -> bool {
113    is_csharp(rel) || markup::is_markup(rel) || parse::ts_grammar_for(rel).is_some()
114}
115
116// LOSSY UTF-8 decoding (invalid byte sequences become U+FFFD), never failing on
117// bad encoding, only on a real I/O failure (missing file, permission). Both this
118// crate's extraction worker and `default_purpose` read this way;
119// `fs::read_to_string` is the wrong primitive here -- it is STRICT UTF-8 and
120// returns `Err` on the first invalid byte, silently dropping any file with even
121// one non-UTF-8 byte (e.g. a stray CP1252 em-dash inside a `//` comment: the
122// lossy read substitutes one U+FFFD and produces a normal signature, while
123// `fs::read_to_string` errors on it outright). Losing a file this way is not just
124// a missed purpose -- `graph::index_is_stale`'s `index.len() != graph_files.len()`
125// check makes the loss STICKY: a C# file the walk finds but the fragments cache
126// can never hold forces `changed == true` on every subsequent run forever,
127// defeating incremental reuse for the whole graph, not just this one file.
128fn read_source_lossy(path: &Path) -> Option<String> {
129    fs::read(path)
130        .ok()
131        .map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
132}
133
134// ---------------------------------------------------------------------------
135// Content-hash cache key (see module header).
136// ---------------------------------------------------------------------------
137
138mod hashkey {
139    use sha2::{Digest, Sha256};
140
141    // First 8 bytes of the file's SHA-256 digest, big-endian, as an i64 -- used
142    // ONLY as an opaque reuse-cache key (see module header); never exposed as a
143    // content hash proper (no hex encoding kept, no full digest retained).
144    pub fn cache_key(bytes: &[u8]) -> i64 {
145        let digest = Sha256::digest(bytes);
146        i64::from_be_bytes(digest[0..8].try_into().expect("sha256 digest is 32 bytes"))
147    }
148
149    #[cfg(test)]
150    mod tests {
151        use super::*;
152
153        #[test]
154        fn same_bytes_produce_the_same_key() {
155            assert_eq!(cache_key(b"hello"), cache_key(b"hello"));
156        }
157
158        #[test]
159        fn different_bytes_produce_different_keys() {
160            assert_ne!(cache_key(b"hello"), cache_key(b"world"));
161        }
162    }
163}
164
165// `MapOptions` lives in its own sibling file, `mapcmd/options.rs` -- this
166// file sits at its exact size-ratchet ceiling with zero headroom (see that
167// file's own header comment).
168mod options;
169pub use options::MapOptions;
170
171/// Counts + timing `devscout map`'s CLI line reports, plus the scoped-merge
172/// bookkeeping. Every field but `total_manifest_entries`/`merged_out_of_scope`
173/// corresponds to a term in the one-line summary; see `summary_line`.
174#[derive(Debug, Clone)]
175pub struct MapReport {
176    /// The scope value.
177    pub scope: Vec<String>,
178    /// Files this run actually walked and (re)decided a manifest entry for.
179    /// This is the IN-SCOPE count only; see `total_manifest_entries` for the
180    /// merged total actually written to disk.
181    pub scoped_file_count: usize,
182    /// `scoped_file_count` + `merged_out_of_scope` -- the manifest entry count
183    /// actually written this run. Equals `scoped_file_count` whenever the prior
184    /// manifest had no out-of-scope entries (always true for an unscoped/full-repo
185    /// run, so this only differs from `scoped_file_count` on a genuinely scoped
186    /// `map <dir>` against a manifest that already covered more).
187    pub total_manifest_entries: usize,
188    /// Prior entries outside this run's `scope`, carried through untouched. A
189    /// naive whole-manifest replacement would drop these; this count is exactly
190    /// what that approach would have destroyed.
191    pub merged_out_of_scope: usize,
192    /// The preserved value.
193    pub preserved: usize,
194    /// The downgraded value.
195    pub downgraded: usize,
196    /// The added value.
197    pub added: usize,
198    /// In-scope prior entries this run's walk no longer produced -- legitimate
199    /// pruning (deleted, renamed, or newly excluded by walk.rs's skip/extension
200    /// rules), unaffected by the scoped merge: an out-of-scope prior entry is
201    /// never a candidate for removal, merged or not.
202    pub removed: usize,
203    /// The parsed ast value.
204    pub parsed_ast: usize,
205    /// The graph rebuilt value.
206    pub graph_rebuilt: bool,
207    /// The graph seconds value.
208    pub graph_seconds: f64,
209    /// The graph def count value.
210    pub graph_def_count: Option<usize>,
211    /// The graph edge count value.
212    pub graph_edge_count: Option<usize>,
213}
214
215impl MapReport {
216    /// The one-line `out` string `devscout map` reports. On a genuinely scoped
217    /// run this describes the SCOPED work (`scoped_file_count`/`scope`), which is
218    /// what this run actually decided; the out-of-scope merge is a silent,
219    /// additional safety property the summary line does not mention.
220    pub fn summary_line(&self) -> String {
221        let graph_note = match (
222            self.graph_rebuilt,
223            self.graph_def_count,
224            self.graph_edge_count,
225        ) {
226            (true, Some(defs), Some(edges)) => {
227                format!(
228                    "graph rebuilt in {:.2}s ({defs} defs, {edges} edges)",
229                    self.graph_seconds
230                )
231            }
232            _ => "graph unchanged".to_string(),
233        };
234        format!(
235            "mapped {} files under {} (preserved {} agent purposes, downgraded {} changed, {} new, {} removed, {} ast signatures); {graph_note}",
236            self.scoped_file_count,
237            self.scope.join(", "),
238            self.preserved,
239            self.downgraded,
240            self.added,
241            self.removed,
242            self.parsed_ast,
243        )
244    }
245}
246
247// ---------------------------------------------------------------------------
248// `carries_best_source` -- an unchanged cache key alone doesn't justify reuse: a
249// C# or TS/JS entry written before its language had an AST path still carries a
250// heuristic purpose, and reusing it would freeze the manifest on the weaker
251// source forever. Reuse holds only when the stored source is already the best
252// this run could produce. The ast-capable predicate is `is_csharp(rel) ||
253// parse::is_ts_js(rel)`: both languages have an AST path, so both expect "ast" as
254// their best source; every other extension (docs, config, ...) expects
255// "heuristic".
256//
257// For TS/JS specifically, "ast-none" ALSO counts as best -- a
258// zero-export/parse-failure file already got the AST worker's final answer
259// (None), and re-tagging it "heuristic" would make this function treat it as
260// not-best forever, resending it to the AST worker on every map run. C# is
261// completely untouched: it never produces "ast-none" (this module's entries-build
262// below only ever writes that source for a TS/JS rel), so its own branch is
263// unchanged.
264// ---------------------------------------------------------------------------
265
266fn carries_best_source(entry: &Value, rel: &str) -> bool {
267    let source = entry
268        .get("source")
269        .and_then(Value::as_str)
270        .unwrap_or("heuristic");
271    if source == "agent" {
272        return true;
273    }
274    if parse::is_ts_js(rel) {
275        return source == "ast" || source == "ast-none";
276    }
277    source == if is_csharp(rel) { "ast" } else { "heuristic" }
278}
279
280// The `mtime` field read for the reuse comparison. Under content-hash reuse this
281// same JSON field instead carries a content-hash cache key (module header) -- the
282// comparison is identical either way, just against a differently-sourced i64.
283fn entry_cache_key(entry: &Value) -> Option<i64> {
284    match entry.get("mtime") {
285        Some(Value::Number(n)) => n.as_i64(),
286        _ => None,
287    }
288}
289
290// A reused entry with `source` defaulted to `"heuristic"`; every OTHER field
291// (including any the manifest schema doesn't know about -- manifest.rs's own
292// header documents the manifest as fully schema-agnostic) passes through
293// untouched. If `entry` already has a `source` key, its VALUE is overwritten in
294// place (position preserved); if `entry` has no `source` key, a new one is
295// appended at the end.
296fn with_ensured_source(entry: &Value) -> Value {
297    let fields = entry.as_object().unwrap_or(&[]);
298    let source = entry
299        .get("source")
300        .and_then(Value::as_str)
301        .unwrap_or("heuristic")
302        .to_string();
303    let mut out: Vec<(String, Value)> = Vec::with_capacity(fields.len() + 1);
304    let mut replaced = false;
305    for (k, v) in fields {
306        if k == "source" {
307            out.push((k.clone(), Value::string(source.clone())));
308            replaced = true;
309        } else {
310            out.push((k.clone(), v.clone()));
311        }
312    }
313    if !replaced {
314        out.push(("source".to_string(), Value::string(source)));
315    }
316    Value::Object(out)
317}
318
319// Does `rel` (a manifest key, `/`-joined repo-relative path) fall under one of
320// `scope`'s directories? Mirrors the SAME containment `list_source_files` uses to
321// decide what to walk (`"."` covers everything; otherwise `dir` itself or
322// anything under `dir/`) -- so a prior entry is "in scope" exactly when THIS
323// run's walk could have produced it, independent of whether it actually did. That
324// independence is the whole point: it is what lets `map_repo` tell "genuinely
325// deleted (in scope, not in this run's files)" apart from "never looked at this
326// run (out of scope entirely)" -- a distinction a plain "is this rel in
327// `entries`" test cannot make, because that is true for neither case.
328fn is_within_scope(rel: &str, scope: &[String]) -> bool {
329    scope
330        .iter()
331        .any(|d| d == "." || rel == d || rel.starts_with(&format!("{d}/")))
332}
333
334// mtime keying (`hash_reuse == false`): the file's modification time in
335// milliseconds, `0` on any stat failure (caught, not propagated -- a file that
336// vanished between the walk and the stat is not fatal to the whole run). The
337// value is computed as a double (`sec * 1000 + nsec / 1e6`) and then floored,
338// deliberately: near-millisecond-boundary nanosecond values can round UP in that
339// float math, and reproducing that exact pipeline keeps the cache key stable
340// against manifests keyed the same way.
341//
342// content-hash reuse (`hash_reuse == true`): the file's content-hash cache key
343// (module header) instead, `0` on any read failure -- same fail-open shape.
344fn cache_key_for(root: &Path, rel: &str, opts: &MapOptions) -> i64 {
345    let abs = root.join(rel);
346    if opts.hash_reuse {
347        match fs::read(&abs) {
348            Ok(bytes) => hashkey::cache_key(&bytes),
349            Err(_) => 0,
350        }
351    } else {
352        match fs::metadata(&abs).and_then(|m| m.modified()) {
353            Ok(t) => match t.duration_since(UNIX_EPOCH) {
354                Ok(d) => {
355                    let ms = (d.as_secs() as f64) * 1000.0 + f64::from(d.subsec_nanos()) / 1e6;
356                    ms.floor() as i64
357                }
358                Err(_) => 0,
359            },
360            Err(_) => 0,
361        }
362    }
363}
364
365struct PlanEntry {
366    rel: String,
367    cache_key: i64,
368    prior_entry: Option<Value>,
369    reusable: bool,
370}
371
372// One reparsed file's result, tagged by which grammar produced it -- the C#
373// branch (purpose + graph fragment, via `extract::extract`) or the TS/JS branch
374// (purpose + TS reference fragment, via `extract::extract_ts_file`), from the
375// same parse. Kept as an enum rather than one widened struct so a TS/JS result
376// can never accidentally reach `graph::fragment_from_extraction` below, and vice
377// versa.
378enum ParsedFile {
379    CSharp(String, extract::Extraction),
380    // The purpose AND the reference fragment, off ONE parse. The purpose is
381    // `None` for a zero-export file (the heuristic text stands); the fragment is
382    // written either way, for the same reason the C# branch writes an empty
383    // fragment -- a rel absent from the cache makes `index_is_stale` see a
384    // permanent mismatch and rebuild on every run.
385    TsJs(String, Option<String>, extract::TsFragment),
386    // A markup or resource file: a fragment carrying names and nothing else, and
387    // no purpose (the heuristic one stands).
388    Markup(String, Fragment),
389}
390
391/// Runs `devscout map`, assembled from walk.rs (walk), extract.rs (extract),
392/// graph.rs (resolve + graph) and manifest.rs. `scope_dirs` is the CLI's
393/// positional dir arguments with `--refresh` already stripped (module header) --
394/// an empty slice means "no explicit scope".
395#[allow(
396    clippy::too_many_lines,
397    reason = "one ordered pipeline stage by stage (walk, extract, resolve, manifest); the stages share state that would only be split apart by an artificial seam"
398)]
399pub fn map_repo(root: &Path, scope_dirs: &[String], opts: MapOptions) -> io::Result<MapReport> {
400    let scope = manifest::scope_for(
401        root,
402        if scope_dirs.is_empty() {
403            None
404        } else {
405            Some(scope_dirs)
406        },
407    )
408    .map_err(io_err)?;
409    let files = walk::list_source_files(root, &scope)?;
410
411    // A missing manifest, or one with no `entries` object, both collapse to "no
412    // prior entries" (no error either way; `find_in_manifest`'s stricter
413    // behavior on a missing `entries` key does NOT apply here -- `map` never
414    // errors on this, only `find` does).
415    let prior_manifest = manifest::read_manifest(root).map_err(io_err)?;
416    let prior_entries: Vec<(String, Value)> = prior_manifest
417        .as_ref()
418        .and_then(|m| m.get("entries"))
419        .and_then(Value::as_object)
420        .map(|s| s.to_vec())
421        .unwrap_or_default();
422    let prior_index: HashMap<String, Value> = prior_entries.iter().cloned().collect();
423
424    // The cheap mtime-only index (`graph::read_fragments_index`) -- read once,
425    // reused for every file's fragment-freshness check below AND for the
426    // `changed` decision further down, so even a fully unchanged run reads it
427    // only once.
428    let graph_index = graph::read_fragments_index(root);
429
430    let plan: Vec<PlanEntry> = files
431        .iter()
432        .map(|rel| {
433            let cache_key = cache_key_for(root, rel, &opts);
434            let fragment_ok =
435                !is_graph_source(rel) || graph_index.get(rel.as_str()) == Some(&cache_key);
436            let prior_entry = prior_index.get(rel.as_str()).cloned();
437            let reusable = prior_entry
438                .as_ref()
439                .map(|e| {
440                    entry_cache_key(e) == Some(cache_key)
441                        && carries_best_source(e, rel)
442                        && fragment_ok
443                })
444                .unwrap_or(false);
445            PlanEntry {
446                rel: rel.clone(),
447                cache_key,
448                prior_entry,
449                reusable,
450            }
451        })
452        .collect();
453
454    // ---- parallel reparse (rayon) --------------------------------------
455    // See the module header's "Parallel extraction determinism" section:
456    // `map` (not `filter`) over `plan.par_iter()` keeps this an
457    // IndexedParallelIterator end to end, and the result is folded into
458    // order-erasing HashMaps immediately below, before anything
459    // artifact-affecting reads it.
460    //
461    // A reparse candidate is C#, markup or TS/JS -- each file is dispatched to
462    // the C# branch (purpose + graph fragment) or the TS/JS branch (purpose + TS
463    // reference fragment) on the SAME parse. Both branches produce a fragment;
464    // which SHAPE it is decides which resolver sees it, and that routing is the
465    // `AnyFragment` tag, never this dispatch.
466    let per_file: Vec<Option<ParsedFile>> = plan
467        .par_iter()
468        .map(|p| {
469            if p.reusable {
470                return None;
471            }
472            if is_csharp(&p.rel) {
473                return read_source_lossy(&root.join(&p.rel))
474                    .map(|src| ParsedFile::CSharp(p.rel.clone(), extract::extract(&src)));
475            }
476            // Markup never reaches a grammar -- the scan is a line walk -- but it
477            // obeys the same reuse decision every other graph source obeys, so it
478            // is dispatched from the same place.
479            if markup::is_markup(&p.rel) {
480                return graph::markup_fragment(root, &p.rel)
481                    .map(|f| ParsedFile::Markup(p.rel.clone(), f));
482            }
483            let grammar = parse::ts_grammar_for(&p.rel)?;
484            // The leading-comment prefix rides inside `extract_ts_file` (not the
485            // pure `extract_ts_purpose`): for every TS/JS file that yields an AST
486            // purpose it re-checks `default_purpose_detailed` and prefixes the
487            // leading comment text when that heuristic match was comment-derived.
488            // The same call returns the reference fragment off that one parse, and
489            // a parse failure drops the file out of BOTH outputs.
490            let src = read_source_lossy(&root.join(&p.rel))?;
491            let ts = extract::extract_ts_file(root, &p.rel, &src, grammar)?;
492            Some(ParsedFile::TsJs(p.rel.clone(), ts.purpose, ts.fragment))
493        })
494        .collect();
495
496    let mut purposes: HashMap<String, String> = HashMap::new();
497    let mut fresh_fragments: HashMap<String, AnyFragment> = HashMap::new();
498    for parsed in per_file.into_iter().flatten() {
499        match parsed {
500            ParsedFile::CSharp(rel, extraction) => {
501                if let Some(p) = &extraction.purpose {
502                    purposes.insert(rel.clone(), p.clone());
503                }
504                // Cached even when the purpose is None (no namespace-level types):
505                // skipping empty fragments here would make `index_is_stale`'s "did
506                // the C# set change" check see a permanent mismatch for genuinely
507                // type-free files, forcing a rebuild on every run even when nothing
508                // changed.
509                fresh_fragments.insert(
510                    rel,
511                    AnyFragment::Cs(graph::fragment_from_extraction(&extraction)),
512                );
513            }
514            ParsedFile::Markup(rel, fragment) => {
515                fresh_fragments.insert(rel, AnyFragment::Cs(fragment));
516            }
517            ParsedFile::TsJs(rel, purpose, fragment) => {
518                if let Some(p) = purpose {
519                    purposes.insert(rel.clone(), p);
520                }
521                // Cached even when the file exports nothing, for the same reason
522                // the C# branch caches an empty fragment: a rel absent from
523                // `graph` makes the fragments-index reuse check see a permanent
524                // mismatch and rebuild on every run.
525                fresh_fragments.insert(rel, AnyFragment::Ts(fragment));
526            }
527        }
528    }
529    // A plan entry with no `per_file` result (unreadable file, reusable, or not a
530    // reparse candidate at all -- e.g. a `.md`/`.json` file) simply has no
531    // `purposes`/`fresh_fragments` entry -- the loop below falls back to
532    // `default_purpose`, and `graph::rebuild_graph` drops it from the graph (a
533    // file with no fragment contributes nothing).
534
535    let mut entries: Vec<(String, Value)> = Vec::with_capacity(plan.len());
536    let mut preserved = 0usize;
537    let mut downgraded = 0usize;
538    let mut added = 0usize;
539
540    for p in &plan {
541        if p.reusable {
542            let merged = with_ensured_source(
543                p.prior_entry
544                    .as_ref()
545                    .expect("reusable implies a prior entry exists"),
546            );
547            if merged.get("source").and_then(Value::as_str) == Some("agent") {
548                preserved += 1;
549            }
550            entries.push((p.rel.clone(), merged));
551            continue;
552        }
553        if let Some(prior) = p.prior_entry.as_ref() {
554            let source = prior
555                .get("source")
556                .and_then(Value::as_str)
557                .unwrap_or("heuristic");
558            if source == "agent" {
559                downgraded += 1;
560            }
561        } else {
562            added += 1;
563        }
564        let value = match purposes.get(&p.rel) {
565            Some(sig) => Value::object(vec![
566                ("purpose", Value::string(sig.clone())),
567                ("mtime", Value::number(p.cache_key)),
568                ("source", Value::string("ast")),
569            ]),
570            None => {
571                // A TS/JS file whose AST pass yielded no purpose (zero-export or
572                // parse failure) is already the best this worker can produce this
573                // run -- "ast-none" carries the same heuristic purpose text as a
574                // plain heuristic result, but (unlike "heuristic")
575                // `carries_best_source` above recognises it as best-obtainable for
576                // TS/JS, so an unchanged mtime is reused on the next map instead of
577                // being resent for reparse. C# is untouched -- it still gets
578                // "heuristic".
579                let source = if parse::is_ts_js(&p.rel) {
580                    "ast-none"
581                } else {
582                    "heuristic"
583                };
584                Value::object(vec![
585                    (
586                        "purpose",
587                        Value::string(walk::default_purpose(root, &p.rel)),
588                    ),
589                    ("mtime", Value::number(p.cache_key)),
590                    ("source", Value::string(source)),
591                ])
592            }
593        };
594        entries.push((p.rel.clone(), value));
595    }
596
597    // Counts BOTH freshly-reparsed AND reused entries whose source is already
598    // "ast", over the (pre-merge, in-scope) `entries`.
599    let parsed_ast = entries
600        .iter()
601        .filter(|(_, v)| v.get("source").and_then(Value::as_str) == Some("ast"))
602        .count();
603
604    // In-scope prior entries this run's walk no longer produced -- see
605    // `removed`'s doc comment on `MapReport`. Scoped to a block so the
606    // borrow of `entries` ends before the out-of-scope merge loop mutates it.
607    let removed = {
608        let entries_index: HashSet<String> = entries.iter().map(|(k, _)| k.clone()).collect();
609        prior_entries
610            .iter()
611            .filter(|entry| is_within_scope(&entry.0, &scope) && !entries_index.contains(&entry.0))
612            .count()
613    };
614
615    let scoped_file_count = entries.len();
616
617    // Scoped merge (module header): every out-of-scope prior entry is carried
618    // through untouched, in its original prior-manifest order, appended after
619    // this run's in-scope entries -- never counted in
620    // preserved/downgraded/added/removed/parsed_ast above.
621    let mut merged_out_of_scope = 0usize;
622    for entry in &prior_entries {
623        if !is_within_scope(&entry.0, &scope) {
624            entries.push(entry.clone());
625            merged_out_of_scope += 1;
626        }
627    }
628    let total_manifest_entries = entries.len();
629    let indexed_file_set: HashSet<String> = entries.iter().map(|(k, _)| k.clone()).collect();
630
631    let built_at_head = manifest::git_head(root);
632    let manifest_value = Value::object(vec![
633        (
634            "built_at_head",
635            built_at_head
636                .clone()
637                .map(Value::string)
638                .unwrap_or(Value::Null),
639        ),
640        (
641            "scoped_dirs",
642            Value::array(scope.iter().cloned().map(Value::string).collect()),
643        ),
644        ("entries", Value::Object(entries)),
645    ]);
646    manifest::write_manifest(root, &manifest_value)?;
647
648    // Index-freshness sidecar, written every successful map run, the same cadence
649    // manifest.json's own built_at_head refreshes on.
650    let dirty = manifest::is_working_tree_dirty(root, &scope);
651    let dirty_indexed_files = manifest::dirty_indexed_files_at(root, &scope, &indexed_file_set);
652    manifest::write_index_state(
653        root,
654        built_at_head,
655        dirty,
656        &dirty_indexed_files,
657        total_manifest_entries as i64,
658    )?;
659
660    // Clear the `refresh-needed` flag if present.
661    let flag = repo::scout_dir(root).join("refresh-needed");
662    if flag.exists() {
663        fs::remove_file(&flag)?;
664    }
665
666    let graph_files: Vec<GraphFile> = plan
667        .iter()
668        .filter(|p| is_graph_source(&p.rel))
669        .map(|p| GraphFile {
670            rel: p.rel.clone(),
671            mtime: p.cache_key,
672        })
673        .collect();
674    // The repo's `.csproj` projects, rediscovered on every run: the scan is
675    // cheap (a handful of files, hand-scanned) next to the extraction above,
676    // and it is the only way to notice a csproj edit at all -- `.csproj` is
677    // not a `SOURCE_EXT`, so no walked file's mtime moves when one changes.
678    // `None` when the repo declares no project, which keeps graph.json's
679    // bytes exactly as they were.
680    let project_model = project::discover(root, &scope)?;
681    // `--no-semantic` (`MapOptions::no_semantic`) is the rollback lever: with
682    // it set, an admitted compiler-facts artifact is never loaded, so this
683    // run's graph is exactly what a build with no artifact admitted would
684    // produce, regardless of what is actually sitting on disk.
685    let semantic_layer = if opts.no_semantic {
686        None
687    } else {
688        crate::semantic::SemanticLayer::load(root)
689    };
690
691    // Graph rebuild triggers, OR-ed (a repo with none of the three answers
692    // false at no cost): any indexed file's cache key changed
693    // (`graph::index_is_stale`); the csproj model changed since the last
694    // rebuild (`project::sidecar_differs`); or an admitted compiler-facts
695    // artifact is present ON DISK at all -- checked independently of
696    // `semantic_layer` so `--no-semantic` still fires this trigger and
697    // rebuilds even though it forces `semantic_layer` to `None` itself.
698    let changed = graph::index_is_stale(&graph_index, &graph_files)
699        || project::sidecar_differs(root, project_model.as_ref())
700        || options::compiler_facts_artifact_present(root);
701
702    let graph_start = Instant::now();
703    let outcome = graph::rebuild_graph(
704        root,
705        &graph_files,
706        &fresh_fragments,
707        changed,
708        project_model.as_ref(),
709        semantic_layer.as_ref(),
710    )?;
711    let graph_seconds = graph_start.elapsed().as_secs_f64();
712
713    let (graph_rebuilt, graph_def_count, graph_edge_count) = match &outcome {
714        graph::RebuildOutcome::Rebuilt(g) => (true, Some(g.defs.len()), Some(g.edges.len())),
715        graph::RebuildOutcome::NotRebuilt => (false, None, None),
716    };
717
718    Ok(MapReport {
719        scope,
720        scoped_file_count,
721        total_manifest_entries,
722        merged_out_of_scope,
723        preserved,
724        downgraded,
725        added,
726        removed,
727        parsed_ast,
728        graph_rebuilt,
729        graph_seconds,
730        graph_def_count,
731        graph_edge_count,
732    })
733}
734
735#[cfg(test)]
736mod tests {
737    use super::*;
738    use std::sync::atomic::{AtomicU64, Ordering};
739
740    static COUNTER: AtomicU64 = AtomicU64::new(0);
741
742    fn temp_dir(label: &str) -> std::path::PathBuf {
743        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
744        let dir = std::env::temp_dir().join(format!(
745            "scout-mapcmd-test-{label}-{}-{n}",
746            std::process::id()
747        ));
748        fs::create_dir_all(&dir).expect("create temp dir");
749        dir
750    }
751
752    fn write_file(path: &Path, contents: &str) {
753        if let Some(parent) = path.parent() {
754            fs::create_dir_all(parent).expect("create parent dirs");
755        }
756        fs::write(path, contents).expect("write file");
757    }
758
759    // -- is_within_scope --------------------------------------------------
760
761    #[test]
762    fn dot_scope_covers_everything() {
763        assert!(is_within_scope("src/a.ts", &[".".to_string()]));
764        assert!(is_within_scope("a.ts", &[".".to_string()]));
765    }
766
767    #[test]
768    fn dir_scope_covers_itself_and_nested_paths_only() {
769        let scope = vec!["src/Foo".to_string()];
770        assert!(is_within_scope("src/Foo/A.cs", &scope));
771        assert!(is_within_scope("src/Foo/Sub/B.cs", &scope));
772        assert!(!is_within_scope("src/Bar/C.cs", &scope));
773        // A same-prefixed SIBLING directory ("src/FooBar") must not match --
774        // this is why the check requires the "/" boundary, not a bare
775        // `starts_with(d)`.
776        assert!(!is_within_scope("src/FooBar/D.cs", &scope));
777    }
778
779    #[test]
780    fn multiple_scope_dirs_union() {
781        let scope = vec!["src".to_string(), "docs".to_string()];
782        assert!(is_within_scope("src/a.ts", &scope));
783        assert!(is_within_scope("docs/readme.md", &scope));
784        assert!(!is_within_scope("config/settings.json", &scope));
785    }
786
787    // -- carries_best_source ------------------------------------------------
788
789    #[test]
790    fn agent_source_always_carries() {
791        let e = Value::object(vec![("source", Value::string("agent"))]);
792        assert!(carries_best_source(&e, "a.cs"));
793        assert!(carries_best_source(&e, "a.ts"));
794    }
795
796    // `carries_best_source`'s "ast-capable" predicate is `is_csharp(rel) ||
797    // parse::is_ts_js(rel)` -- both languages have an AST path, so both expect
798    // "ast" as their best source. `a.json`/`a.md`/etc. (neither predicate) expect
799    // "heuristic".
800    #[test]
801    fn ast_source_carries_for_csharp_and_ts_js() {
802        let e = Value::object(vec![("source", Value::string("ast"))]);
803        assert!(carries_best_source(&e, "a.cs"));
804        assert!(carries_best_source(&e, "a.ts"));
805        assert!(!carries_best_source(&e, "a.json"));
806    }
807
808    #[test]
809    fn heuristic_source_carries_only_for_non_ast_capable_extensions() {
810        let e = Value::object(vec![("source", Value::string("heuristic"))]);
811        assert!(!carries_best_source(&e, "a.cs"));
812        assert!(!carries_best_source(&e, "a.ts"));
813        assert!(carries_best_source(&e, "a.json"));
814    }
815
816    #[test]
817    fn missing_source_defaults_to_heuristic() {
818        let e = Value::object(vec![("purpose", Value::string("x"))]);
819        assert!(carries_best_source(&e, "a.json"));
820        assert!(!carries_best_source(&e, "a.cs"));
821        assert!(!carries_best_source(&e, "a.ts"));
822    }
823
824    // "ast-none" carries the best source for TS/JS (no more forced resend on
825    // every rerun), but is NOT a valid best source for C# -- that path never
826    // produces it.
827    #[test]
828    fn ast_none_source_carries_for_ts_js_but_not_for_csharp_or_plain_files() {
829        let e = Value::object(vec![("source", Value::string("ast-none"))]);
830        assert!(carries_best_source(&e, "a.ts"));
831        assert!(carries_best_source(&e, "a.tsx"));
832        assert!(carries_best_source(&e, "a.js"));
833        assert!(
834            !carries_best_source(&e, "a.cs"),
835            "C# never produces ast-none; an entry claiming it must still be reparsed"
836        );
837        assert!(!carries_best_source(&e, "a.json"));
838    }
839
840    // -- with_ensured_source: source-field position semantics --------------
841
842    #[test]
843    fn ensured_source_is_appended_when_absent() {
844        let e = Value::object(vec![
845            ("purpose", Value::string("p")),
846            ("mtime", Value::number(1)),
847        ]);
848        let out = with_ensured_source(&e);
849        let fields = out.as_object().unwrap();
850        assert_eq!(fields.last().unwrap().0, "source");
851        assert_eq!(out.get("source").and_then(Value::as_str), Some("heuristic"));
852    }
853
854    #[test]
855    fn ensured_source_overwrites_in_place_preserving_position() {
856        let e = Value::object(vec![
857            ("source", Value::string("agent")),
858            ("purpose", Value::string("p")),
859            ("mtime", Value::number(1)),
860        ]);
861        let out = with_ensured_source(&e);
862        let fields = out.as_object().unwrap();
863        // Position preserved (still first), value untouched (already "agent").
864        assert_eq!(fields[0].0, "source");
865        assert_eq!(out.get("source").and_then(Value::as_str), Some("agent"));
866    }
867
868    #[test]
869    fn ensured_source_null_collapses_to_heuristic_in_place() {
870        let e = Value::object(vec![
871            ("purpose", Value::string("p")),
872            ("source", Value::Null),
873        ]);
874        let out = with_ensured_source(&e);
875        let fields = out.as_object().unwrap();
876        assert_eq!(fields[1].0, "source"); // position preserved (was 2nd key)
877        assert_eq!(out.get("source").and_then(Value::as_str), Some("heuristic"));
878    }
879
880    // -- map_repo: full run, reuse, scoped merge -----------------------------
881
882    #[test]
883    fn full_run_builds_entries_with_expected_sources() {
884        let root = temp_dir("full");
885        write_file(
886            &root.join("src/A.cs"),
887            "namespace Fixtures.MapCmd\n{\n    public class A\n    {\n    }\n}\n",
888        );
889        // This composes a purpose (`const x`) via the TS AST path instead of
890        // falling back to the heuristic -- source is "ast".
891        write_file(
892            &root.join("src/note.ts"),
893            "// a helper\nexport const x = 1;\n",
894        );
895        // A file with no exported top-level declaration still degrades to
896        // the heuristic purpose, same as a C# file with no namespace-level
897        // type -- proves the AST-vs-heuristic branch is still live, not
898        // just "every TS/JS file is always ast now".
899        write_file(
900            &root.join("src/internal.ts"),
901            "function helper() { return 1; }\n",
902        );
903
904        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
905        assert_eq!(report.scoped_file_count, 3);
906        assert_eq!(report.total_manifest_entries, 3);
907        assert_eq!(report.merged_out_of_scope, 0);
908        assert_eq!(report.added, 3);
909        assert_eq!(
910            report.parsed_ast, 2,
911            "A.cs and note.ts both compose a purpose; internal.ts has no exported declaration"
912        );
913        assert!(report.graph_rebuilt);
914        // `A.cs`'s class AND `note.ts`'s exported `const x` -- TS/JS files
915        // contribute graph defs. `internal.ts` exports nothing, so it still
916        // contributes a cached fragment and no def.
917        assert_eq!(
918            report.graph_def_count,
919            Some(2),
920            "A.cs's class plus note.ts's exported const"
921        );
922
923        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
924        let entries = manifest.get("entries").unwrap().as_object().unwrap();
925        let a = entries
926            .iter()
927            .find(|(k, _)| k == "src/A.cs")
928            .unwrap()
929            .1
930            .clone();
931        assert_eq!(a.get("source").and_then(Value::as_str), Some("ast"));
932        let note = entries
933            .iter()
934            .find(|(k, _)| k == "src/note.ts")
935            .unwrap()
936            .1
937            .clone();
938        assert_eq!(note.get("source").and_then(Value::as_str), Some("ast"));
939        // note.ts's first line ("// a helper") IS comment-derived under
940        // `default_purpose_detailed`, so its AST purpose carries the
941        // leading-comment hybrid prefix.
942        assert_eq!(
943            note.get("purpose").and_then(Value::as_str),
944            Some("a helper — const x")
945        );
946        let internal = entries
947            .iter()
948            .find(|(k, _)| k == "src/internal.ts")
949            .unwrap()
950            .1
951            .clone();
952        // A zero-export TS/JS file is tagged "ast-none", not "heuristic" --
953        // carries the same heuristic purpose text, but is recognised as
954        // best-obtainable by `carries_best_source` so an unchanged rerun reuses
955        // it instead of resending it.
956        assert_eq!(
957            internal.get("source").and_then(Value::as_str),
958            Some("ast-none")
959        );
960        assert_eq!(
961            internal.get("purpose").and_then(Value::as_str),
962            Some("function helper() { return 1; }")
963        );
964
965        fs::remove_dir_all(&root).ok();
966    }
967
968    #[test]
969    fn ts_purpose_upgrades_from_heuristic_and_then_reuses_unchanged() {
970        // TS reuse semantics: a TS entry with source "heuristic" upgrades to
971        // "ast" on the first map with the AST path; an immediate unchanged rerun
972        // then reuses it (no reparse, counted in `parsed_ast` since it already
973        // carries "ast").
974        let root = temp_dir("ts-reuse");
975        write_file(&root.join("src/bar.ts"), "export function bar() {}\n");
976
977        let first = map_repo(&root, &[], MapOptions::default()).unwrap();
978        assert_eq!(first.added, 1);
979        assert_eq!(first.parsed_ast, 1);
980        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
981        let entries = manifest.get("entries").unwrap().as_object().unwrap();
982        let bar = entries
983            .iter()
984            .find(|(k, _)| k == "src/bar.ts")
985            .unwrap()
986            .1
987            .clone();
988        assert_eq!(bar.get("source").and_then(Value::as_str), Some("ast"));
989        assert_eq!(
990            bar.get("purpose").and_then(Value::as_str),
991            Some("function bar")
992        );
993
994        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
995        assert_eq!(second.added, 0);
996        assert_eq!(second.downgraded, 0);
997        assert_eq!(
998            second.parsed_ast, 1,
999            "reused ast-source TS entry still counts as ast on an unchanged rerun"
1000        );
1001
1002        fs::remove_dir_all(&root).ok();
1003    }
1004
1005    #[test]
1006    fn unchanged_second_run_reuses_everything() {
1007        let root = temp_dir("reuse");
1008        write_file(
1009            &root.join("src/A.cs"),
1010            "namespace Fixtures.MapCmd\n{\n    public class A\n    {\n    }\n}\n",
1011        );
1012
1013        let first = map_repo(&root, &[], MapOptions::default()).unwrap();
1014        assert_eq!(first.added, 1);
1015
1016        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1017        assert_eq!(second.added, 0);
1018        assert_eq!(second.downgraded, 0);
1019        assert_eq!(second.removed, 0);
1020        assert_eq!(second.parsed_ast, 1, "reused entry keeps its ast source");
1021        assert!(
1022            !second.graph_rebuilt,
1023            "unchanged C# set must not rebuild the graph"
1024        );
1025
1026        fs::remove_dir_all(&root).ok();
1027    }
1028
1029    #[test]
1030    fn deleted_file_is_pruned_and_counted_removed() {
1031        let root = temp_dir("prune");
1032        write_file(
1033            &root.join("src/A.cs"),
1034            "namespace Fixtures.MapCmd { public class A {} }\n",
1035        );
1036        write_file(
1037            &root.join("src/B.cs"),
1038            "namespace Fixtures.MapCmd { public class B {} }\n",
1039        );
1040        map_repo(&root, &[], MapOptions::default()).unwrap();
1041
1042        fs::remove_file(root.join("src/B.cs")).unwrap();
1043        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1044        assert_eq!(report.removed, 1);
1045        assert_eq!(report.total_manifest_entries, 1);
1046
1047        fs::remove_dir_all(&root).ok();
1048    }
1049
1050    // A `.csproj` is not a `SOURCE_EXT`: adding or editing one moves no
1051    // walked file's mtime, so `index_is_stale` alone can never see it and the
1052    // graph would keep a stale project model forever. The sidecar comparison
1053    // is what closes that hole, and this walks the whole cycle -- no project
1054    // at all, a project added, nothing touched, a reference dropped.
1055    #[test]
1056    fn a_csproj_edit_rebuilds_the_graph_and_lands_its_units_while_no_csproj_changes_nothing() {
1057        let root = temp_dir("csproj-units");
1058        write_file(
1059            &root.join("A/A.cs"),
1060            "namespace Fixtures.MapCmd.A { public class A {} }\n",
1061        );
1062        write_file(
1063            &root.join("B/B.cs"),
1064            "namespace Fixtures.MapCmd.B { public class B {} }\n",
1065        );
1066
1067        let graph_json = graph::graph_json_path(&root);
1068        let sidecar = graph::project_units_path(&root);
1069
1070        // 1. No `.csproj` anywhere: the graph must not grow a `units` key and
1071        //    the sidecar must not be written at all -- an empty `[]` on disk
1072        //    would be indistinguishable from "a model that has no projects".
1073        let first = map_repo(&root, &[], MapOptions::default()).unwrap();
1074        assert!(first.graph_rebuilt);
1075        let json = fs::read_to_string(&graph_json).unwrap();
1076        assert!(
1077            !json.contains(r#""units""#),
1078            "a repo with no csproj must serialize exactly as it did before the project model: {json}"
1079        );
1080        assert!(!sidecar.exists(), "no model, no sidecar");
1081
1082        // 2. A project added, referencing a second one. Not one indexed file
1083        //    moved, so `index_is_stale` is false here and the rebuild is the
1084        //    sidecar's doing alone.
1085        write_file(
1086            &root.join("A/A.csproj"),
1087            "<Project Sdk=\"Microsoft.NET.Sdk\">\n  <ItemGroup>\n    <ProjectReference Include=\"..\\B\\B.csproj\" />\n  </ItemGroup>\n</Project>\n",
1088        );
1089        write_file(
1090            &root.join("B/B.csproj"),
1091            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>\n",
1092        );
1093
1094        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1095        assert!(
1096            second.graph_rebuilt,
1097            "a csproj appearing must rebuild the graph even though no source file changed"
1098        );
1099        let json = fs::read_to_string(&graph_json).unwrap();
1100        assert!(
1101            json.ends_with(
1102                r#","units":[{"id":"A/A.csproj","name":"A","refs":["B/B.csproj"]},{"id":"B/B.csproj","name":"B"}]}"#
1103            ),
1104            "units are appended LAST, sorted by id, with the ref list normalized to repo-relative ids: {json}"
1105        );
1106        assert!(
1107            sidecar.exists(),
1108            "the model was written, so was the sidecar"
1109        );
1110
1111        // The persisted rows really do rebuild a usable model, closure and
1112        // all -- that is the whole point of storing them.
1113        let graph_value = graph::read_graph(&root).unwrap();
1114        let model =
1115            project::ProjectModel::from_units(project::units_from_graph(&graph_value.units));
1116        assert!(model.reachable(0, 1), "A references B");
1117        assert!(!model.reachable(1, 0), "B does not reference A");
1118        assert_eq!(model.unit_of_file("A/A.cs"), Some(0));
1119        assert_eq!(model.unit_of_file("B/B.cs"), Some(1));
1120
1121        // 3. Nothing touched: the sidecar matches, so no rebuild.
1122        let third = map_repo(&root, &[], MapOptions::default()).unwrap();
1123        assert!(
1124            !third.graph_rebuilt,
1125            "an unchanged csproj must not force a rebuild on every run"
1126        );
1127
1128        // 4. The reference dropped. Same file count, same mtimes on every
1129        //    indexed file -- only the csproj's CONTENT changed.
1130        write_file(
1131            &root.join("A/A.csproj"),
1132            "<Project Sdk=\"Microsoft.NET.Sdk\">\n</Project>\n",
1133        );
1134        let fourth = map_repo(&root, &[], MapOptions::default()).unwrap();
1135        assert!(
1136            fourth.graph_rebuilt,
1137            "editing a csproj's references must rebuild the graph"
1138        );
1139        let json = fs::read_to_string(&graph_json).unwrap();
1140        assert!(
1141            json.ends_with(
1142                r#","units":[{"id":"A/A.csproj","name":"A"},{"id":"B/B.csproj","name":"B"}]}"#
1143            ),
1144            "the dropped reference is gone and `refs` is omitted rather than emitted empty: {json}"
1145        );
1146
1147        fs::remove_dir_all(&root).ok();
1148    }
1149
1150    #[test]
1151    fn scoped_run_merges_out_of_scope_entries() {
1152        let root = temp_dir("scoped-merge");
1153        write_file(
1154            &root.join("src/Foo/A.cs"),
1155            "namespace Fixtures.MapCmd.Foo { public class A {} }\n",
1156        );
1157        write_file(
1158            &root.join("src/Bar/C.ts"),
1159            "// bar helper\nexport const bar = 1;\n",
1160        );
1161        write_file(&root.join("docs/readme.md"), "# Fixtures Map Demo\n");
1162
1163        let full = map_repo(&root, &[], MapOptions::default()).unwrap();
1164        assert_eq!(full.total_manifest_entries, 3);
1165
1166        let scoped = map_repo(&root, &["src/Foo".to_string()], MapOptions::default()).unwrap();
1167        assert_eq!(scoped.scope, vec!["src/Foo".to_string()]);
1168        assert_eq!(
1169            scoped.scoped_file_count, 1,
1170            "only src/Foo/A.cs is walked this run"
1171        );
1172        assert_eq!(
1173            scoped.merged_out_of_scope, 2,
1174            "src/Bar/C.ts and docs/readme.md preserved, not destroyed"
1175        );
1176        assert_eq!(scoped.total_manifest_entries, 3);
1177        assert_eq!(scoped.removed, 0, "nothing in scope was actually deleted");
1178
1179        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
1180        let entries = manifest.get("entries").unwrap().as_object().unwrap();
1181        assert_eq!(entries.len(), 3);
1182        assert!(
1183            entries.iter().any(|(k, _)| k == "src/Bar/C.ts"),
1184            "out-of-scope entry must survive a scoped map"
1185        );
1186        assert!(
1187            entries.iter().any(|(k, _)| k == "docs/readme.md"),
1188            "out-of-scope entry must survive a scoped map"
1189        );
1190
1191        fs::remove_dir_all(&root).ok();
1192    }
1193
1194    #[test]
1195    fn scoped_run_still_prunes_in_scope_deletions() {
1196        let root = temp_dir("scoped-prune");
1197        write_file(
1198            &root.join("src/Foo/A.cs"),
1199            "namespace Fixtures.MapCmd.Foo { public class A {} }\n",
1200        );
1201        write_file(
1202            &root.join("src/Foo/B.cs"),
1203            "namespace Fixtures.MapCmd.Foo { public class B {} }\n",
1204        );
1205        write_file(
1206            &root.join("src/Bar/C.ts"),
1207            "// bar\nexport const bar = 1;\n",
1208        );
1209        map_repo(&root, &[], MapOptions::default()).unwrap();
1210
1211        fs::remove_file(root.join("src/Foo/B.cs")).unwrap();
1212        let scoped = map_repo(&root, &["src/Foo".to_string()], MapOptions::default()).unwrap();
1213        // In-scope deletion IS pruned (not merge-preserved) -- the scoped merge
1214        // only protects entries this run never looked at, not entries it looked
1215        // at and found gone.
1216        assert_eq!(scoped.removed, 1);
1217        assert_eq!(
1218            scoped.merged_out_of_scope, 1,
1219            "src/Bar/C.ts still preserved"
1220        );
1221        assert_eq!(scoped.total_manifest_entries, 2);
1222
1223        fs::remove_dir_all(&root).ok();
1224    }
1225
1226    #[test]
1227    fn invalid_utf8_byte_is_read_lossily_not_dropped() {
1228        // Regression: a stray CP1252 byte (0x97, em-dash) inside a `//` comment
1229        // must not cause the whole file to be silently excluded from extraction
1230        // (see `read_source_lossy`'s doc comment).
1231        let root = temp_dir("lossy-utf8");
1232        let path = root.join("src/Weird.cs");
1233        fs::create_dir_all(path.parent().unwrap()).unwrap();
1234        let mut bytes = b"namespace Fixtures.MapCmd\n{\n    // note \x97 em dash\n    public class Weird\n    {\n    }\n}\n".to_vec();
1235        assert!(
1236            std::str::from_utf8(&bytes).is_err(),
1237            "fixture must actually contain an invalid UTF-8 byte"
1238        );
1239        fs::write(&path, &bytes).unwrap();
1240        bytes.clear(); // silence "unused" if the assert above is ever removed
1241
1242        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1243        assert_eq!(report.added, 1);
1244        assert_eq!(
1245            report.parsed_ast, 1,
1246            "the file must still be AST-extracted despite the bad byte"
1247        );
1248        assert!(report.graph_rebuilt);
1249        assert_eq!(report.graph_def_count, Some(1));
1250
1251        // The regression's OTHER symptom: index_is_stale's length check
1252        // must not be permanently tripped by a dropped file -- an immediate
1253        // unchanged rerun must skip the graph rebuild.
1254        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1255        assert!(
1256            !second.graph_rebuilt,
1257            "an unchanged rerun must reuse, not be permanently stale"
1258        );
1259
1260        fs::remove_dir_all(&root).ok();
1261    }
1262
1263    // -- content-hash reuse --------------------------------------------
1264
1265    #[test]
1266    fn hash_reuse_survives_an_mtime_only_touch() {
1267        let root = temp_dir("hash-reuse");
1268        let opts = MapOptions {
1269            hash_reuse: true,
1270            ..Default::default()
1271        };
1272        let content = "namespace Fixtures.MapCmd { public class A {} }\n";
1273        write_file(&root.join("src/A.cs"), content);
1274        let first = map_repo(&root, &[], opts).unwrap();
1275        assert_eq!(first.added, 1);
1276        assert!(first.graph_rebuilt);
1277
1278        // Rewrite with IDENTICAL content -- a real mtime bump (a fresh
1279        // worktree checkout's own failure mode), but the content hash is
1280        // unchanged.
1281        std::thread::sleep(std::time::Duration::from_millis(10));
1282        write_file(&root.join("src/A.cs"), content);
1283
1284        let second = map_repo(&root, &[], opts).unwrap();
1285        assert_eq!(second.added, 0);
1286        assert_eq!(second.downgraded, 0);
1287        assert!(
1288            !second.graph_rebuilt,
1289            "same content hash must reuse, not reparse"
1290        );
1291
1292        fs::remove_dir_all(&root).ok();
1293    }
1294
1295    #[test]
1296    fn hash_reuse_reparses_on_real_content_change() {
1297        let root = temp_dir("hash-reparse");
1298        let opts = MapOptions {
1299            hash_reuse: true,
1300            ..Default::default()
1301        };
1302        write_file(
1303            &root.join("src/A.cs"),
1304            "namespace Fixtures.MapCmd { public class A {} }\n",
1305        );
1306        map_repo(&root, &[], opts).unwrap();
1307
1308        write_file(
1309            &root.join("src/A.cs"),
1310            "namespace Fixtures.MapCmd { public class A { public void M() {} } }\n",
1311        );
1312        let second = map_repo(&root, &[], opts).unwrap();
1313        assert!(second.graph_rebuilt, "changed content hash must reparse");
1314
1315        fs::remove_dir_all(&root).ok();
1316    }
1317
1318    #[test]
1319    fn mtime_default_mode_ignores_content_and_keys_on_mtime_only() {
1320        // Sanity check that the two modes are actually distinct: rewriting
1321        // identical content under DEFAULT (mtime) mode still forces a
1322        // reparse if the mtime moved, unlike hash_reuse above.
1323        let root = temp_dir("mtime-default");
1324        let content = "namespace Fixtures.MapCmd { public class A {} }\n";
1325        write_file(&root.join("src/A.cs"), content);
1326        map_repo(&root, &[], MapOptions::default()).unwrap();
1327
1328        std::thread::sleep(std::time::Duration::from_millis(10));
1329        write_file(&root.join("src/A.cs"), content);
1330        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1331        // mtime moved -> not reusable -> reparsed -> graph still has the
1332        // same single def, but the fragments-index mtime must have moved,
1333        // which rebuild_graph treats as "changed".
1334        assert!(
1335            second.graph_rebuilt,
1336            "default mode has no content-awareness -- an mtime bump alone reparses"
1337        );
1338
1339        fs::remove_dir_all(&root).ok();
1340    }
1341
1342    // -- ast-none manifest + reuse ------------------------------------------
1343
1344    #[test]
1345    fn zero_export_ts_file_is_tagged_ast_none_not_heuristic() {
1346        let root = temp_dir("ast-none-tag");
1347        write_file(
1348            &root.join("src/internal.ts"),
1349            "function helper() { return 1; }\n",
1350        );
1351
1352        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1353        assert_eq!(report.added, 1);
1354        assert_eq!(
1355            report.parsed_ast, 0,
1356            "ast-none never counts toward parsed_ast"
1357        );
1358
1359        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
1360        let entries = manifest.get("entries").unwrap().as_object().unwrap();
1361        let entry = entries
1362            .iter()
1363            .find(|(k, _)| k == "src/internal.ts")
1364            .unwrap()
1365            .1
1366            .clone();
1367        assert_eq!(
1368            entry.get("source").and_then(Value::as_str),
1369            Some("ast-none")
1370        );
1371
1372        fs::remove_dir_all(&root).ok();
1373    }
1374
1375    #[test]
1376    fn unchanged_rerun_does_not_recompute_ast_none_purposes() {
1377        // The resend trap: plant a sentinel purpose a fresh recompute could never
1378        // produce, at the SAME cache key/source ("ast-none"). If the file is
1379        // resent for recompute on the next map (ast-none miscategorised as
1380        // not-best), the real heuristic text overwrites the sentinel; correct
1381        // reuse leaves it intact. A byte-identical manifest.json alone would NOT
1382        // prove this -- a resent file recomputes to the same deterministic text --
1383        // so this is the direct evidence for "rerun resends 0 TS files".
1384        let root = temp_dir("ast-none-resend-trap");
1385        write_file(
1386            &root.join("src/internal.ts"),
1387            "function helper() { return 1; }\n",
1388        );
1389        map_repo(&root, &[], MapOptions::default()).unwrap();
1390
1391        let manifest = manifest::read_manifest(&root).unwrap().unwrap();
1392        let built_at_head = manifest
1393            .get("built_at_head")
1394            .cloned()
1395            .unwrap_or(Value::Null);
1396        let scoped_dirs = manifest
1397            .get("scoped_dirs")
1398            .cloned()
1399            .unwrap_or_else(|| Value::array(vec![]));
1400        let mut entries: Vec<(String, Value)> = manifest
1401            .get("entries")
1402            .and_then(Value::as_object)
1403            .unwrap()
1404            .to_vec();
1405        for (k, v) in entries.iter_mut() {
1406            if k == "src/internal.ts" {
1407                assert_eq!(
1408                    v.get("source").and_then(Value::as_str),
1409                    Some("ast-none"),
1410                    "precondition"
1411                );
1412                let cache_key = v.get("mtime").cloned().expect("mtime present");
1413                *v = Value::object(vec![
1414                    ("purpose", Value::string("__SENTINEL_DO_NOT_RECOMPUTE__")),
1415                    ("mtime", cache_key),
1416                    ("source", Value::string("ast-none")),
1417                ]);
1418            }
1419        }
1420        manifest::write_manifest(
1421            &root,
1422            &Value::object(vec![
1423                ("built_at_head", built_at_head),
1424                ("scoped_dirs", scoped_dirs),
1425                ("entries", Value::Object(entries)),
1426            ]),
1427        )
1428        .unwrap();
1429
1430        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1431        assert_eq!(second.added, 0);
1432        assert_eq!(second.downgraded, 0);
1433
1434        let after = manifest::read_manifest(&root).unwrap().unwrap();
1435        let after_entries = after.get("entries").and_then(Value::as_object).unwrap();
1436        let reused = after_entries
1437            .iter()
1438            .find(|(k, _)| k == "src/internal.ts")
1439            .unwrap()
1440            .1
1441            .clone();
1442        assert_eq!(
1443            reused.get("purpose").and_then(Value::as_str),
1444            Some("__SENTINEL_DO_NOT_RECOMPUTE__"),
1445            "unchanged rerun must not resend this file for recompute"
1446        );
1447        assert_eq!(
1448            reused.get("source").and_then(Value::as_str),
1449            Some("ast-none")
1450        );
1451
1452        fs::remove_dir_all(&root).ok();
1453    }
1454
1455    // -- non-git root (no manifest yet) ------------------------------------
1456
1457    #[test]
1458    fn empty_dir_maps_to_zero_entries_without_erroring() {
1459        let root = temp_dir("empty");
1460        fs::create_dir_all(&root).unwrap();
1461        let report = map_repo(&root, &[], MapOptions::default()).unwrap();
1462        assert_eq!(report.scoped_file_count, 0);
1463        assert_eq!(report.total_manifest_entries, 0);
1464        // First run always writes an initial (empty) graph.json -- the graph
1465        // rebuild only skips when `!changed` AND a graph.json already exists; on a
1466        // fresh repo neither is true yet.
1467        assert!(report.graph_rebuilt);
1468        assert_eq!(report.graph_def_count, Some(0));
1469
1470        // A SECOND run against the same (still empty) root has an existing,
1471        // unchanged (0 == 0 C# files) graph.json -- this is the case that
1472        // actually exercises the "skip" path.
1473        let second = map_repo(&root, &[], MapOptions::default()).unwrap();
1474        assert!(!second.graph_rebuilt);
1475
1476        fs::remove_dir_all(&root).ok();
1477    }
1478}