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}