Skip to main content

devscout_rs/
tsgraph.rs

1// The TS/TSX resolver: turns many files' reference fragments (see
2// `extract::extract_ts_fragment`) into resolved edges.
3//
4// The split mirrors the C# side exactly: the extractor records what one file
5// SAYS, this module decides what those words point at once every file is known.
6// What it does NOT do is deliberately bounded -- no node_modules-internal
7// resolution, no `.d.ts` / type-level analysis, and no resolution through a
8// chained method call, which is the same open gap the C# side still carries.
9//
10// Everything here is pure over the fragment map except one read: a tsconfig
11// chain, which is where a bare specifier's meaning is written down. A
12// monorepo writes that meaning down more than once -- an app folder owns its
13// own aliases below the root's -- so the read is scoped PER DIRECTORY: a file
14// resolves through the nearest ancestor `tsconfig.json` above it, falling
15// back to the repo root's own chain when no ancestor owns one, or when the
16// one that does declares neither `paths` nor `baseUrl` of its own (see
17// `read_ts_alias_scopes`).
18
19use std::collections::{HashMap, HashSet};
20use std::fs;
21use std::path::Path;
22
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25
26use crate::extract::TsFragment;
27use crate::graph::{Def, Edge, OrderedMap};
28
29const DEF_SEPARATOR: char = '#';
30// Extension probe order for a specifier that names no extension. TypeScript
31// sources lead so a repo carrying both `x.ts` and a built `x.js` resolves to
32// the source, which is the file an agent asking "who calls this" wants.
33const PROBE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
34// ESM spells a relative import of a TypeScript module with the extension of
35// what it COMPILES to. The mapping is the TypeScript compiler's own, applied
36// only after the literal path has already failed to match a mapped file.
37const OUTPUT_EXTENSION_SOURCES: &[(&str, &[&str])] = &[
38    (".js", &[".ts", ".tsx"]),
39    (".jsx", &[".tsx"]),
40    (".mjs", &[".mts", ".ts"]),
41    (".cjs", &[".cts", ".ts"]),
42];
43const TSCONFIG_CHAIN_LIMIT: usize = 4;
44// Where a repo writes down what its bare specifiers mean. `tsconfig.json` is
45// the default entry point; a workspace whose root config is generated per-app
46// keeps the shared aliases in `tsconfig.base.json` instead, with nothing at
47// the default name at all -- so both are tried, and the one that actually
48// declares `paths` wins rather than whichever happens to exist.
49const TSCONFIG_ROOTS: &[&str] = &["tsconfig.json", "tsconfig.base.json"];
50// A directory below the root can own its OWN `tsconfig.json`, scoping the
51// files beneath it to that config's chain instead of the root's -- the shape
52// a monorepo's per-app aliases actually take. Only the default name is
53// recognised as a nested owner (never `tsconfig.base.json`): a nested config
54// is written by the app itself and names itself the ordinary way, unlike the
55// root, which sometimes has nothing at the default name at all.
56// Feature folders nest their index files two to four barrels deep, so one hop
57// of following was not enough: a name a file re-exports is looked up in the
58// module it re-exports FROM, which may itself re-export it again, up to this
59// many times before the walk gives up. The bound is what keeps a cyclic
60// `export *` pair (`a` re-exporting `b`, `b` re-exporting `a`) from recursing
61// forever even before the visited-pair guard in `lookup_export` catches the
62// cycle outright.
63const BARREL_HOPS: usize = 8;
64
65/// Builds a TypeScript definition identifier from a file and symbol name.
66pub fn ts_def_id(file: &str, name: &str) -> String {
67    format!("{file}{DEF_SEPARATOR}{name}")
68}
69
70fn dir_of(rel: &str) -> &str {
71    match rel.rfind('/') {
72        None => "",
73        Some(i) => &rel[..i],
74    }
75}
76
77// `/`-joined, repo-relative, no filesystem access: a `..` that would climb
78// above the repo root returns `None` rather than an absolute escape.
79fn join_rel(base: &str, rest: &str) -> Option<String> {
80    let mut parts: Vec<&str> = Vec::new();
81    let joined = format!("{base}/{rest}");
82    for seg in joined.split('/') {
83        if seg.is_empty() || seg == "." {
84            continue;
85        }
86        if seg == ".." {
87            if parts.is_empty() {
88                return None;
89            }
90            parts.pop();
91            continue;
92        }
93        parts.push(seg);
94    }
95    Some(parts.join("/"))
96}
97
98fn extension_of(rel: &str) -> &str {
99    let slash = rel.rfind('/').map(|i| i as i64).unwrap_or(-1);
100    let dot = rel.rfind('.').map(|i| i as i64).unwrap_or(-1);
101    if dot > slash {
102        &rel[dot as usize..]
103    } else {
104        ""
105    }
106}
107
108// A resolved specifier is a file THIS INDEX MAPPED, never merely a path that
109// exists: an edge to a file the graph does not carry would point a caller at a
110// row that is not there. Probe order is fixed so the choice among several
111// candidate spellings is deterministic.
112fn probe_file(base: Option<&str>, file_set: &HashSet<&str>) -> Option<String> {
113    let base = base?;
114    if file_set.contains(base) {
115        return Some(base.to_string());
116    }
117    let ext = extension_of(base);
118    if let Some((_, sources)) = OUTPUT_EXTENSION_SOURCES.iter().find(|(e, _)| *e == ext) {
119        let stem = &base[..base.len() - ext.len()];
120        for s in *sources {
121            let candidate = format!("{stem}{s}");
122            if file_set.contains(candidate.as_str()) {
123                return Some(candidate);
124            }
125        }
126    }
127    for e in PROBE_EXTENSIONS {
128        let candidate = format!("{base}{e}");
129        if file_set.contains(candidate.as_str()) {
130            return Some(candidate);
131        }
132    }
133    for e in PROBE_EXTENSIONS {
134        let candidate = format!("{base}/index{e}");
135        if file_set.contains(candidate.as_str()) {
136            return Some(candidate);
137        }
138    }
139    None
140}
141
142// JSON with comments and trailing commas -- the spelling every real tsconfig
143// uses. Returns `None` on anything it cannot parse; a tsconfig this module
144// cannot read means bare specifiers stay external, never that they get guessed.
145// Two deliberate crudenesses: the trailing-comma pass is NOT string-aware (a
146// plain scan over the comment-stripped text), and it does not rescan its own
147// output.
148fn strip_jsonc(text: &str) -> String {
149    let chars: Vec<char> = text.chars().collect();
150    let mut out = String::new();
151    let mut i = 0usize;
152    let mut in_string = false;
153    while i < chars.len() {
154        let ch = chars[i];
155        if in_string {
156            out.push(ch);
157            if ch == '\\' {
158                if let Some(next) = chars.get(i + 1) {
159                    out.push(*next);
160                }
161                i += 2;
162                continue;
163            }
164            if ch == '"' {
165                in_string = false;
166            }
167            i += 1;
168            continue;
169        }
170        if ch == '"' {
171            in_string = true;
172            out.push(ch);
173            i += 1;
174            continue;
175        }
176        if ch == '/' && chars.get(i + 1) == Some(&'/') {
177            while i < chars.len() && chars[i] != '\n' {
178                i += 1;
179            }
180            continue;
181        }
182        if ch == '/' && chars.get(i + 1) == Some(&'*') {
183            i += 2;
184            while i < chars.len() && !(chars[i] == '*' && chars.get(i + 1) == Some(&'/')) {
185                i += 1;
186            }
187            i += 2;
188            continue;
189        }
190        out.push(ch);
191        i += 1;
192    }
193    drop_trailing_commas(&out)
194}
195
196// Drops a comma that precedes a `}` or `]` (with optional whitespace between):
197// one left-to-right pass, each match consuming the comma AND the
198// whitespace-plus-closer that followed it, so the scan resumes past the closer
199// and never rescans its own replacement.
200fn drop_trailing_commas(text: &str) -> String {
201    let chars: Vec<char> = text.chars().collect();
202    let mut out = String::new();
203    let mut i = 0usize;
204    while i < chars.len() {
205        if chars[i] != ',' {
206            out.push(chars[i]);
207            i += 1;
208            continue;
209        }
210        let mut j = i + 1;
211        while j < chars.len() && chars[j].is_whitespace() {
212            j += 1;
213        }
214        if j < chars.len() && (chars[j] == '}' || chars[j] == ']') {
215            for &c in &chars[i + 1..=j] {
216                out.push(c);
217            }
218            i = j + 1;
219            continue;
220        }
221        out.push(',');
222        i += 1;
223    }
224    out
225}
226
227// `paths` is read through `OrderedMap` so the aliases are built in the
228// document's own key order (serde_json's default `Map` sorts, which would
229// reorder a length tie); the untagged fallback treats a non-object `paths` as
230// "no paths" rather than failing the whole read of the tsconfig.
231#[derive(Debug, Deserialize)]
232#[serde(untagged)]
233enum PathsField {
234    Map(OrderedMap<Value>),
235    Other(#[allow(dead_code)] Value),
236}
237
238#[derive(Debug, Default, Deserialize)]
239struct CompilerOptions {
240    #[serde(default, rename = "baseUrl")]
241    base_url: Option<Value>,
242    #[serde(default)]
243    paths: Option<PathsField>,
244}
245
246#[derive(Debug, Default, Deserialize)]
247struct TsConfigDoc {
248    #[serde(default)]
249    extends: Option<Value>,
250    #[serde(default, rename = "compilerOptions")]
251    compiler_options: Option<CompilerOptions>,
252}
253
254impl TsConfigDoc {
255    fn base_url(&self) -> Option<&str> {
256        self.compiler_options.as_ref()?.base_url.as_ref()?.as_str()
257    }
258
259    // The guard used when picking which config OWNS the aliases: a `paths` that
260    // is present and an object.
261    fn paths(&self) -> Option<&OrderedMap<Value>> {
262        match self.compiler_options.as_ref()?.paths.as_ref()? {
263            PathsField::Map(m) => Some(m),
264            PathsField::Other(_) => None,
265        }
266    }
267
268    // A bare TRUTHINESS test on `compilerOptions.paths` -- the weaker guard the
269    // chain-selection loop uses, deliberately kept distinct from `paths()` above
270    // so a config declaring a non-object `paths` still wins the chain and still
271    // contributes no alias.
272    fn declares_paths(&self) -> bool {
273        match self
274            .compiler_options
275            .as_ref()
276            .and_then(|o| o.paths.as_ref())
277        {
278            None => false,
279            Some(PathsField::Map(_)) => true,
280            Some(PathsField::Other(v)) => match v {
281                Value::Bool(b) => *b,
282                Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0),
283                Value::String(str_value) => !str_value.is_empty(),
284                Value::Null => false,
285                _ => true,
286            },
287        }
288    }
289}
290
291struct ChainLink {
292    dir: String,
293    cfg: TsConfigDoc,
294}
295
296fn read_tsconfig_chain(root: &Path, start: &str) -> Vec<ChainLink> {
297    let mut chain: Vec<ChainLink> = Vec::new();
298    let mut seen: HashSet<String> = HashSet::new();
299    let mut rel: Option<String> = Some(start.to_string());
300    for _ in 0..TSCONFIG_CHAIN_LIMIT {
301        let Some(current) = rel.clone() else { break };
302        if !seen.insert(current.clone()) {
303            break;
304        }
305        let path = root.join(&current);
306        if !path.exists() {
307            break;
308        }
309        let Ok(text) = fs::read_to_string(&path) else {
310            break;
311        };
312        let stripped = strip_jsonc(&text);
313        // A parse failure means "cannot read this config" and stops the chain. A
314        // config that parses but is not an object contributes no option at all,
315        // which is what `TsConfigDoc::default()` is.
316        let Ok(value) = serde_json::from_str::<Value>(&stripped) else {
317            break;
318        };
319        let cfg = if value.is_object() {
320            serde_json::from_str::<TsConfigDoc>(&stripped).unwrap_or_default()
321        } else {
322            TsConfigDoc::default()
323        };
324        let dir = dir_of(&current).to_string();
325        // Only a RELATIVE extends is followed: a bare one names a package inside
326        // node_modules, which this resolver deliberately does not resolve into.
327        rel = match cfg.extends.as_ref().and_then(Value::as_str) {
328            Some(ext) if ext.starts_with('.') => {
329                let spelled = if ext.ends_with(".json") {
330                    ext.to_string()
331                } else {
332                    format!("{ext}.json")
333                };
334                join_rel(&dir, &spelled)
335            }
336            _ => None,
337        };
338        chain.push(ChainLink { dir, cfg });
339    }
340    chain
341}
342
343#[derive(Debug, Clone)]
344struct AliasTarget {
345    prefix: Option<String>,
346    suffix: String,
347}
348
349#[derive(Debug, Clone)]
350struct Alias {
351    exact: Option<String>,
352    prefix: String,
353    suffix: String,
354    targets: Vec<AliasTarget>,
355    wildcard: bool,
356}
357
358#[derive(Debug, Clone, Default)]
359/// Represents `TsPathAliases`.
360pub struct TsPathAliases {
361    aliases: Vec<Alias>,
362    base_url_dir: Option<String>,
363}
364
365#[derive(Debug, Clone, Default)]
366/// A repo's tsconfig alias reading, scoped per directory.
367///
368/// `root` is the repo-root chain (see `read_ts_path_aliases`), and `nested`
369/// is every directory below it that owns a `tsconfig.json` declaring `paths`
370/// or `baseUrl` of its own, longest directory first so the nearest ancestor
371/// is always checked before a shallower one. Built by `read_ts_alias_scopes`.
372pub struct TsAliasScopes {
373    root: TsPathAliases,
374    nested: Vec<(String, TsPathAliases)>,
375}
376
377/// Reads the repo-root tsconfig chain into its aliases.
378///
379/// `paths` and `baseUrl` are whole-property overrides in TypeScript's own
380/// inheritance rule, not merged key by key -- the nearest config that declares
381/// one wins it outright. `paths` entries resolve against that config's own
382/// baseUrl when it has one, else against the directory the config sits in
383/// (TypeScript 4.1's rule).
384pub fn read_ts_path_aliases(root: &Path) -> TsPathAliases {
385    let mut chain: Vec<ChainLink> = Vec::new();
386    for start in TSCONFIG_ROOTS {
387        let candidate = read_tsconfig_chain(root, start);
388        if candidate.is_empty() {
389            continue;
390        }
391        let declares_paths = candidate.iter().any(|c| c.cfg.declares_paths());
392        if chain.is_empty() || declares_paths {
393            chain = candidate;
394        }
395        if declares_paths {
396            break;
397        }
398    }
399    aliases_from_chain(&chain)
400}
401
402// The part of alias reading shared by the root chain and every nested one:
403// once a chain is picked, decide which link in it owns `paths` and which owns
404// `baseUrl` (independently -- each is its own whole-property override) and
405// resolve every pattern's targets against that.
406fn aliases_from_chain(chain: &[ChainLink]) -> TsPathAliases {
407    let paths_from = chain.iter().find(|c| c.cfg.paths().is_some());
408    let base_url_from = chain.iter().find(|c| c.cfg.base_url().is_some());
409    let base_url_dir = base_url_from.and_then(|c| join_rel(&c.dir, c.cfg.base_url().unwrap_or("")));
410    let mut aliases: Vec<Alias> = Vec::new();
411    if let Some(pf) = paths_from {
412        let base: Option<String> = match pf.cfg.base_url() {
413            Some(own) => join_rel(&pf.dir, own),
414            None => base_url_dir.clone().or_else(|| Some(pf.dir.clone())),
415        };
416        for (pattern, targets) in pf.cfg.paths().expect("paths_from declares paths").iter() {
417            let Some(targets) = targets.as_array() else {
418                continue;
419            };
420            let resolved: Vec<AliasTarget> = targets
421                .iter()
422                .filter_map(Value::as_str)
423                .map(|t| match t.find('*') {
424                    None => AliasTarget {
425                        prefix: join_rel(base.as_deref().unwrap_or(""), t),
426                        suffix: String::new(),
427                    },
428                    Some(star) => AliasTarget {
429                        prefix: Some(
430                            join_rel(base.as_deref().unwrap_or(""), &t[..star]).unwrap_or_default(),
431                        ),
432                        suffix: t[star + 1..].to_string(),
433                    },
434                })
435                .collect();
436            aliases.push(match pattern.find('*') {
437                None => Alias {
438                    exact: Some(pattern.clone()),
439                    prefix: pattern.clone(),
440                    suffix: String::new(),
441                    targets: resolved,
442                    wildcard: false,
443                },
444                Some(star) => Alias {
445                    exact: None,
446                    prefix: pattern[..star].to_string(),
447                    suffix: pattern[star + 1..].to_string(),
448                    targets: resolved,
449                    wildcard: true,
450                },
451            });
452        }
453    }
454    // Longest prefix wins, TypeScript's own tie-break; the name breaks a length
455    // tie so the order is total and identical on every run. `sort_by` is stable,
456    // matching V8's own stable `Array.prototype.sort`.
457    aliases.sort_by(|a, b| {
458        b.prefix
459            .len()
460            .cmp(&a.prefix.len())
461            .then_with(|| a.prefix.cmp(&b.prefix))
462    });
463    TsPathAliases {
464        aliases,
465        base_url_dir,
466    }
467}
468
469// Walks a directory's own ancestors toward the repo root -- itself first, then
470// its parent, and so on, stopping before the empty root directory -- for the
471// nearest one that owns a `tsconfig.json` DECLARING `paths` or `baseUrl`. A
472// config that declares neither is stepped over, never stopped at: the walk
473// continues to the next owner above it, so the answer for a directory is a
474// function of its own ancestors alone, never of which other files the repo
475// happens to carry. Memoized per directory (the owner) and per config (its
476// aliases) so two files under the same app cost one filesystem check between
477// them rather than one each.
478fn owning_tsconfig_dir(
479    root: &Path,
480    dir: &str,
481    owner_cache: &mut HashMap<String, Option<String>>,
482    alias_cache: &mut HashMap<String, Option<TsPathAliases>>,
483) -> Option<String> {
484    if dir.is_empty() {
485        return None;
486    }
487    if let Some(hit) = owner_cache.get(dir) {
488        return hit.clone();
489    }
490    let own = alias_cache
491        .entry(dir.to_string())
492        .or_insert_with(|| {
493            let config = root.join(dir).join("tsconfig.json");
494            if !config.is_file() {
495                return None;
496            }
497            let chain = read_tsconfig_chain(root, &format!("{dir}/tsconfig.json"));
498            let aliases = aliases_from_chain(&chain);
499            let owns_something = !aliases.aliases.is_empty() || aliases.base_url_dir.is_some();
500            owns_something.then_some(aliases)
501        })
502        .is_some();
503    let answer = if own {
504        Some(dir.to_string())
505    } else {
506        owning_tsconfig_dir(root, dir_of(dir), owner_cache, alias_cache)
507    };
508    owner_cache.insert(dir.to_string(), answer.clone());
509    answer
510}
511
512/// Reads the root chain once, then finds each distinct directory `files`
513/// names an owner for: the nearest ancestor `tsconfig.json` above it that
514/// declares `paths` or `baseUrl` of its own.
515///
516/// A nested config that declares neither is stepped over -- a silent config
517/// must not switch the workspace's own aliases off for the files beneath it --
518/// so those files resolve through the next owner above it, and through `root`
519/// when there is none, exactly as if the silent config did not exist.
520pub fn read_ts_alias_scopes<'a>(
521    root: &Path,
522    files: impl IntoIterator<Item = &'a str>,
523) -> TsAliasScopes {
524    let root_aliases = read_ts_path_aliases(root);
525    let mut owner_cache: HashMap<String, Option<String>> = HashMap::new();
526    let mut alias_cache: HashMap<String, Option<TsPathAliases>> = HashMap::new();
527    let mut owners: HashSet<String> = HashSet::new();
528    for file in files {
529        if let Some(owner) =
530            owning_tsconfig_dir(root, dir_of(file), &mut owner_cache, &mut alias_cache)
531        {
532            owners.insert(owner);
533        }
534    }
535    let mut nested: Vec<(String, TsPathAliases)> = owners
536        .into_iter()
537        .filter_map(|dir| {
538            let aliases = alias_cache.remove(&dir).flatten()?;
539            Some((dir, aliases))
540        })
541        .collect();
542    // Longest directory first, ties broken by name for a total, deterministic
543    // order: `scope_for` below returns the first match, so the nearest
544    // ancestor must be checked before a shallower one that also prefixes the
545    // file.
546    nested.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
547    TsAliasScopes {
548        root: root_aliases,
549        nested,
550    }
551}
552
553// The scope a specifier in `from_file` resolves against: its nearest owning
554// nested config, or `root` when none owns it. `dir` prefixes `from_file` only
555// when the next character is a path separator, so `apps/web2` never matches
556// an owner directory `apps/web`.
557fn scope_for<'a>(from_file: &str, scopes: &'a TsAliasScopes) -> &'a TsPathAliases {
558    for (dir, aliases) in &scopes.nested {
559        if from_file.len() > dir.len()
560            && from_file.starts_with(dir.as_str())
561            && from_file.as_bytes()[dir.len()] == b'/'
562        {
563            return aliases;
564        }
565    }
566    &scopes.root
567}
568
569fn alias_targets(spec: &str, aliases: &[Alias]) -> Vec<Option<String>> {
570    for a in aliases {
571        if !a.wildcard {
572            if a.exact.as_deref() == Some(spec) {
573                return a.targets.iter().map(|t| t.prefix.clone()).collect();
574            }
575            continue;
576        }
577        // A length guard (`spec.len() < prefix.len() + suffix.len()`), then a
578        // prefix and suffix match. Stripping the prefix first and the suffix off
579        // what REMAINS expresses the same three conditions in one pass -- an
580        // overlapping prefix/suffix pair fails the second strip exactly where the
581        // length guard would reject it.
582        let Some(rest) = spec.strip_prefix(a.prefix.as_str()) else {
583            continue;
584        };
585        let Some(mid) = rest.strip_suffix(a.suffix.as_str()) else {
586            continue;
587        };
588        return a
589            .targets
590            .iter()
591            .map(|t| {
592                let rest = if t.suffix.is_empty() {
593                    mid.to_string()
594                } else {
595                    format!("{mid}{}", t.suffix)
596                };
597                t.prefix.as_deref().and_then(|p| join_rel(p, &rest))
598            })
599            .collect();
600    }
601    Vec::new()
602}
603
604/// Relative specifiers resolve directly; a bare one resolves only as far as
605/// the repo's own tsconfig paths/baseUrl already take it -- the nearest
606/// owning scope to `from_file`, root when none owns it.
607///
608/// Anything else is `external` -- the TS-side counterpart of the C# graph's
609/// cross-project handling, and never a guess at a package's internals.
610pub fn resolve_specifier(
611    from_file: &str,
612    spec: &str,
613    file_set: &HashSet<&str>,
614    alias: &TsAliasScopes,
615) -> Option<String> {
616    if spec.starts_with('.') {
617        return probe_file(join_rel(dir_of(from_file), spec).as_deref(), file_set);
618    }
619    let scope = scope_for(from_file, alias);
620    for candidate in alias_targets(spec, &scope.aliases) {
621        if let Some(hit) = probe_file(candidate.as_deref(), file_set) {
622            return Some(hit);
623        }
624    }
625    if let Some(base) = &scope.base_url_dir {
626        if let Some(hit) = probe_file(join_rel(base, spec).as_deref(), file_set) {
627            return Some(hit);
628        }
629    }
630    None
631}
632
633/// The four TS edge kinds' counts, appended to `stats.edges_by_kind` in this
634/// order and only when the repo carries a TS fragment at all.
635#[derive(Debug, Clone, Default, PartialEq)]
636pub struct TsEdgeCounts {
637    /// The import value.
638    pub import: usize,
639    /// The call value.
640    pub call: usize,
641    /// The jsx use value.
642    pub jsx_use: usize,
643    /// The dispatch value.
644    pub dispatch: usize,
645}
646
647/// `stats.ts` -- appended inside `stats` after `test_def_count`, and only when
648/// the repo carries a TS fragment at all. `heuristic_by_tier` is the newer
649/// fact and follows it (see `graph::Stats`).
650#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
651pub struct TsStats {
652    /// The ts file count value.
653    pub ts_file_count: usize,
654    /// The ts def count value.
655    pub ts_def_count: usize,
656    /// The external import count value.
657    pub external_import_count: usize,
658    /// The unresolved ref count value.
659    pub unresolved_ref_count: usize,
660}
661
662/// Represents `TsGraph`.
663pub struct TsGraph {
664    /// The defs value.
665    pub defs: Vec<Def>,
666    /// The edges value.
667    pub edges: Vec<Edge>,
668    /// The edges by kind value.
669    pub edges_by_kind: TsEdgeCounts,
670    /// The stats value.
671    pub stats: TsStats,
672}
673
674struct ResolvedReexport {
675    spec: String,
676    line: usize,
677    star: bool,
678    names: Vec<(String, String)>,
679    to: Option<String>,
680}
681
682/// Resolves the TS/TSX graph: every file's reference fragments into edges.
683#[allow(
684    clippy::too_many_lines,
685    reason = "one ordered pass building every def, export and edge table the resolution below reads from, all over the same fragment set"
686)]
687pub fn resolve_ts_graph(fragments: &[(String, TsFragment)], alias: &TsAliasScopes) -> TsGraph {
688    let file_set: HashSet<&str> = fragments.iter().map(|(f, _)| f.as_str()).collect();
689
690    let mut defs: Vec<Def> = Vec::new();
691    let mut exports_by_file: HashMap<&str, HashMap<String, usize>> = HashMap::new();
692    for (file, frag) in fragments {
693        let mut by_name: HashMap<String, usize> = HashMap::new();
694        for d in &frag.defs {
695            if by_name.contains_key(&d.name) {
696                continue;
697            }
698            by_name.insert(d.name.clone(), defs.len());
699            defs.push(Def {
700                id: ts_def_id(file, &d.name),
701                name: d.name.clone(),
702                namespace: dir_of(file).to_string(),
703                kind: d.kind.clone(),
704                file: file.clone(),
705                line: d.line,
706                methods: Vec::new(),
707                test_methods: Vec::new(),
708                also_in: Vec::new(),
709                end_line: d.end_line,
710            });
711        }
712        // `export default Foo` makes the SAME declaration importable under the
713        // name `default` -- one def, two names, never a second row on one
714        // declaration.
715        if let Some(dflt) = &frag.default {
716            if let Some(&i) = by_name.get(dflt) {
717                by_name.insert("default".to_string(), i);
718            }
719        }
720        exports_by_file.insert(file.as_str(), by_name);
721    }
722
723    let mut reexports_by_file: HashMap<&str, Vec<ResolvedReexport>> = HashMap::new();
724    for (file, frag) in fragments {
725        let rows = frag
726            .reexports
727            .iter()
728            .map(|rx| ResolvedReexport {
729                spec: rx.spec.clone(),
730                line: rx.line,
731                star: rx.star,
732                names: rx
733                    .names
734                    .iter()
735                    .map(|n| (n.exported.clone(), n.imported.clone()))
736                    .collect(),
737                to: resolve_specifier(file, &rx.spec, &file_set, alias),
738            })
739            .collect();
740        reexports_by_file.insert(file.as_str(), rows);
741    }
742
743    // `visited` is one top-level lookup's own map, fresh at every call site
744    // below (never shared across bindings or refs): a `(file, name)` pair
745    // revisited mid-walk means a cyclic `export *` pair sent the walk back to
746    // a state it is already computing, so it fails closed to `None` right
747    // there rather than recursing again. That guard also keeps a single
748    // `export *` fan-out linear in files times names. What the map REMEMBERS
749    // is the shallowest hop count the pair was walked from: a pair first met
750    // deep in the walk, where the cap cut its own re-exports short, is walked
751    // again when a shorter path reaches it with more hops to spend -- and
752    // never again from the same depth or deeper.
753    fn lookup_export(
754        file: &str,
755        name: &str,
756        hops: usize,
757        exports_by_file: &HashMap<&str, HashMap<String, usize>>,
758        reexports_by_file: &HashMap<&str, Vec<ResolvedReexport>>,
759        visited: &mut HashMap<(String, String), usize>,
760    ) -> Option<usize> {
761        let key = (file.to_string(), name.to_string());
762        if visited.get(&key).is_some_and(|&seen| seen <= hops) {
763            return None;
764        }
765        visited.insert(key, hops);
766        // Own declarations are checked before the hop cap is enforced, so a
767        // chain of exactly `BARREL_HOPS` barrels followed by a declaring file
768        // still resolves -- the cap bounds how many re-export hops are
769        // FOLLOWED, not how many files may be visited in total.
770        if let Some(own) = exports_by_file.get(file).and_then(|m| m.get(name)) {
771            return Some(*own);
772        }
773        if hops >= BARREL_HOPS {
774            return None;
775        }
776        for rx in reexports_by_file
777            .get(file)
778            .map(Vec::as_slice)
779            .unwrap_or(&[])
780        {
781            let Some(to) = &rx.to else { continue };
782            if rx.star {
783                if let Some(hit) = lookup_export(
784                    to,
785                    name,
786                    hops + 1,
787                    exports_by_file,
788                    reexports_by_file,
789                    visited,
790                ) {
791                    return Some(hit);
792                }
793                continue;
794            }
795            // Declaration order is the tie-break: the first re-export naming
796            // this export wins, matching the first-occurrence rule the def
797            // map itself uses.
798            for (exported, imported) in &rx.names {
799                if exported != name {
800                    continue;
801                }
802                if let Some(hit) = lookup_export(
803                    to,
804                    imported,
805                    hops + 1,
806                    exports_by_file,
807                    reexports_by_file,
808                    visited,
809                ) {
810                    return Some(hit);
811                }
812            }
813        }
814        None
815    }
816
817    let mut edges: Vec<Edge> = Vec::new();
818    let mut counts = TsEdgeCounts::default();
819    let mut external_imports = 0usize;
820    let mut unresolved_refs = 0usize;
821
822    for (file, frag) in fragments {
823        let mut bindings: HashMap<String, (String, String)> = HashMap::new();
824        for imp in &frag.imports {
825            let to = resolve_specifier(file, &imp.spec, &file_set, alias);
826            let Some(to) = to else {
827                external_imports += 1;
828                continue;
829            };
830            edges.push(Edge::Import {
831                from_file: file.clone(),
832                from_line: imp.line,
833                target: imp.spec.clone(),
834                to_file: to.clone(),
835                via: None,
836            });
837            counts.import += 1;
838            // First binding of a local name wins, the same first-occurrence
839            // rule the def map above uses: a file that binds one name twice is
840            // already broken TypeScript, and picking the later one would make
841            // the answer depend on statement order for no gain.
842            for b in &imp.bindings {
843                bindings
844                    .entry(b.local.clone())
845                    .or_insert((to.clone(), b.imported.clone()));
846            }
847            // A barrel is a routing table, not a dependency: `import
848            // { useThing } from '../hooks'` names index.ts but DEPENDS on the
849            // module index.ts re-exports it from. Without this edge, `impact`
850            // on the declaring file misses every consumer that came through
851            // the barrel. The edge to the named file stays (it is what the
852            // source literally says); this one carries `via` so a reader can
853            // tell the two apart, and one line contributes at most one edge per
854            // distinct declaring file however many names it pulls through.
855            let mut followed: HashSet<String> = HashSet::new();
856            for b in &imp.bindings {
857                if b.imported == "*" {
858                    continue;
859                }
860                let mut visited: HashMap<(String, String), usize> = HashMap::new();
861                let Some(target) = lookup_export(
862                    &to,
863                    &b.imported,
864                    0,
865                    &exports_by_file,
866                    &reexports_by_file,
867                    &mut visited,
868                ) else {
869                    continue;
870                };
871                let target_file = defs[target].file.clone();
872                if target_file == to || followed.contains(&target_file) {
873                    continue;
874                }
875                followed.insert(target_file.clone());
876                edges.push(Edge::Import {
877                    from_file: file.clone(),
878                    from_line: imp.line,
879                    target: imp.spec.clone(),
880                    to_file: target_file,
881                    via: Some(to.clone()),
882                });
883                counts.import += 1;
884            }
885        }
886        for rx in reexports_by_file
887            .get(file.as_str())
888            .map(Vec::as_slice)
889            .unwrap_or(&[])
890        {
891            match &rx.to {
892                None => external_imports += 1,
893                Some(to) => {
894                    edges.push(Edge::Import {
895                        from_file: file.clone(),
896                        from_line: rx.line,
897                        target: rx.spec.clone(),
898                        to_file: to.clone(),
899                        via: None,
900                    });
901                    counts.import += 1;
902                }
903            }
904        }
905
906        for r in &frag.refs {
907            let target = match bindings.get(&r.name) {
908                // A namespace binding (`* as ns`, or a whole-module `require`)
909                // carries no name of its own -- only `ns.member` says which
910                // export is meant.
911                Some((to, imported)) if imported == "*" => match &r.member {
912                    Some(m) => {
913                        let mut visited: HashMap<(String, String), usize> = HashMap::new();
914                        lookup_export(to, m, 0, &exports_by_file, &reexports_by_file, &mut visited)
915                    }
916                    None => None,
917                },
918                Some((to, imported)) => {
919                    let mut visited: HashMap<(String, String), usize> = HashMap::new();
920                    lookup_export(
921                        to,
922                        imported,
923                        0,
924                        &exports_by_file,
925                        &reexports_by_file,
926                        &mut visited,
927                    )
928                }
929                None if r.member.is_none() => exports_by_file
930                    .get(file.as_str())
931                    .and_then(|m| m.get(&r.name))
932                    .copied(),
933                None => None,
934            };
935            let Some(target) = target else {
936                unresolved_refs += 1;
937                continue;
938            };
939            let to = defs[target].id.clone();
940            let to_file = defs[target].file.clone();
941            match r.kind.as_str() {
942                "call" => {
943                    edges.push(Edge::Call {
944                        from_file: file.clone(),
945                        from_line: r.line,
946                        to,
947                        to_file,
948                    });
949                    counts.call += 1;
950                }
951                "jsx-use" => {
952                    edges.push(Edge::JsxUse {
953                        from_file: file.clone(),
954                        from_line: r.line,
955                        to,
956                        to_file,
957                    });
958                    counts.jsx_use += 1;
959                }
960                "dispatch" => {
961                    edges.push(Edge::Dispatch {
962                        from_file: file.clone(),
963                        from_line: r.line,
964                        to,
965                        to_file,
966                    });
967                    counts.dispatch += 1;
968                }
969                // Unreachable over a fragment this crate produced: the
970                // extractor emits exactly the three ref kinds above. A cached
971                // fragment carrying anything else is dropped rather than
972                // guessed at, the same stance every other unknown fact gets.
973                _ => unresolved_refs += 1,
974            }
975        }
976    }
977
978    let stats = TsStats {
979        ts_file_count: fragments.len(),
980        ts_def_count: defs.len(),
981        external_import_count: external_imports,
982        unresolved_ref_count: unresolved_refs,
983    };
984    TsGraph {
985        defs,
986        edges,
987        edges_by_kind: counts,
988        stats,
989    }
990}
991
992#[cfg(test)]
993mod tests {
994    use super::*;
995    use crate::extract::{TsBinding, TsFragmentDef, TsImport, TsReexport, TsReexportName, TsRef};
996    use std::sync::atomic::{AtomicU64, Ordering};
997
998    fn frag(
999        defs: Vec<(&str, &str, usize)>,
1000        imports: Vec<TsImport>,
1001        reexports: Vec<TsReexport>,
1002        refs: Vec<TsRef>,
1003    ) -> TsFragment {
1004        TsFragment {
1005            ts: 1,
1006            defs: defs
1007                .into_iter()
1008                .map(|(n, k, l)| TsFragmentDef {
1009                    name: n.into(),
1010                    kind: k.into(),
1011                    line: l,
1012                    end_line: l,
1013                })
1014                .collect(),
1015            imports,
1016            reexports,
1017            refs,
1018            default: None,
1019        }
1020    }
1021
1022    fn import(spec: &str, line: usize, bindings: &[(&str, &str)]) -> TsImport {
1023        TsImport {
1024            spec: spec.into(),
1025            line,
1026            bindings: bindings
1027                .iter()
1028                .map(|(l, i)| TsBinding {
1029                    local: (*l).into(),
1030                    imported: (*i).into(),
1031                })
1032                .collect(),
1033        }
1034    }
1035
1036    fn call_ref(name: &str, line: usize) -> TsRef {
1037        TsRef {
1038            kind: "call".into(),
1039            name: name.into(),
1040            member: None,
1041            line,
1042        }
1043    }
1044
1045    fn named_barrel(spec: &str, exported: &str) -> TsReexport {
1046        TsReexport {
1047            spec: spec.into(),
1048            line: 1,
1049            star: false,
1050            names: vec![TsReexportName {
1051                exported: exported.into(),
1052                imported: exported.into(),
1053            }],
1054        }
1055    }
1056
1057    fn star_barrel(spec: &str) -> TsReexport {
1058        TsReexport {
1059            spec: spec.into(),
1060            line: 1,
1061            star: true,
1062            names: vec![],
1063        }
1064    }
1065
1066    fn no_alias() -> TsAliasScopes {
1067        TsAliasScopes::default()
1068    }
1069
1070    // A throwaway directory tree for the alias-scope tests below: nothing
1071    // else in this module touches disk. Named and counted the same way every
1072    // other `src/*.rs` test's temp-dir helper is (see e.g. `mapcmd.rs`'s),
1073    // and removed on drop so a panicking assertion still cleans up.
1074    struct TmpDir(std::path::PathBuf);
1075
1076    static TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
1077
1078    impl TmpDir {
1079        fn new(label: &str) -> Self {
1080            let n = TMP_COUNTER.fetch_add(1, Ordering::SeqCst);
1081            let dir = std::env::temp_dir().join(format!(
1082                "scout-tsgraph-test-{label}-{}-{n}",
1083                std::process::id()
1084            ));
1085            fs::create_dir_all(&dir).expect("create temp dir");
1086            TmpDir(dir)
1087        }
1088
1089        fn write(&self, rel: &str, contents: &str) {
1090            let path = self.0.join(rel);
1091            if let Some(parent) = path.parent() {
1092                fs::create_dir_all(parent).expect("create parent dirs");
1093            }
1094            fs::write(&path, contents).expect("write fixture file");
1095        }
1096    }
1097
1098    impl Drop for TmpDir {
1099        fn drop(&mut self) {
1100            let _ = fs::remove_dir_all(&self.0);
1101        }
1102    }
1103
1104    #[test]
1105    fn a_root_tsconfig_alias_resolves_a_bare_specifier() {
1106        let tmp = TmpDir::new("root-alias");
1107        tmp.write(
1108            "tsconfig.base.json",
1109            r#"{
1110  "compilerOptions": {
1111    "baseUrl": ".",
1112    "paths": { "@core/*": ["packages/core/src/*"] }
1113  }
1114}"#,
1115        );
1116        let files = ["apps/api/src/handler.ts"];
1117        let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1118        let file_set: HashSet<&str> = [
1119            "apps/api/src/handler.ts",
1120            "packages/core/src/formatLabel.ts",
1121        ]
1122        .into_iter()
1123        .collect();
1124        assert_eq!(
1125            resolve_specifier(
1126                "apps/api/src/handler.ts",
1127                "@core/formatLabel",
1128                &file_set,
1129                &scopes
1130            ),
1131            Some("packages/core/src/formatLabel.ts".to_string())
1132        );
1133    }
1134
1135    #[test]
1136    fn a_nested_tsconfig_owns_the_files_beneath_it() {
1137        let tmp = TmpDir::new("nested-owns");
1138        tmp.write(
1139            "tsconfig.base.json",
1140            r#"{
1141  "compilerOptions": {
1142    "baseUrl": ".",
1143    "paths": { "@core/*": ["packages/core/src/*"] }
1144  }
1145}"#,
1146        );
1147        tmp.write(
1148            "apps/web/tsconfig.json",
1149            r#"{
1150  "extends": "../../tsconfig.base.json",
1151  "compilerOptions": {
1152    "baseUrl": ".",
1153    "paths": { "@/*": ["./src/*"] }
1154  }
1155}"#,
1156        );
1157        let files = ["apps/web/src/pages/Home.tsx", "apps/api/src/handler.ts"];
1158        let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1159        let file_set: HashSet<&str> = [
1160            "apps/web/src/pages/Home.tsx",
1161            "apps/api/src/handler.ts",
1162            "packages/core/src/formatLabel.ts",
1163            "apps/web/src/components/Button.tsx",
1164        ]
1165        .into_iter()
1166        .collect();
1167
1168        assert_eq!(
1169            resolve_specifier(
1170                "apps/web/src/pages/Home.tsx",
1171                "@/components/Button",
1172                &file_set,
1173                &scopes
1174            ),
1175            Some("apps/web/src/components/Button.tsx".to_string()),
1176            "the nested config's own @/ alias resolves"
1177        );
1178        assert_eq!(
1179            resolve_specifier(
1180                "apps/web/src/pages/Home.tsx",
1181                "@core/formatLabel",
1182                &file_set,
1183                &scopes
1184            ),
1185            None,
1186            "the nested paths override is whole-property: it turns off the root's @core alias too"
1187        );
1188        assert_eq!(
1189            resolve_specifier(
1190                "apps/api/src/handler.ts",
1191                "@core/formatLabel",
1192                &file_set,
1193                &scopes
1194            ),
1195            Some("packages/core/src/formatLabel.ts".to_string()),
1196            "a file with no nested owner still resolves through the root chain"
1197        );
1198    }
1199
1200    #[test]
1201    fn a_nested_paths_entry_shadows_the_root_entry_with_the_same_pattern() {
1202        let tmp = TmpDir::new("shadow");
1203        tmp.write(
1204            "tsconfig.base.json",
1205            r#"{
1206  "compilerOptions": {
1207    "baseUrl": ".",
1208    "paths": { "@shared/*": ["packages/shared/src/*"] }
1209  }
1210}"#,
1211        );
1212        tmp.write(
1213            "apps/web/tsconfig.json",
1214            r#"{
1215  "extends": "../../tsconfig.base.json",
1216  "compilerOptions": {
1217    "baseUrl": ".",
1218    "paths": { "@shared/*": ["./vendor/shared/*"] }
1219  }
1220}"#,
1221        );
1222        let files = ["apps/web/src/index.ts", "apps/other/src/index.ts"];
1223        let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1224        let file_set: HashSet<&str> = [
1225            "apps/web/src/index.ts",
1226            "apps/other/src/index.ts",
1227            "packages/shared/src/thing.ts",
1228            "apps/web/vendor/shared/thing.ts",
1229        ]
1230        .into_iter()
1231        .collect();
1232
1233        assert_eq!(
1234            resolve_specifier("apps/web/src/index.ts", "@shared/thing", &file_set, &scopes),
1235            Some("apps/web/vendor/shared/thing.ts".to_string()),
1236            "the file under the nested config resolves to the nested target"
1237        );
1238        assert_eq!(
1239            resolve_specifier(
1240                "apps/other/src/index.ts",
1241                "@shared/thing",
1242                &file_set,
1243                &scopes
1244            ),
1245            Some("packages/shared/src/thing.ts".to_string()),
1246            "a file outside the nested config still resolves to the root target"
1247        );
1248    }
1249
1250    #[test]
1251    fn a_nested_tsconfig_without_paths_or_base_url_falls_back_to_the_root_scope() {
1252        let tmp = TmpDir::new("silent-nested");
1253        tmp.write(
1254            "tsconfig.base.json",
1255            r#"{
1256  "compilerOptions": {
1257    "baseUrl": ".",
1258    "paths": { "@core/*": ["packages/core/src/*"] }
1259  }
1260}"#,
1261        );
1262        tmp.write(
1263            "apps/web/tsconfig.json",
1264            r#"{
1265  "compilerOptions": { "strict": true }
1266}"#,
1267        );
1268        let files = ["apps/web/src/index.ts"];
1269        let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1270        let file_set: HashSet<&str> = ["apps/web/src/index.ts", "packages/core/src/formatLabel.ts"]
1271            .into_iter()
1272            .collect();
1273
1274        assert_eq!(
1275            resolve_specifier(
1276                "apps/web/src/index.ts",
1277                "@core/formatLabel",
1278                &file_set,
1279                &scopes
1280            ),
1281            Some("packages/core/src/formatLabel.ts".to_string()),
1282            "a nested config declaring neither paths nor baseUrl does not switch the root alias off"
1283        );
1284    }
1285
1286    #[test]
1287    fn a_silent_nested_tsconfig_is_stepped_over_to_the_next_owner_above_it() {
1288        // `apps/` owns an alias; `apps/web/` carries a config that declares
1289        // nothing. A file under `apps/web/` resolves through `apps/` -- and
1290        // gets the same answer whether or not some sibling file elsewhere
1291        // under `apps/` happens to exist to register that owner.
1292        let tmp = TmpDir::new("stepped-over");
1293        tmp.write(
1294            "apps/tsconfig.json",
1295            r#"{
1296  "compilerOptions": {
1297    "baseUrl": ".",
1298    "paths": { "@app/*": ["shared/*"] }
1299  }
1300}"#,
1301        );
1302        tmp.write(
1303            "apps/web/tsconfig.json",
1304            r#"{
1305  "compilerOptions": { "strict": true }
1306}"#,
1307        );
1308        let file_set: HashSet<&str> = [
1309            "apps/web/src/x.ts",
1310            "apps/tool/y.ts",
1311            "apps/shared/label.ts",
1312        ]
1313        .into_iter()
1314        .collect();
1315        let alone = read_ts_alias_scopes(&tmp.0, ["apps/web/src/x.ts"]);
1316        let with_sibling = read_ts_alias_scopes(&tmp.0, ["apps/web/src/x.ts", "apps/tool/y.ts"]);
1317        for scopes in [&alone, &with_sibling] {
1318            assert_eq!(
1319                resolve_specifier("apps/web/src/x.ts", "@app/label", &file_set, scopes),
1320                Some("apps/shared/label.ts".to_string()),
1321                "the next owner above a silent config answers, sibling or no sibling"
1322            );
1323        }
1324    }
1325
1326    #[test]
1327    fn nested_scopes_are_ordered_longest_directory_first() {
1328        let tmp = TmpDir::new("nested-order");
1329        tmp.write(
1330            "apps/web/tsconfig.json",
1331            r#"{
1332  "compilerOptions": {
1333    "baseUrl": ".",
1334    "paths": { "@/*": ["./from-web/*"] }
1335  }
1336}"#,
1337        );
1338        tmp.write(
1339            "apps/web/admin/tsconfig.json",
1340            r#"{
1341  "compilerOptions": {
1342    "baseUrl": ".",
1343    "paths": { "@/*": ["./from-admin/*"] }
1344  }
1345}"#,
1346        );
1347        let files = ["apps/web/other/file.ts", "apps/web/admin/src/page.ts"];
1348        let scopes = read_ts_alias_scopes(&tmp.0, files.iter().copied());
1349        let file_set: HashSet<&str> = [
1350            "apps/web/other/file.ts",
1351            "apps/web/admin/src/page.ts",
1352            "apps/web/from-web/thing.ts",
1353            "apps/web/admin/from-admin/thing.ts",
1354        ]
1355        .into_iter()
1356        .collect();
1357
1358        assert_eq!(
1359            resolve_specifier("apps/web/admin/src/page.ts", "@/thing", &file_set, &scopes),
1360            Some("apps/web/admin/from-admin/thing.ts".to_string()),
1361            "the deeper nested config owns files beneath it, not the shallower one"
1362        );
1363        assert_eq!(
1364            resolve_specifier("apps/web/other/file.ts", "@/thing", &file_set, &scopes),
1365            Some("apps/web/from-web/thing.ts".to_string()),
1366            "a file outside the deeper config still resolves through the shallower one"
1367        );
1368    }
1369
1370    #[test]
1371    fn join_rel_refuses_to_climb_above_the_repo_root() {
1372        assert_eq!(
1373            join_rel("src", "../util/format"),
1374            Some("util/format".to_string())
1375        );
1376        assert_eq!(join_rel("", "../escape"), None);
1377    }
1378
1379    #[test]
1380    fn probe_order_prefers_a_typescript_source_over_a_built_js_sibling() {
1381        let files: HashSet<&str> = ["src/x.ts", "src/x.js"].into_iter().collect();
1382        assert_eq!(
1383            probe_file(Some("src/x"), &files),
1384            Some("src/x.ts".to_string())
1385        );
1386    }
1387
1388    #[test]
1389    fn an_esm_js_specifier_resolves_to_the_ts_source_it_compiles_from() {
1390        let files: HashSet<&str> = ["src/x.ts"].into_iter().collect();
1391        assert_eq!(
1392            probe_file(Some("src/x.js"), &files),
1393            Some("src/x.ts".to_string())
1394        );
1395    }
1396
1397    #[test]
1398    fn a_directory_specifier_resolves_through_its_index_file() {
1399        let files: HashSet<&str> = ["src/hooks/index.ts"].into_iter().collect();
1400        assert_eq!(
1401            probe_file(Some("src/hooks"), &files),
1402            Some("src/hooks/index.ts".to_string())
1403        );
1404    }
1405
1406    #[test]
1407    fn strip_jsonc_drops_comments_and_one_trailing_comma_per_closer() {
1408        let text = "{\n // a\n \"a\": 1, /* b */\n \"b\": [1, 2,],\n}";
1409        let value: Value = serde_json::from_str(&strip_jsonc(text)).expect("parses");
1410        assert_eq!(value["a"], serde_json::json!(1));
1411        assert_eq!(value["b"], serde_json::json!([1, 2]));
1412    }
1413
1414    #[test]
1415    fn a_relative_import_becomes_one_import_edge_and_a_resolved_call() {
1416        let fragments = vec![
1417            (
1418                "src/a.ts".to_string(),
1419                frag(
1420                    vec![],
1421                    vec![import("./b", 1, &[("go", "go")])],
1422                    vec![],
1423                    vec![call_ref("go", 3)],
1424                ),
1425            ),
1426            (
1427                "src/b.ts".to_string(),
1428                frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1429            ),
1430        ];
1431        let g = resolve_ts_graph(&fragments, &no_alias());
1432        assert_eq!(
1433            g.edges_by_kind,
1434            TsEdgeCounts {
1435                import: 1,
1436                call: 1,
1437                jsx_use: 0,
1438                dispatch: 0
1439            }
1440        );
1441        assert_eq!(g.stats.ts_def_count, 1);
1442        assert_eq!(g.stats.unresolved_ref_count, 0);
1443    }
1444
1445    #[test]
1446    fn an_unresolvable_specifier_is_external_and_never_an_edge() {
1447        let fragments = vec![(
1448            "src/a.ts".to_string(),
1449            frag(
1450                vec![],
1451                vec![import("./nowhere", 1, &[("go", "go")])],
1452                vec![],
1453                vec![call_ref("go", 3)],
1454            ),
1455        )];
1456        let g = resolve_ts_graph(&fragments, &no_alias());
1457        assert_eq!(g.edges.len(), 0);
1458        assert_eq!(g.stats.external_import_count, 1);
1459        assert_eq!(g.stats.unresolved_ref_count, 1);
1460    }
1461
1462    #[test]
1463    fn chained_barrels_resolve_to_the_declaring_file_within_the_hop_cap() {
1464        let fragments = vec![
1465            (
1466                "src/a.ts".to_string(),
1467                frag(
1468                    vec![],
1469                    vec![import("./b", 1, &[("go", "go")])],
1470                    vec![],
1471                    vec![call_ref("go", 3)],
1472                ),
1473            ),
1474            (
1475                "src/b.ts".to_string(),
1476                frag(vec![], vec![], vec![named_barrel("./c", "go")], vec![]),
1477            ),
1478            (
1479                "src/c.ts".to_string(),
1480                frag(vec![], vec![], vec![star_barrel("./d")], vec![]),
1481            ),
1482            (
1483                "src/d.ts".to_string(),
1484                frag(vec![], vec![], vec![named_barrel("./e", "go")], vec![]),
1485            ),
1486            (
1487                "src/e.ts".to_string(),
1488                frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1489            ),
1490        ];
1491        let g = resolve_ts_graph(&fragments, &no_alias());
1492        assert!(matches!(
1493            g.edges.iter().find(|e| matches!(
1494                e,
1495                Edge::Import { via: Some(v), .. } if v == "src/b.ts"
1496            )),
1497            Some(Edge::Import { to_file, .. }) if to_file == "src/e.ts"
1498        ));
1499        assert!(matches!(
1500            g.edges.iter().find(|e| matches!(e, Edge::Call { .. })),
1501            Some(Edge::Call { to_file, .. }) if to_file == "src/e.ts"
1502        ));
1503
1504        // One barrel more than the cap allows: the walk gives up one hop
1505        // short of the file that finally declares the name, so it resolves
1506        // to nothing.
1507        let mut deep = vec![(
1508            "src/deep_a.ts".to_string(),
1509            frag(
1510                vec![],
1511                vec![import("./deep0", 1, &[("go", "go")])],
1512                vec![],
1513                vec![],
1514            ),
1515        )];
1516        for i in 0..=BARREL_HOPS {
1517            let next = if i == BARREL_HOPS {
1518                "deep_decl".to_string()
1519            } else {
1520                format!("deep{}", i + 1)
1521            };
1522            deep.push((
1523                format!("src/deep{i}.ts"),
1524                frag(
1525                    vec![],
1526                    vec![],
1527                    vec![named_barrel(&format!("./{next}"), "go")],
1528                    vec![],
1529                ),
1530            ));
1531        }
1532        deep.push((
1533            "src/deep_decl.ts".to_string(),
1534            frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1535        ));
1536        let g2 = resolve_ts_graph(&deep, &no_alias());
1537        assert!(
1538            !g2.edges
1539                .iter()
1540                .any(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1541            "a chain one barrel past the hop cap resolves to nothing"
1542        );
1543    }
1544
1545    #[test]
1546    fn a_cyclic_star_reexport_pair_terminates_and_resolves_nothing_for_an_absent_name() {
1547        let fragments = vec![
1548            (
1549                "src/a.ts".to_string(),
1550                frag(
1551                    vec![],
1552                    vec![import("./b", 1, &[("missing", "missing")])],
1553                    vec![],
1554                    vec![call_ref("missing", 3)],
1555                ),
1556            ),
1557            (
1558                "src/b.ts".to_string(),
1559                frag(vec![], vec![], vec![star_barrel("./c")], vec![]),
1560            ),
1561            (
1562                "src/c.ts".to_string(),
1563                frag(vec![], vec![], vec![star_barrel("./b")], vec![]),
1564            ),
1565        ];
1566        // The assertion below is the point of the test, but so is simply
1567        // reaching it: a cyclic `export *` pair with no bound would recurse
1568        // forever over a name neither file ever declares.
1569        let g = resolve_ts_graph(&fragments, &no_alias());
1570        assert!(
1571            !g.edges
1572                .iter()
1573                .any(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1574            "a name absent from a cyclic star-reexport pair carries no via edge"
1575        );
1576        assert!(
1577            !g.edges.iter().any(|e| matches!(e, Edge::Call { .. })),
1578            "a name absent from a cyclic star-reexport pair resolves no call"
1579        );
1580    }
1581
1582    #[test]
1583    fn a_cycle_still_resolves_a_name_one_of_its_members_declares() {
1584        let fragments = vec![
1585            (
1586                "src/a.ts".to_string(),
1587                frag(
1588                    vec![],
1589                    vec![import("./b", 1, &[("go", "go")])],
1590                    vec![],
1591                    vec![call_ref("go", 3)],
1592                ),
1593            ),
1594            (
1595                "src/b.ts".to_string(),
1596                frag(vec![], vec![], vec![star_barrel("./c")], vec![]),
1597            ),
1598            (
1599                "src/c.ts".to_string(),
1600                frag(
1601                    vec![("go", "function", 1)],
1602                    vec![],
1603                    vec![star_barrel("./b")],
1604                    vec![],
1605                ),
1606            ),
1607        ];
1608        let g = resolve_ts_graph(&fragments, &no_alias());
1609        assert!(matches!(
1610            g.edges.iter().find(|e| matches!(e, Edge::Import { via: Some(_), .. })),
1611            Some(Edge::Import { to_file, .. }) if to_file == "src/c.ts"
1612        ));
1613        assert!(matches!(
1614            g.edges.iter().find(|e| matches!(e, Edge::Call { .. })),
1615            Some(Edge::Call { to_file, .. }) if to_file == "src/c.ts"
1616        ));
1617    }
1618
1619    #[test]
1620    fn a_pair_first_cut_by_the_cap_is_walked_again_from_a_shorter_path() {
1621        // `b` fans out to a long chain first and a short one second, both
1622        // reaching `deep`, which alone re-exports the declaring file. Down
1623        // the long chain `deep` is entered exactly at the cap, so its own
1624        // re-export is never followed; the short chain then reaches it with
1625        // hops to spare and must be allowed to walk it again.
1626        let long_links = BARREL_HOPS - 1;
1627        let mut fragments = vec![
1628            (
1629                "src/a.ts".to_string(),
1630                frag(
1631                    vec![],
1632                    vec![import("./b", 1, &[("go", "go")])],
1633                    vec![],
1634                    vec![call_ref("go", 3)],
1635                ),
1636            ),
1637            (
1638                "src/b.ts".to_string(),
1639                frag(
1640                    vec![],
1641                    vec![],
1642                    vec![star_barrel("./long1"), star_barrel("./short")],
1643                    vec![],
1644                ),
1645            ),
1646            (
1647                "src/short.ts".to_string(),
1648                frag(vec![], vec![], vec![star_barrel("./deep")], vec![]),
1649            ),
1650            (
1651                "src/deep.ts".to_string(),
1652                frag(vec![], vec![], vec![star_barrel("./x")], vec![]),
1653            ),
1654            (
1655                "src/x.ts".to_string(),
1656                frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1657            ),
1658        ];
1659        for i in 1..=long_links {
1660            let next = if i == long_links {
1661                "./deep".to_string()
1662            } else {
1663                format!("./long{}", i + 1)
1664            };
1665            fragments.push((
1666                format!("src/long{i}.ts"),
1667                frag(vec![], vec![], vec![star_barrel(&next)], vec![]),
1668            ));
1669        }
1670        let g = resolve_ts_graph(&fragments, &no_alias());
1671        assert!(
1672            matches!(
1673                g.edges.iter().find(|e| matches!(e, Edge::Call { .. })),
1674                Some(Edge::Call { to_file, .. }) if to_file == "src/x.ts"
1675            ),
1676            "the short chain must still reach src/x.ts: {:?}",
1677            g.edges
1678        );
1679    }
1680
1681    #[test]
1682    fn a_namespace_binding_resolves_only_through_a_member() {
1683        let fragments = vec![
1684            (
1685                "src/a.ts".to_string(),
1686                frag(
1687                    vec![],
1688                    vec![import("./b", 1, &[("ns", "*")])],
1689                    vec![],
1690                    vec![
1691                        TsRef {
1692                            kind: "call".into(),
1693                            name: "ns".into(),
1694                            member: Some("go".into()),
1695                            line: 3,
1696                        },
1697                        call_ref("ns", 4),
1698                    ],
1699                ),
1700            ),
1701            (
1702                "src/b.ts".to_string(),
1703                frag(vec![("go", "function", 1)], vec![], vec![], vec![]),
1704            ),
1705        ];
1706        let g = resolve_ts_graph(&fragments, &no_alias());
1707        assert_eq!(g.edges_by_kind.call, 1);
1708        assert_eq!(g.stats.unresolved_ref_count, 1);
1709    }
1710}