Skip to main content

codingest/builder/
mod.rs

1//! Builder: orchestrates parse → model → load phases.
2
3pub mod call_edges;
4pub mod js_workspace;
5pub mod load;
6pub mod other_edges;
7pub mod routes;
8pub mod semantic_edges;
9pub mod type_edges;
10
11use crate::models::{ParseResult, ProjectInfo, SourceRoot};
12use crate::parsers::{detect_languages, get_parser, language_for_path};
13// builder + load both return `Arc<DirGraph>` (not the pyapi
14// `KnowledgeGraph` wrapper) so this subtree stays engine-only.
15// The pyapi callsite (`code_tree.build()` pyfunction) wraps the
16// result via `KnowledgeGraph::from_arc`.
17use kglite::api::DirGraph;
18use rayon::prelude::*;
19use std::collections::{BTreeMap, BTreeSet, HashSet};
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22use walkdir::WalkDir;
23
24/// Graph node label for a `ClassInfo`, keyed on its `kind` discriminator.
25/// `struct` → `Struct`, `mixin` (Dart) → `Mixin`; everything else →
26/// `Class` (covers `class`, `extension` / `extension_type`, Swift `actor`,
27/// …, all distinguished further by the node's `kind` property).
28///
29/// Single source of truth for the `ClassInfo.kind` → label mapping —
30/// used by node creation and by the DEFINES / HAS_METHOD / IMPLEMENTS /
31/// USES_TYPE edge routers, which must all agree on the endpoint label.
32pub(crate) fn class_node_type(kind: &str) -> &'static str {
33    match kind {
34        "struct" => "Struct",
35        "mixin" => "Mixin",
36        _ => "Class",
37    }
38}
39
40/// Whether `[timing]` diagnostics print: the caller's `verbose` flag OR the
41/// `KGLITE_CODE_TREE_VERBOSE` env var, never one alone.
42///
43/// The two switches reach different call sites — `verbose` is a build
44/// parameter the CLI/Python entry points pass, the env var is the only switch
45/// available to code with no flag in scope (`load_into_graph`'s stage timers,
46/// the CLI's `source_fingerprint`, which also runs from `status` and `query`).
47/// Gating each timer on whichever switch happened to be in scope is what made
48/// the env-var route print an incomplete set — a diagnostic that lies by
49/// omission is worse than one that is off. Every `[timing]` line in this module
50/// goes through here; `verbose` alone still gates the *non*-timing narration.
51///
52/// Cheap enough to call per phase (a `getenv` per build phase, not per file).
53pub(crate) fn timing_enabled(verbose: bool) -> bool {
54    verbose || std::env::var_os("KGLITE_CODE_TREE_VERBOSE").is_some()
55}
56
57/// Public `build()` entry point matching the Python API — returns just the
58/// graph. Thin wrapper over [`run_with_options_stats`]; the stats it drops
59/// are the CALLS-resolution counters consumed by the `code_tree_stats` dev
60/// bin and the re-resolution phases. Single implementation, two entry points.
61#[allow(clippy::too_many_arguments)]
62pub fn run_with_options(
63    input: &Path,
64    verbose: bool,
65    include_tests: bool,
66    save_to: Option<&Path>,
67    max_loc_per_file: Option<usize>,
68    include_docs: bool,
69) -> Result<Arc<DirGraph>, String> {
70    run_with_options_stats(
71        input,
72        verbose,
73        include_tests,
74        save_to,
75        max_loc_per_file,
76        include_docs,
77    )
78    .map(|(graph, _stats)| graph)
79}
80
81/// Full `build()` implementation — also returns [`call_edges::CallResolutionStats`].
82///
83/// Accepts either a directory or an explicit manifest file. When given a
84/// manifest (or when one is auto-detected in the directory) the parser
85/// uses manifest-declared source/test roots; otherwise it falls back to a
86/// recursive directory scan.
87///
88/// `max_loc_per_file`, when set, skips files whose newline count exceeds
89/// the threshold — they get a `FileInfo` with `skip_reason="too_large"`
90/// so paths remain queryable, but no AST is parsed. Useful for repos
91/// like dotnet/runtime where ~80 autogenerated test files (89k LOC each)
92/// dominate parse time without contributing structural information.
93#[allow(clippy::too_many_arguments)]
94pub fn run_with_options_stats(
95    input: &Path,
96    verbose: bool,
97    include_tests: bool,
98    save_to: Option<&Path>,
99    max_loc_per_file: Option<usize>,
100    include_docs: bool,
101) -> Result<(Arc<DirGraph>, call_edges::CallResolutionStats), String> {
102    let input = input.canonicalize().unwrap_or_else(|_| input.to_path_buf());
103
104    // Manifest discovery is a once-per-build cost that no other timer covers —
105    // the first phase timer (`t_parse`) starts only after this returns.
106    let t_manifest = std::time::Instant::now();
107    let (project_root, mut project_info) = if input.is_file() {
108        let project_root = input
109            .parent()
110            .map(PathBuf::from)
111            .unwrap_or_else(|| input.clone());
112        let info =
113            crate::manifest::try_read_manifest_file(&input, &project_root)?.ok_or_else(|| {
114                format!(
115                    "Not a recognised manifest file: {}",
116                    input.file_name().and_then(|o| o.to_str()).unwrap_or(""),
117                )
118            })?;
119        (project_root, Some(info))
120    } else if input.is_dir() {
121        let info = crate::manifest::try_read_manifest(&input)?;
122        (input.clone(), info)
123    } else {
124        return Err(format!("Not a file or directory: {}", input.display()));
125    };
126    if timing_enabled(verbose) {
127        eprintln!(
128            "[timing] manifest discovery: {:.3}s",
129            t_manifest.elapsed().as_secs_f64()
130        );
131    }
132
133    let mut combined = ParseResult::new();
134    let mut parsed_any = false;
135
136    if let Some(info) = &mut project_info {
137        if info.source_roots.is_empty() {
138            // Manifest exists but declared no primary source roots (e.g. a
139            // tooling-only pyproject.toml in a C/C++ repo). Don't parse just
140            // tests — fall through to the whole-repo scan below so the
141            // primary codebase isn't silently skipped.
142            if verbose {
143                eprintln!(
144                    "Manifest: {} ({}) — no source roots declared, scanning whole repo",
145                    info.manifest_path,
146                    info.build_system.as_deref().unwrap_or("")
147                );
148            }
149        } else {
150            let mut roots: Vec<_> = info.source_roots.clone();
151            if include_tests {
152                roots.extend(info.test_roots.iter().cloned());
153            }
154            let roots = collapse_covered_roots(roots);
155            if verbose {
156                eprintln!(
157                    "Manifest: {} ({})",
158                    info.manifest_path,
159                    info.build_system.as_deref().unwrap_or("")
160                );
161                let labels: Vec<String> = roots
162                    .iter()
163                    .map(|r| {
164                        r.path
165                            .strip_prefix(&project_root)
166                            .map(|p| p.display().to_string())
167                            .unwrap_or_else(|_| r.path.display().to_string())
168                    })
169                    .collect();
170                eprintln!("Source roots: {}", labels.join(", "));
171            }
172            let t_parse = std::time::Instant::now();
173            // Every root's files go through ONE dispatch, so the parser pool
174            // is never drained between roots. Root order (and, inside a root,
175            // language then path order) is preserved by `parse_roots`.
176            let walk_dirs: Vec<&Path> = roots
177                .iter()
178                .map(|root| root.path.as_path())
179                .filter(|path| path.is_dir())
180                .collect();
181            if !walk_dirs.is_empty() {
182                let result = parse_roots(&walk_dirs, &project_root, verbose, max_loc_per_file);
183                combined.merge(result);
184                parsed_any = true;
185            }
186            if timing_enabled(verbose) && parsed_any {
187                eprintln!("[timing] parse: {:.3}s", t_parse.elapsed().as_secs_f64());
188            }
189        }
190    }
191
192    if !parsed_any {
193        if !project_root.is_dir() {
194            return Err(format!("Not a directory: {}", project_root.display()));
195        }
196        let t_parse = std::time::Instant::now();
197        let result = parse_roots(
198            &[project_root.as_path()],
199            &project_root,
200            verbose,
201            max_loc_per_file,
202        );
203        combined.merge(result);
204        if timing_enabled(verbose) {
205            eprintln!("[timing] parse: {:.3}s", t_parse.elapsed().as_secs_f64());
206        }
207    }
208
209    if !include_tests {
210        retain_non_test_entities(&mut combined);
211    }
212    if let Some(info) = &mut project_info {
213        reconcile_project_languages(info, &combined);
214    } else {
215        // No supported manifest — infer the project from the root itself, so
216        // the graph still has an owner for its files and docs. Runs AFTER the
217        // parse so the inferred languages are the ones actually found.
218        project_info = infer_project_info(&project_root, &combined);
219    }
220
221    finalize_and_load(
222        combined,
223        project_info,
224        &project_root,
225        include_docs,
226        verbose,
227        save_to,
228    )
229}
230
231/// Remove roots already covered by an ancestor walk. Manifest discovery can
232/// legitimately report both `.` and `tests/`; walking both emits duplicate
233/// entities before graph loading.
234fn collapse_covered_roots(roots: Vec<SourceRoot>) -> Vec<SourceRoot> {
235    let mut kept: Vec<(PathBuf, SourceRoot)> = Vec::new();
236    for root in roots {
237        let path = root
238            .path
239            .canonicalize()
240            .unwrap_or_else(|_| root.path.clone());
241        if kept.iter().any(|(ancestor, _)| path.starts_with(ancestor)) {
242            continue;
243        }
244        kept.retain(|(candidate, _)| !candidate.starts_with(&path));
245        kept.push((path, root));
246    }
247    kept.into_iter().map(|(_, root)| root).collect()
248}
249
250/// Enforce `include_tests=false` after parsing so it also applies to broad
251/// manifest roots and the whole-repository fallback, not only declared test
252/// roots.
253fn retain_non_test_entities(result: &mut ParseResult) {
254    let test_paths: HashSet<String> = result
255        .files
256        .iter()
257        .filter(|file| file.is_test)
258        .map(|file| file.path.clone())
259        .collect();
260    if test_paths.is_empty() {
261        return;
262    }
263
264    let removed_types: HashSet<String> = result
265        .classes
266        .iter()
267        .filter(|item| test_paths.contains(&item.file_path))
268        .map(|item| item.qualified_name.clone())
269        .chain(
270            result
271                .interfaces
272                .iter()
273                .filter(|item| test_paths.contains(&item.file_path))
274                .map(|item| item.qualified_name.clone()),
275        )
276        .collect();
277
278    result.files.retain(|item| !item.is_test);
279    result
280        .functions
281        .retain(|item| !test_paths.contains(&item.file_path));
282    result
283        .classes
284        .retain(|item| !test_paths.contains(&item.file_path));
285    result
286        .enums
287        .retain(|item| !test_paths.contains(&item.file_path));
288    result
289        .interfaces
290        .retain(|item| !test_paths.contains(&item.file_path));
291    result
292        .attributes
293        .retain(|item| !test_paths.contains(&item.file_path));
294    result
295        .constants
296        .retain(|item| !test_paths.contains(&item.file_path));
297    result
298        .elements
299        .retain(|item| !test_paths.contains(&item.file_path));
300    result
301        .selectors
302        .retain(|item| !test_paths.contains(&item.file_path));
303
304    let retained_functions: HashSet<&str> = result
305        .functions
306        .iter()
307        .map(|item| item.qualified_name.as_str())
308        .collect();
309    let retained_constants: HashSet<&str> = result
310        .constants
311        .iter()
312        .map(|item| item.qualified_name.as_str())
313        .collect();
314    result.control_transfers.retain(|site| {
315        retained_functions.contains(site.caller.as_str())
316            && site
317                .target
318                .as_deref()
319                .is_none_or(|target| retained_functions.contains(target))
320    });
321    result.reference_sites.retain(|site| {
322        retained_functions.contains(site.caller.as_str())
323            && retained_constants.contains(site.target.as_str())
324    });
325    result.symbol_relationships.retain(|relationship| {
326        retained_constants.contains(relationship.source.as_str())
327            && match relationship.target_kind {
328                crate::models::SymbolTargetKind::Constant => {
329                    retained_constants.contains(relationship.target.as_str())
330                }
331                crate::models::SymbolTargetKind::Function => {
332                    retained_functions.contains(relationship.target.as_str())
333                }
334            }
335    });
336
337    let retained_types: HashSet<&str> = result
338        .classes
339        .iter()
340        .map(|item| item.qualified_name.as_str())
341        .chain(
342            result
343                .interfaces
344                .iter()
345                .map(|item| item.qualified_name.as_str()),
346        )
347        .collect();
348    result.type_relationships.retain_mut(|relationship| {
349        relationship
350            .methods
351            .retain(|method| !test_paths.contains(&method.file_path));
352        !removed_types.contains(&relationship.source_type)
353            || retained_types.contains(relationship.source_type.as_str())
354    });
355}
356
357/// Manifest-declared languages are useful intent, but the graph metadata must
358/// also describe every language actually retained in the parsed file set.
359/// Synthesize a [`ProjectInfo`] for a repository with no supported manifest.
360///
361/// Only `pyproject.toml` and `Cargo.toml` are recognised manifests, so without
362/// this every other repository — every JS/TS, Go, Java or C++ tree, and any
363/// plain directory — built with no `:Project` node at all: no owner for its
364/// files, and docs floating unattached to anything structural. The inferred
365/// project fills that hole with the two facts the directory can actually
366/// supply: its own name, and the languages the parse found. Everything else
367/// (version, license, dependencies, source roots) stays empty — inference does
368/// not guess metadata it has no source for.
369///
370/// It writes [`INFERRED_MANIFEST`] into `manifest_path` instead of adding an
371/// `inferred` flag: see that constant for why the property set must not grow.
372///
373/// Returns `None` only when the root has no usable directory name (a
374/// filesystem root), where there is nothing to name the project after.
375fn infer_project_info(project_root: &Path, result: &ParseResult) -> Option<ProjectInfo> {
376    let name = project_root.file_name().and_then(|name| name.to_str())?;
377    if name.is_empty() {
378        return None;
379    }
380    let mut info = ProjectInfo {
381        name: name.to_string(),
382        manifest_path: crate::models::INFERRED_MANIFEST.to_string(),
383        ..ProjectInfo::default()
384    };
385    reconcile_project_languages(&mut info, result);
386    Some(info)
387}
388
389fn reconcile_project_languages(project: &mut ProjectInfo, result: &ParseResult) {
390    let present: BTreeSet<&str> = result
391        .files
392        .iter()
393        .map(|file| file.language.as_str())
394        .filter(|language| !language.is_empty())
395        .collect();
396    let mut known: HashSet<String> = project.languages.iter().cloned().collect();
397    for language in crate::parsers::registry::LANGUAGES {
398        if present.contains(language.id) && known.insert(language.id.to_string()) {
399            project.languages.push(language.id.to_string());
400        }
401    }
402    for language in present {
403        if known.insert(language.to_string()) {
404            project.languages.push(language.to_string());
405        }
406    }
407}
408
409/// Walk `walk_dir` once and partition every source file it contains by
410/// language, in the order the parse must see them: languages in `BTreeMap`
411/// order, paths sorted within each language.
412///
413/// The `.h` → C++ routing decision is made here, i.e. **per walked root**: it
414/// answers "does *this* root contain definitively-C++ source", and a root of
415/// pure C headers must keep its `.h` files on the C parser even when a sibling
416/// root in the same project is C++.
417fn collect_files_by_lang(walk_dir: &Path, verbose: bool) -> BTreeMap<&'static str, Vec<PathBuf>> {
418    // One walk, partition by language. The previous implementation walked
419    // `dir` once for `detect_languages` and again per-language inside each
420    // parser's `parse_directory` — N+1 traversals of the same tree. On
421    // dotnet/runtime that was 8 walks of 57k entries; consolidating shaves
422    // ~1–2s off the parse phase before any per-file work begins.
423    let t_walk = std::time::Instant::now();
424    let mut by_lang: BTreeMap<&'static str, Vec<PathBuf>> = BTreeMap::new();
425    // Skip VCS / build-output / virtualenv / package-cache subdirs at
426    // any depth (`.venv`, `target`, `node_modules`, `__pycache__`, …).
427    // Without this, a supplemental source root pointing to a directory
428    // with a nested venv would index every site-package's Python source.
429    for entry in WalkDir::new(walk_dir)
430        .into_iter()
431        .filter_entry(crate::manifest::walk_filter)
432        .filter_map(Result::ok)
433    {
434        if !entry.file_type().is_file() {
435            continue;
436        }
437        if let Some(lang) = language_for_path(entry.path()) {
438            by_lang
439                .entry(lang)
440                .or_default()
441                .push(entry.path().to_path_buf());
442        }
443    }
444
445    // `.h` is ambiguous: C or C++. `language_for_path` maps it to C, but many
446    // C++ engines (kuzu, LevelDB, …) use `.h` for their C++ headers — parsed by
447    // the C grammar, every `class` / `namespace` / `template` is dropped (the
448    // dominant cause of kuzu's empty type layer). If the repo contains any
449    // definitively-C++ source, route its `.h` files to the C++ parser, which is
450    // a superset of C and still extracts any genuine C headers correctly. `.c`
451    // files stay C.
452    if by_lang.contains_key("cpp") {
453        if let Some(c_files) = by_lang.remove("c") {
454            let (h_files, c_only): (Vec<PathBuf>, Vec<PathBuf>) = c_files
455                .into_iter()
456                .partition(|p| p.extension().and_then(|e| e.to_str()) == Some("h"));
457            if !h_files.is_empty() {
458                by_lang.entry("cpp").or_default().extend(h_files);
459            }
460            if !c_only.is_empty() {
461                by_lang.insert("c", c_only);
462            }
463        }
464    }
465
466    // WalkDir follows the host filesystem's directory-entry order, which is
467    // not stable across platforms. Parsing in path order keeps graph node
468    // insertion (and therefore unsorted query results) reproducible.
469    for files in by_lang.values_mut() {
470        files.sort();
471    }
472
473    if verbose {
474        let langs: Vec<&'static str> = by_lang.keys().copied().collect();
475        eprintln!(
476            "  Detected languages in {}: {:?}",
477            walk_dir.display(),
478            langs
479        );
480        for lang in &langs {
481            eprintln!("  Found {} {} files", by_lang[lang].len(), lang);
482        }
483    }
484    if timing_enabled(verbose) {
485        eprintln!("[timing] walk: {:.3}s", t_walk.elapsed().as_secs_f64());
486    }
487
488    by_lang
489}
490
491/// One (source root, language) parse group: how many files of the flat
492/// worklist belong to it, the oversized files skipped without parsing, and
493/// the parser that owns both the per-file parse and the slice's `finalize`.
494struct ParseGroup {
495    parser: Box<dyn crate::parsers::LanguageParser + Send + Sync>,
496    file_count: usize,
497    skipped: Vec<crate::models::FileInfo>,
498}
499
500/// Walk every source root, then parse **all** of their files through a single
501/// parallel dispatch; resulting File-node paths are computed relative to
502/// `project_root`, not to the individual walked root. This matters when
503/// multiple source roots share a common file name at matching depths (e.g.
504/// Cargo workspace crates each with `src/lib.rs`) — keying dedup on a
505/// root-relative `rel_path` would collapse them.
506///
507/// Why one dispatch: parsing used to run a separate `par_iter` per (root,
508/// language) batch, so every batch ended in a join that idled the pool
509/// whenever a batch was smaller than the core count or had one slow file —
510/// paid once per language per root, and worst on the many-small-batches shape
511/// that a multi-root polyglot repo has. One flat worklist keeps every core fed
512/// until the last file is done.
513///
514/// Output is byte-identical to the per-batch form because the *merge* order is
515/// unchanged: results are collected in worklist index order, and the worklist
516/// is built in (root order, `BTreeMap` language order, sorted path order) —
517/// exactly the nesting the old loops walked. Per-language cross-file passes
518/// stay scoped to their own (root, language) slice via `finalize`.
519fn parse_roots(
520    walk_dirs: &[&Path],
521    project_root: &Path,
522    verbose: bool,
523    max_loc_per_file: Option<usize>,
524) -> ParseResult {
525    // Flat worklist: (index of the owning group, file to parse).
526    let mut worklist: Vec<(usize, PathBuf)> = Vec::new();
527    let mut groups: Vec<ParseGroup> = Vec::new();
528
529    for walk_dir in walk_dirs {
530        for (lang, files) in collect_files_by_lang(walk_dir, verbose) {
531            let Some(parser) = get_parser(lang) else {
532                continue;
533            };
534            // Optional pre-filter: split files whose newline count exceeds
535            // `max_loc_per_file` into a "skipped" pile that's recorded as
536            // FileInfo without invoking the parser.
537            let (to_parse, skipped) = match max_loc_per_file {
538                Some(threshold) => prefilter_oversized(&files, threshold, project_root, lang),
539                None => (files, Vec::new()),
540            };
541            if verbose && !skipped.is_empty() {
542                eprintln!(
543                    "  Skipped {} {} files over max_loc_per_file (threshold {})",
544                    skipped.len(),
545                    lang,
546                    max_loc_per_file.unwrap_or(0)
547                );
548            }
549            let group_index = groups.len();
550            groups.push(ParseGroup {
551                parser,
552                file_count: to_parse.len(),
553                skipped,
554            });
555            worklist.extend(to_parse.into_iter().map(|file| (group_index, file)));
556        }
557    }
558
559    let t_dispatch = std::time::Instant::now();
560    // `collect` on an indexed parallel iterator preserves worklist order, so
561    // the per-file results line up with the groups' contiguous spans.
562    let per_file: Vec<ParseResult> = crate::parsers::parser_pool().install(|| {
563        worklist
564            .par_iter()
565            .map(|(group_index, file)| groups[*group_index].parser.parse_file(file, project_root))
566            .collect()
567    });
568    if timing_enabled(verbose) {
569        eprintln!(
570            "[timing] parse dispatch: {:.3}s ({} files)",
571            t_dispatch.elapsed().as_secs_f64(),
572            worklist.len()
573        );
574    }
575
576    let mut results = per_file.into_iter();
577    let mut combined = ParseResult::new();
578    for group in groups {
579        let slice: Vec<ParseResult> = results.by_ref().take(group.file_count).collect();
580        let mut result = group.parser.finalize(slice);
581        result.files.extend(group.skipped);
582        combined.merge(result);
583    }
584    combined
585}
586
587/// Split a slice of file paths into (under-threshold, oversized-skipped).
588/// For each oversized file, build a synthetic [`FileInfo`] with
589/// `skip_reason = "too_large"` so the caller can record it without
590/// invoking the parser. Counts newlines via a single read (the byte-size
591/// pre-filter avoids reading files that can't possibly exceed the
592/// threshold — a file with fewer bytes than the LOC cap can't have more
593/// lines than the cap).
594fn prefilter_oversized(
595    files: &[PathBuf],
596    threshold: usize,
597    src_root: &Path,
598    language: &str,
599) -> (Vec<PathBuf>, Vec<crate::models::FileInfo>) {
600    use std::io::{BufRead, BufReader};
601    let mut to_parse = Vec::with_capacity(files.len());
602    let mut skipped = Vec::new();
603    for fp in files {
604        // Cheap byte-size pre-filter: a file with fewer bytes than
605        // `threshold` cannot have more newlines than `threshold`.
606        let size_bytes = std::fs::metadata(fp).map(|m| m.len() as usize).unwrap_or(0);
607        if size_bytes <= threshold {
608            to_parse.push(fp.clone());
609            continue;
610        }
611        // Accurate newline count: bail as soon as we exceed the threshold.
612        let Ok(file) = std::fs::File::open(fp) else {
613            to_parse.push(fp.clone());
614            continue;
615        };
616        let mut reader = BufReader::new(file);
617        let mut buf = Vec::new();
618        let mut loc: usize = 0;
619        let mut over = false;
620        while let Ok(n) = reader.read_until(b'\n', &mut buf) {
621            if n == 0 {
622                break;
623            }
624            loc += 1;
625            buf.clear();
626            if loc > threshold {
627                over = true;
628                break;
629            }
630        }
631        if over {
632            // Drain remaining lines to get a final count. Bounded by
633            // file size, so this is O(file_size) with no parsing.
634            while let Ok(n) = reader.read_until(b'\n', &mut buf) {
635                if n == 0 {
636                    break;
637                }
638                loc += 1;
639                buf.clear();
640            }
641            let rel_path = fp.strip_prefix(src_root).unwrap_or(fp);
642            let filename = fp
643                .file_name()
644                .and_then(|s| s.to_str())
645                .unwrap_or("")
646                .to_string();
647            skipped.push(crate::models::FileInfo {
648                path: rel_path.display().to_string(),
649                filename,
650                loc: loc as u32,
651                module_path: String::new(),
652                language: language.to_string(),
653                submodule_declarations: Vec::new(),
654                imports: Vec::new(),
655                exports: Vec::new(),
656                annotations: None,
657                is_test: false,
658                skip_reason: Some("too_large".into()),
659            });
660        } else {
661            to_parse.push(fp.clone());
662        }
663    }
664    (to_parse, skipped)
665}
666
667#[allow(clippy::too_many_arguments)]
668fn finalize_and_load(
669    mut combined: ParseResult,
670    project_info: Option<crate::models::ProjectInfo>,
671    project_root: &Path,
672    include_docs: bool,
673    verbose: bool,
674    save_to: Option<&Path>,
675) -> Result<(Arc<DirGraph>, call_edges::CallResolutionStats), String> {
676    if verbose {
677        eprintln!(
678            "Parsed: {} files, {} functions, {} classes, {} enums, {} interfaces, {} attributes, {} constants",
679            combined.files.len(),
680            combined.functions.len(),
681            combined.classes.len(),
682            combined.enums.len(),
683            combined.interfaces.len(),
684            combined.attributes.len(),
685            combined.constants.len()
686        );
687    }
688
689    let t_dedup = std::time::Instant::now();
690    dedup_by_key(&mut combined.files, |f| f.path.clone());
691    preserve_overloaded_function_ids(&mut combined.functions, &mut combined.type_relationships);
692    dedup_by_key(&mut combined.functions, |f| f.qualified_name.clone());
693    dedup_by_key(&mut combined.classes, |c| c.qualified_name.clone());
694    dedup_by_key(&mut combined.enums, |e| e.qualified_name.clone());
695    dedup_by_key(&mut combined.interfaces, |i| i.qualified_name.clone());
696    dedup_by_key(&mut combined.constants, |c| c.qualified_name.clone());
697    combined.control_transfers.sort();
698    combined.control_transfers.dedup();
699    combined.reference_sites.sort();
700    combined.reference_sites.dedup();
701    combined.symbol_relationships.sort();
702    combined.symbol_relationships.dedup();
703    if timing_enabled(verbose) {
704        eprintln!("[timing] dedup: {:.3}s", t_dedup.elapsed().as_secs_f64());
705    }
706
707    let t_load = std::time::Instant::now();
708    // tsconfig `paths` + workspace-package table. Discovered only when the
709    // parse actually produced TS/JS sources, so no other ecosystem pays for
710    // the extra small-file walk.
711    let t_ws = std::time::Instant::now();
712    let js_workspace = if combined
713        .files
714        .iter()
715        .any(|f| crate::parsers::registry::uses_module_path_imports(&f.language))
716    {
717        js_workspace::JsWorkspace::discover(project_root)
718    } else {
719        js_workspace::JsWorkspace::default()
720    };
721    if timing_enabled(verbose) {
722        eprintln!(
723            "[timing] js workspace discovery: {:.3}s",
724            t_ws.elapsed().as_secs_f64()
725        );
726    }
727    // `mut` is consumed by the cross-language pass below (and the okf docs pass).
728    let (mut graph, call_stats) =
729        load::load_into_graph(&combined, project_info.as_ref(), &js_workspace)?;
730    if timing_enabled(verbose) {
731        eprintln!("[timing] load: {:.3}s", t_load.elapsed().as_secs_f64());
732    }
733
734    // Optional docs pass: ingest the repo's markdown as `:Doc` nodes and link
735    // them to the code symbols they mention. Runs after all code nodes exist so
736    // symbol resolution can find them. Reuses the OKF parser (gated `okf`).
737    if include_docs {
738        let t_docs = std::time::Instant::now();
739        #[cfg(feature = "docs")]
740        {
741            let g =
742                std::sync::Arc::get_mut(&mut graph).expect("graph is uniquely owned during build");
743            crate::docs::ingest_and_link(
744                g,
745                project_root,
746                project_info.as_ref().map(|info| info.name.as_str()),
747                verbose,
748            )?;
749            if timing_enabled(verbose) {
750                eprintln!("[timing] docs: {:.3}s", t_docs.elapsed().as_secs_f64());
751            }
752        }
753        #[cfg(not(feature = "docs"))]
754        {
755            let _ = (project_root, t_docs);
756            return Err("include_docs=true requires the 'okf' Cargo feature".into());
757        }
758    }
759
760    // Cross-language HTTP boundary edges: link client HTTP calls to the server
761    // Route nodes they target (by normalized path), so impact analysis crosses
762    // the client/server (and language) boundary. Runs after load so Route nodes
763    // exist; a no-op when the graph has none.
764    {
765        let g = std::sync::Arc::get_mut(&mut graph).expect("graph is uniquely owned during build");
766        let t_xlang = std::time::Instant::now();
767        crate::cross_lang::ingest_http_cross_edges(g, project_root, verbose)?;
768        if timing_enabled(verbose) {
769            eprintln!(
770                "[timing] cross-lang: {:.3}s",
771                t_xlang.elapsed().as_secs_f64()
772            );
773        }
774    }
775
776    if let Some(dest) = save_to {
777        // Everything a `.kgl` write needs done before its bytes exist: the
778        // metadata stamp plus the column-consolidation pass. Without it,
779        // property column stores aren't materialised before serialisation and
780        // only `id`/`title`/`type` survive the round-trip.
781        //
782        // This was open-coded as `prepare_save` + `Arc::make_mut(..)
783        // .enable_columnar()` until kglite 0.16.0, which made
784        // `enable_columnar` crate-internal and published this as the one
785        // route. Not merely a rename: `prepare_kgl_write` preserves the
786        // graph's copy-on-write lineage across the mutation, which the
787        // `Arc::make_mut` spelling did not.
788        let mut graph = graph;
789        kglite::api::io::prepare_kgl_write(&mut graph);
790        let dest_str = dest.to_string_lossy();
791        kglite::api::io::write_kgl(&graph, &dest_str).map_err(|e| e.to_string())?;
792        return Ok((graph, call_stats));
793    }
794    Ok((graph, call_stats))
795}
796
797/// Legacy entry — directory-only, used by the initial smoke test.
798pub fn run(src_dir: &Path, verbose: bool) -> Result<Arc<DirGraph>, String> {
799    let mut combined = ParseResult::new();
800    let languages = detect_languages(src_dir);
801    if verbose {
802        eprintln!("Detected languages: {:?}", languages);
803    }
804    for lang in languages {
805        let Some(parser) = get_parser(lang) else {
806            if verbose {
807                eprintln!("  (no Rust parser yet for {lang})");
808            }
809            continue;
810        };
811        if verbose {
812            eprintln!("Parsing {} files...", lang);
813        }
814        let result = parser.parse_directory(src_dir, verbose);
815        combined.merge(result);
816    }
817
818    // Dedup — overlapping source/test roots can parse the same file twice.
819    // Last-seen wins so test-root flags take priority (matches builder.py).
820    dedup_by_key(&mut combined.files, |f| f.path.clone());
821    dedup_by_key(&mut combined.functions, |f| f.qualified_name.clone());
822    dedup_by_key(&mut combined.classes, |c| c.qualified_name.clone());
823    dedup_by_key(&mut combined.enums, |e| e.qualified_name.clone());
824    dedup_by_key(&mut combined.interfaces, |i| i.qualified_name.clone());
825    dedup_by_key(&mut combined.constants, |c| c.qualified_name.clone());
826    combined.control_transfers.sort();
827    combined.control_transfers.dedup();
828    combined.reference_sites.sort();
829    combined.reference_sites.dedup();
830    combined.symbol_relationships.sort();
831    combined.symbol_relationships.dedup();
832
833    if verbose {
834        eprintln!(
835            "Parsed: {} files, {} functions, {} classes, {} enums, {} interfaces, {} attributes, {} constants",
836            combined.files.len(),
837            combined.functions.len(),
838            combined.classes.len(),
839            combined.enums.len(),
840            combined.interfaces.len(),
841            combined.attributes.len(),
842            combined.constants.len()
843        );
844    }
845
846    load::load_into_graph(&combined, None, &js_workspace::JsWorkspace::default())
847        .map(|(graph, _stats)| graph)
848}
849
850/// Keep the last occurrence of each key, preserving encounter order otherwise.
851fn dedup_by_key<T, K, F>(items: &mut Vec<T>, mut key: F)
852where
853    K: Eq + std::hash::Hash,
854    F: FnMut(&T) -> K,
855{
856    let mut seen: std::collections::HashMap<K, usize> = std::collections::HashMap::new();
857    for (idx, item) in items.iter().enumerate() {
858        seen.insert(key(item), idx);
859    }
860    if seen.len() == items.len() {
861        return;
862    }
863    let mut keep: Vec<usize> = seen.into_values().collect();
864    keep.sort_unstable();
865    let mut out: Vec<T> = Vec::with_capacity(keep.len());
866    for (idx, item) in std::mem::take(items).into_iter().enumerate() {
867        if keep.binary_search(&idx).is_ok() {
868            out.push(item);
869        }
870    }
871    *items = out;
872}
873
874const OVERLOAD_ID_SEPARATOR: char = '#';
875const SHA256_HEX_LEN: usize = 64;
876
877/// Preserve same-scope overloads without changing any ordinary function ID.
878///
879/// Parsers can legitimately emit several functions with the same qualified
880/// name. Exact `(qualified_name, signature)` duplicates are still parser
881/// duplicates and retain the builder's established last-wins behavior. Only a
882/// base ID with more than one distinct signature is decorated.
883fn preserve_overloaded_function_ids(
884    functions: &mut Vec<crate::models::FunctionInfo>,
885    type_relationships: &mut [crate::models::TypeRelationship],
886) {
887    dedup_by_key(functions, |function| {
888        (function.qualified_name.clone(), function.signature.clone())
889    });
890
891    let mut signatures_by_id: std::collections::HashMap<&str, HashSet<&str>> =
892        std::collections::HashMap::new();
893    for function in functions.iter() {
894        signatures_by_id
895            .entry(function.qualified_name.as_str())
896            .or_default()
897            .insert(function.signature.as_str());
898    }
899    let overloaded: HashSet<String> = signatures_by_id
900        .into_iter()
901        .filter_map(|(qualified_name, signatures)| {
902            (signatures.len() > 1).then(|| qualified_name.to_string())
903        })
904        .collect();
905    if overloaded.is_empty() {
906        return;
907    }
908
909    let replacements: std::collections::HashMap<(String, String), String> = functions
910        .iter_mut()
911        .filter(|function| overloaded.contains(function.qualified_name.as_str()))
912        .map(|function| {
913            let base = function.qualified_name.clone();
914            let signature = function.signature.clone();
915            let qualified_name = overloaded_qualified_name(&base, &signature);
916            function.qualified_name.clone_from(&qualified_name);
917            ((base, signature), qualified_name)
918        })
919        .collect();
920
921    for relationship in type_relationships {
922        for method in &mut relationship.methods {
923            if let Some(qualified_name) =
924                replacements.get(&(method.qualified_name.clone(), method.signature.clone()))
925            {
926                method.qualified_name.clone_from(qualified_name);
927            }
928        }
929    }
930}
931
932fn overloaded_qualified_name(base: &str, signature: &str) -> String {
933    use sha2::{Digest, Sha256};
934
935    let discriminator: String = Sha256::digest(signature)
936        .iter()
937        .map(|byte| format!("{byte:02x}"))
938        .collect();
939    format!("{base}{OVERLOAD_ID_SEPARATOR}{discriminator}")
940}
941
942pub(super) fn overload_base_qualified_name(qualified_name: &str) -> Option<&str> {
943    let (base, discriminator) = qualified_name.rsplit_once(OVERLOAD_ID_SEPARATOR)?;
944    (discriminator.len() == SHA256_HEX_LEN
945        && discriminator.bytes().all(|byte| byte.is_ascii_hexdigit()))
946    .then_some(base)
947}
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use crate::models::{ClassInfo, FileInfo, FunctionInfo, TypeRelationship};
953    use crate::parsers::get_parser;
954    use kglite::api::GraphRead;
955    use kglite::datatypes::Value;
956
957    /// `parse_roots` dispatches every root's files through one flat parallel
958    /// worklist, but a parser's cross-file pass must still see only its own
959    /// (root, language) slice — merging the roots before `finalize` would
960    /// widen what that pass can resolve and change the graph.
961    ///
962    /// AGC is the parser that has such a pass. Both files below sit in the
963    /// same AGC *program* (`src`, the first path component under the project
964    /// root), so nothing but the slice boundary stops `HELPER` from resolving
965    /// across the two roots: under a single merged finalize it would be
966    /// promoted to `role_hint = routine` by the call in the other root.
967    #[test]
968    fn agc_cross_file_pass_stays_scoped_to_one_source_root() {
969        let temp = tempfile::tempdir().expect("tempdir");
970        let project = temp.path().join("project");
971        let root_a = project.join("src/a");
972        let root_b = project.join("src/b");
973        std::fs::create_dir_all(&root_a).expect("create root a");
974        std::fs::create_dir_all(&root_b).expect("create root b");
975        std::fs::write(root_a.join("MAIN.agc"), "CALLER TC HELPER\n").expect("write caller");
976        std::fs::write(root_b.join("HELP.agc"), "HELPER TC Q\n").expect("write helper");
977
978        let split = parse_roots(&[root_a.as_path(), root_b.as_path()], &project, false, None);
979        let helper = split
980            .functions
981            .iter()
982            .find(|function| function.qualified_name == "src.HELPER")
983            .expect("helper parsed");
984        assert!(
985            !helper.metadata.contains_key("role_hint"),
986            "AGC finalize resolved a call across two source roots: {:?}",
987            helper.metadata
988        );
989
990        // Positive control: with both files in ONE root, the same pass does
991        // promote the callee — so the assertion above pins the scope, not a
992        // pass that stopped working.
993        std::fs::rename(root_b.join("HELP.agc"), root_a.join("HELP.agc")).expect("move helper");
994        let single = parse_roots(&[root_a.as_path()], &project, false, None);
995        let helper = single
996            .functions
997            .iter()
998            .find(|function| function.qualified_name == "src.HELPER")
999            .expect("helper parsed");
1000        assert_eq!(helper.metadata["role_hint"], "routine");
1001    }
1002
1003    #[test]
1004    fn overlapping_roots_collapse_to_the_covering_ancestor() {
1005        let temp = tempfile::tempdir().expect("tempdir");
1006        let project = temp.path().join("project");
1007        std::fs::create_dir_all(project.join("tests")).expect("create tests dir");
1008        std::fs::write(
1009            project.join("pyproject.toml"),
1010            "[project]\nname = \"demo\"\n[tool.setuptools.packages.find]\nwhere = [\".\"]\n",
1011        )
1012        .expect("write manifest");
1013        let info = crate::manifest::try_read_manifest(&project)
1014            .expect("read manifest")
1015            .expect("manifest exists");
1016        assert_eq!(info.source_roots.len(), 1);
1017        assert_eq!(info.test_roots.len(), 1);
1018
1019        let collapsed = collapse_covered_roots(
1020            info.source_roots
1021                .into_iter()
1022                .chain(info.test_roots)
1023                .collect(),
1024        );
1025        assert_eq!(collapsed.len(), 1);
1026        assert_eq!(collapsed[0].label.as_deref(), Some("setuptools-find"));
1027    }
1028
1029    #[test]
1030    fn test_filter_removes_test_files_and_every_owned_entity() {
1031        let source = FileInfo {
1032            path: "src/app.py".into(),
1033            language: "python".into(),
1034            ..FileInfo::default()
1035        };
1036        let test = FileInfo {
1037            path: "tests/test_app.py".into(),
1038            language: "python".into(),
1039            is_test: true,
1040            ..FileInfo::default()
1041        };
1042        let source_fn = FunctionInfo {
1043            qualified_name: "app.run".into(),
1044            file_path: source.path.clone(),
1045            ..FunctionInfo::default()
1046        };
1047        let test_fn = FunctionInfo {
1048            qualified_name: "test_app.test_run".into(),
1049            file_path: test.path.clone(),
1050            ..FunctionInfo::default()
1051        };
1052        let test_class = ClassInfo {
1053            qualified_name: "test_app.Case".into(),
1054            file_path: test.path.clone(),
1055            ..ClassInfo::default()
1056        };
1057        let mut result = ParseResult {
1058            files: vec![source, test],
1059            functions: vec![source_fn, test_fn.clone()],
1060            classes: vec![test_class],
1061            type_relationships: vec![TypeRelationship {
1062                source_type: "test_app.Case".into(),
1063                relationship: "inherent".into(),
1064                methods: vec![test_fn],
1065                ..TypeRelationship::default()
1066            }],
1067            ..ParseResult::default()
1068        };
1069
1070        retain_non_test_entities(&mut result);
1071        assert_eq!(result.files.len(), 1);
1072        assert_eq!(result.functions.len(), 1);
1073        assert!(result.classes.is_empty());
1074        assert!(result.type_relationships.is_empty());
1075    }
1076
1077    #[test]
1078    fn fallback_scan_honours_include_tests_false() {
1079        let temp = tempfile::tempdir().expect("tempdir");
1080        let project = temp.path().join("project");
1081        std::fs::create_dir_all(project.join("tests")).expect("create tests dir");
1082        std::fs::write(project.join("app.py"), "def run():\n    return 1\n").expect("write source");
1083        std::fs::write(
1084            project.join("tests/test_app.py"),
1085            "def test_run():\n    assert True\n",
1086        )
1087        .expect("write test");
1088
1089        let graph = run_with_options(&project, false, false, None, None, false)
1090            .expect("build fallback project");
1091        let nodes: Vec<(String, String)> = graph
1092            .graph
1093            .node_indices()
1094            .filter_map(|index| graph.node_view(index))
1095            .map(|node| {
1096                (
1097                    node.node_type_str(&graph.interner).to_string(),
1098                    node.id().to_string(),
1099                )
1100            })
1101            .collect();
1102        assert!(
1103            nodes
1104                .iter()
1105                .any(|(kind, id)| kind == "File" && id.contains("app.py")),
1106            "{nodes:?}"
1107        );
1108        assert!(nodes.iter().all(|(_, id)| !id.contains("test_app")));
1109    }
1110
1111    /// `WalkDir::filter_entry` applies its predicate to the ROOT entry too, so
1112    /// a `walk_filter` that pruned ignore-listed names at every depth pruned
1113    /// the walk root as well: building *at* `.hidden/`, `target/`, `venv/` or
1114    /// `node_modules/` yielded an empty graph that still reported success.
1115    /// Every such root must now produce the same graph as an ordinary one.
1116    #[test]
1117    fn build_rooted_at_an_ignored_dir_name_is_not_empty() {
1118        const SOURCE: &str = "def run():\n    return 1\n";
1119
1120        // With no manifest, the fallback scan derives Python module names from
1121        // the root directory's own name, so ids differ by construction between
1122        // two differently-named roots. Normalize that one name away; everything
1123        // else must match the ordinary root exactly. The module namer trims a
1124        // dot-prefixed root's leading dot (`.hidden` names package `hidden`),
1125        // so ids may carry either spelling — normalize the raw name first
1126        // (project-level ids), then the trimmed one (module-derived ids).
1127        fn node_set(root: &Path) -> Vec<(String, String)> {
1128            std::fs::create_dir_all(root).expect("create root");
1129            std::fs::write(root.join("app.py"), SOURCE).expect("write source");
1130            let graph =
1131                run_with_options(root, false, false, None, None, false).expect("build project");
1132            let base = root
1133                .file_name()
1134                .and_then(|n| n.to_str())
1135                .expect("root basename");
1136            let trimmed = base.trim_start_matches('.');
1137            let mut nodes: Vec<(String, String)> = graph
1138                .graph
1139                .node_indices()
1140                .filter_map(|index| graph.node_view(index))
1141                .map(|node| {
1142                    (
1143                        node.node_type_str(&graph.interner).to_string(),
1144                        node.id()
1145                            .to_string()
1146                            .replace(base, "plain")
1147                            .replace(trimmed, "plain"),
1148                    )
1149                })
1150                .collect();
1151            nodes.sort();
1152            nodes
1153        }
1154
1155        let temp = tempfile::tempdir().expect("tempdir");
1156        let baseline = node_set(&temp.path().join("plain"));
1157        assert!(
1158            baseline
1159                .iter()
1160                .any(|(kind, id)| kind == "File" && id.contains("app.py")),
1161            "baseline build must see the source file: {baseline:?}"
1162        );
1163
1164        for name in [".hidden", "target", "venv", "node_modules"] {
1165            let root = temp.path().join(name);
1166            // The probe is only meaningful if we really built at the odd root.
1167            assert!(crate::manifest::is_ignored_dir_name(name), "{name}");
1168            let nodes = node_set(&root);
1169            assert!(
1170                !nodes.is_empty(),
1171                "build rooted at `{name}` produced an EMPTY graph"
1172            );
1173            // A dot-prefixed root once yielded one extra empty-id `Module`:
1174            // the module path was `.hidden.app`, whose leading `.` split into
1175            // an empty first segment. The namer now trims the leading dot
1176            // (`hidden.app`), so EVERY odd root — dot-prefixed included —
1177            // must produce exactly the ordinary root's node set.
1178            assert!(
1179                !nodes
1180                    .iter()
1181                    .any(|(kind, id)| kind == "Module" && id == "\"\""),
1182                "build rooted at `{name}` synthesized an empty-id Module node"
1183            );
1184            assert_eq!(
1185                nodes, baseline,
1186                "build rooted at `{name}` differs from an ordinary root"
1187            );
1188        }
1189    }
1190
1191    #[test]
1192    fn malformed_manifest_fails_directory_and_explicit_file_builds() {
1193        let temp = tempfile::tempdir().expect("tempdir");
1194        let project = temp.path().join("project");
1195        std::fs::create_dir(&project).expect("create project");
1196        let manifest = project.join("pyproject.toml");
1197        std::fs::write(&manifest, "[project\nname = broken").expect("write manifest");
1198
1199        for input in [&project, &manifest] {
1200            let error = match run_with_options(input, false, false, None, None, false) {
1201                Ok(_) => panic!("malformed manifest must fail"),
1202                Err(error) => error,
1203            };
1204            assert!(error.contains("pyproject.toml"), "{error}");
1205        }
1206    }
1207
1208    #[test]
1209    fn project_languages_include_parsed_polyglot_files_in_registry_order() {
1210        let temp = tempfile::tempdir().expect("tempdir");
1211        let project = temp.path().join("project");
1212        std::fs::create_dir_all(project.join("demo")).expect("create Python package");
1213        std::fs::write(
1214            project.join("pyproject.toml"),
1215            "[project]\nname = \"demo\"\n[tool.setuptools.packages.find]\nwhere = [\".\"]\n",
1216        )
1217        .expect("write manifest");
1218        std::fs::write(
1219            project.join("demo/__init__.py"),
1220            "def run():\n    return 1\n",
1221        )
1222        .expect("write Python");
1223        std::fs::write(
1224            project.join("app.ts"),
1225            "export function render() { return 1; }\n",
1226        )
1227        .expect("write TypeScript");
1228        std::fs::write(project.join("style.css"), ".app { color: red; }\n").expect("write CSS");
1229
1230        let graph = run_with_options(&project, false, false, None, None, false)
1231            .expect("build polyglot project");
1232        let languages = graph
1233            .graph
1234            .node_indices()
1235            .filter_map(|index| graph.node_view(index))
1236            .find(|node| node.node_type_str(&graph.interner) == "Project")
1237            .and_then(|node| node.get_property("languages"))
1238            .expect("Project.languages");
1239        assert_eq!(
1240            languages.as_ref(),
1241            &Value::String("python, typescript, css".into())
1242        );
1243    }
1244
1245    #[test]
1246    fn fallback_parsers_keep_nested_same_stem_module_identity() {
1247        let temp = tempfile::tempdir().expect("tempdir");
1248        let project = temp.path().join("project");
1249        for dir in ["a", "b"] {
1250            std::fs::create_dir_all(project.join(dir)).expect("create fixture dir");
1251        }
1252        let fixtures = [
1253            ("html", "html", "<!doctype html><h1>Title</h1>\n", "."),
1254            ("css", "css", ".item { color: red; }\n", "."),
1255            ("php", "php", "<?php function run() { return 1; }\n", "\\"),
1256            ("swift", "swift", "func run() -> Int { return 1 }\n", "."),
1257            ("dart", "dart", "int run() { return 1; }\n", "."),
1258        ];
1259        for (language, extension, source, separator) in fixtures {
1260            for dir in ["a", "b"] {
1261                std::fs::write(project.join(format!("{dir}/index.{extension}")), source)
1262                    .expect("write fixture");
1263            }
1264            let result = get_parser(language)
1265                .expect("registered parser")
1266                .parse_directory(&project, false);
1267            let mut modules: Vec<_> = result
1268                .files
1269                .iter()
1270                .filter(|file| file.filename == format!("index.{extension}"))
1271                .map(|file| file.module_path.clone())
1272                .collect();
1273            modules.sort();
1274            assert_eq!(
1275                modules,
1276                [
1277                    format!("project{separator}a{separator}index"),
1278                    format!("project{separator}b{separator}index"),
1279                ],
1280                "{language}"
1281            );
1282
1283            let leaf_modules: HashSet<_> = load::build_modules(&result.files)
1284                .into_iter()
1285                .map(|module| module.qualified_name)
1286                .collect();
1287            assert!(modules.iter().all(|module| leaf_modules.contains(module)));
1288            let contains = other_edges::build_module_contains_file_edges(&result.files);
1289            assert!(modules
1290                .iter()
1291                .all(|module| contains.iter().any(|edge| &edge.module == module)));
1292        }
1293    }
1294
1295    #[test]
1296    fn nested_html_and_css_imports_resolve_to_sibling_directories() {
1297        let temp = tempfile::tempdir().expect("tempdir");
1298        let project = temp.path().join("project");
1299        for dir in ["a", "b"] {
1300            std::fs::create_dir_all(project.join(dir)).expect("create fixture dir");
1301            std::fs::write(
1302                project.join(format!("{dir}/index.html")),
1303                "<!doctype html><link rel=\"stylesheet\" href=\"theme.css\">\n",
1304            )
1305            .expect("write HTML");
1306            std::fs::write(
1307                project.join(format!("{dir}/index.css")),
1308                "@import \"theme.css\";\n.item { color: red; }\n",
1309            )
1310            .expect("write CSS index");
1311            std::fs::write(
1312                project.join(format!("{dir}/theme.css")),
1313                ".theme { color: blue; }\n",
1314            )
1315            .expect("write CSS target");
1316        }
1317
1318        let mut result = get_parser("html")
1319            .expect("HTML parser")
1320            .parse_directory(&project, false);
1321        result.merge(
1322            get_parser("css")
1323                .expect("CSS parser")
1324                .parse_directory(&project, false),
1325        );
1326        let known_modules: HashSet<_> = result
1327            .files
1328            .iter()
1329            .map(|file| file.module_path.clone())
1330            .collect();
1331        let module_pairs: HashSet<_> = other_edges::build_import_edges(
1332            &result.files,
1333            &known_modules,
1334            &js_workspace::JsWorkspace::default(),
1335        )
1336        .into_iter()
1337        .map(|edge| (edge.file_path, edge.module))
1338        .collect();
1339        let module_to_file = result
1340            .files
1341            .iter()
1342            .map(|file| (file.module_path.clone(), file.path.clone()))
1343            .collect();
1344        let file_pairs: HashSet<_> = other_edges::build_file_import_edges(
1345            &result.files,
1346            &module_to_file,
1347            &js_workspace::JsWorkspace::default(),
1348        )
1349        .into_iter()
1350        .map(|edge| (edge.source, edge.target))
1351        .collect();
1352
1353        for dir in ["a", "b"] {
1354            for source in [format!("{dir}/index.html"), format!("{dir}/index.css")] {
1355                assert!(module_pairs.contains(&(source.clone(), format!("project.{dir}.theme"))));
1356                assert!(file_pairs.contains(&(source, format!("{dir}/theme.css"))));
1357            }
1358        }
1359    }
1360
1361    /// Dart import resolution end to end: a same-package `package:` URI
1362    /// keeps its directory segments (`a/x.dart` vs `b/x.dart` land on two
1363    /// distinct files), a relative URI resolves against the importing
1364    /// file's directory via the path-import route, and a `dart:` SDK
1365    /// import produces no edge (mcp-servers report 2026-08-14, finding 7).
1366    #[test]
1367    fn dart_imports_resolve_with_directory_structure() {
1368        let temp = tempfile::tempdir().expect("tempdir");
1369        let project = temp.path().join("dart_import");
1370        std::fs::create_dir_all(project.join("lib/a")).expect("create lib/a");
1371        std::fs::create_dir_all(project.join("lib/b")).expect("create lib/b");
1372        std::fs::write(
1373            project.join("lib/main.dart"),
1374            "import 'dart:core';\n\
1375             import 'package:dart_import/a/x.dart';\n\
1376             import 'package:dart_import/b/x.dart' as bx;\n\
1377             import 'a/x.dart' show aValue;\n\
1378             int combine() { return aValue() + bx.bValue(); }\n",
1379        )
1380        .expect("write main");
1381        std::fs::write(
1382            project.join("lib/a/x.dart"),
1383            "int aValue() { return 10; }\n",
1384        )
1385        .expect("write a/x");
1386        std::fs::write(
1387            project.join("lib/b/x.dart"),
1388            "int bValue() { return 20; }\n",
1389        )
1390        .expect("write b/x");
1391
1392        let result = get_parser("dart")
1393            .expect("Dart parser")
1394            .parse_directory(&project, false);
1395        let known_modules: HashSet<_> = result
1396            .files
1397            .iter()
1398            .map(|file| file.module_path.clone())
1399            .collect();
1400        let module_pairs: Vec<_> = other_edges::build_import_edges(
1401            &result.files,
1402            &known_modules,
1403            &js_workspace::JsWorkspace::default(),
1404        )
1405        .into_iter()
1406        .map(|edge| (edge.file_path, edge.module))
1407        .collect();
1408        // Package URIs land in the module coordinates the files are
1409        // stamped with; the relative `a/x.dart` resolves there too.
1410        assert_eq!(
1411            module_pairs
1412                .iter()
1413                .filter(|(f, m)| f == "lib/main.dart" && m == "dart_import.lib.a.x")
1414                .count(),
1415            2,
1416            "package + relative imports of a/x: {module_pairs:?}"
1417        );
1418        assert_eq!(
1419            module_pairs
1420                .iter()
1421                .filter(|(f, m)| f == "lib/main.dart" && m == "dart_import.lib.b.x")
1422                .count(),
1423            1,
1424            "package import of b/x: {module_pairs:?}"
1425        );
1426        // `dart:core` resolves to nothing — three resolved rows total, and
1427        // in particular no walk-shortened edge onto a bare ancestor module.
1428        assert_eq!(module_pairs.len(), 3, "{module_pairs:?}");
1429
1430        let module_to_file = result
1431            .files
1432            .iter()
1433            .map(|file| (file.module_path.clone(), file.path.clone()))
1434            .collect();
1435        let file_edges: Vec<_> = other_edges::build_file_import_edges(
1436            &result.files,
1437            &module_to_file,
1438            &js_workspace::JsWorkspace::default(),
1439        )
1440        .into_iter()
1441        .map(|edge| (edge.source, edge.target, edge.import_count))
1442        .collect();
1443        // The collision pair becomes TWO distinct File→File edges; the
1444        // relative import aggregates into a/x's count.
1445        assert_eq!(
1446            file_edges,
1447            vec![
1448                (
1449                    "lib/main.dart".to_string(),
1450                    "lib/a/x.dart".to_string(),
1451                    2i64
1452                ),
1453                (
1454                    "lib/main.dart".to_string(),
1455                    "lib/b/x.dart".to_string(),
1456                    1i64
1457                ),
1458            ]
1459        );
1460    }
1461
1462    /// R `source()` resolution end to end (added 2026-08-15 with the R
1463    /// parser): a root-level `source("utils.R")`, a subdir
1464    /// `source("sub/helpers.R")`, and an importing-file-relative
1465    /// `source("deep.R")` from inside `sub/` all resolve through the
1466    /// path-import route to exact files — File→Module and File→File both.
1467    /// A non-literal `source(paste0(…))` contributes nothing.
1468    #[test]
1469    fn r_source_resolves_as_file_paths() {
1470        let temp = tempfile::tempdir().expect("tempdir");
1471        let project = temp.path().join("r_proj");
1472        std::fs::create_dir_all(project.join("sub")).expect("create sub");
1473        std::fs::write(
1474            project.join("main.R"),
1475            "source(\"utils.R\")\n\
1476             source(\"sub/helpers.R\")\n\
1477             source(paste0(dir, \"/dyn.R\"))\n\
1478             main <- function() run()\n",
1479        )
1480        .expect("write main");
1481        std::fs::write(project.join("utils.R"), "add_one <- function(x) x + 1\n")
1482            .expect("write utils");
1483        std::fs::write(
1484            project.join("sub/helpers.R"),
1485            "source(\"deep.R\")\nhelp_fn <- function() 1\n",
1486        )
1487        .expect("write helpers");
1488        std::fs::write(project.join("sub/deep.R"), "deep_fn <- function() 2\n")
1489            .expect("write deep");
1490
1491        let result = get_parser("r")
1492            .expect("R parser")
1493            .parse_directory(&project, false);
1494        let known_modules: HashSet<_> = result
1495            .files
1496            .iter()
1497            .map(|file| file.module_path.clone())
1498            .collect();
1499        let module_pairs: HashSet<_> = other_edges::build_import_edges(
1500            &result.files,
1501            &known_modules,
1502            &js_workspace::JsWorkspace::default(),
1503        )
1504        .into_iter()
1505        .map(|edge| (edge.file_path, edge.module))
1506        .collect();
1507        let expected_modules: HashSet<_> = [
1508            ("main.R", "r_proj.utils"),
1509            ("main.R", "r_proj.sub.helpers"),
1510            ("sub/helpers.R", "r_proj.sub.deep"),
1511        ]
1512        .into_iter()
1513        .map(|(f, m)| (f.to_string(), m.to_string()))
1514        .collect();
1515        assert_eq!(module_pairs, expected_modules, "{module_pairs:?}");
1516
1517        let module_to_file = result
1518            .files
1519            .iter()
1520            .map(|file| (file.module_path.clone(), file.path.clone()))
1521            .collect();
1522        let file_pairs: HashSet<_> = other_edges::build_file_import_edges(
1523            &result.files,
1524            &module_to_file,
1525            &js_workspace::JsWorkspace::default(),
1526        )
1527        .into_iter()
1528        .map(|edge| (edge.source, edge.target))
1529        .collect();
1530        let expected_files: HashSet<_> = [
1531            ("main.R", "utils.R"),
1532            ("main.R", "sub/helpers.R"),
1533            ("sub/helpers.R", "sub/deep.R"),
1534        ]
1535        .into_iter()
1536        .map(|(s, t)| (s.to_string(), t.to_string()))
1537        .collect();
1538        assert_eq!(file_pairs, expected_files, "{file_pairs:?}");
1539    }
1540
1541    /// The R collision bait (added 2026-08-15 with the R parser):
1542    /// `library(tools)` when a local `tools.R` exists. `library()` names an
1543    /// external package, never a project file, so the collision must
1544    /// manufacture NO edge of either kind: R is deliberately not in
1545    /// `build_file_import_edges`'s file-anchored allowlist (no File→File),
1546    /// and the Track-D root-prefix fallback stays Python-only by its own
1547    /// doctrine, so the bare name also never lands on the root-prefixed
1548    /// local module (no File→Module). A package name resolves only on an
1549    /// exact known-module match, which a parser-prefixed local module can't
1550    /// produce. `library(stats)` (genuinely external) likewise resolves to
1551    /// nothing.
1552    #[test]
1553    fn r_library_naming_a_local_module_yields_no_file_edge() {
1554        let temp = tempfile::tempdir().expect("tempdir");
1555        let project = temp.path().join("r_proj");
1556        std::fs::create_dir_all(&project).expect("create project");
1557        std::fs::write(
1558            project.join("main.R"),
1559            "library(stats)\nlibrary(tools)\nmain <- function() pad_left(\"x\", 3)\n",
1560        )
1561        .expect("write main");
1562        std::fs::write(
1563            project.join("tools.R"),
1564            "pad_left <- function(s, n) formatC(s, width = n)\n",
1565        )
1566        .expect("write tools");
1567
1568        let result = get_parser("r")
1569            .expect("R parser")
1570            .parse_directory(&project, false);
1571        let known_modules: HashSet<_> = result
1572            .files
1573            .iter()
1574            .map(|file| file.module_path.clone())
1575            .collect();
1576        let module_pairs: Vec<_> = other_edges::build_import_edges(
1577            &result.files,
1578            &known_modules,
1579            &js_workspace::JsWorkspace::default(),
1580        )
1581        .into_iter()
1582        .map(|edge| (edge.file_path, edge.module))
1583        .collect();
1584        // Neither the bait nor the external name resolves to any module:
1585        // "tools" is not an exact match for the root-prefixed local module
1586        // `r_proj.tools`, and no fallback is allowed to bridge that gap.
1587        assert!(
1588            module_pairs.is_empty(),
1589            "a package reference must not land on a colliding local module: {module_pairs:?}"
1590        );
1591
1592        let module_to_file = result
1593            .files
1594            .iter()
1595            .map(|file| (file.module_path.clone(), file.path.clone()))
1596            .collect();
1597        let file_pairs: Vec<_> = other_edges::build_file_import_edges(
1598            &result.files,
1599            &module_to_file,
1600            &js_workspace::JsWorkspace::default(),
1601        )
1602        .into_iter()
1603        .map(|edge| (edge.source, edge.target))
1604        .collect();
1605        assert!(
1606            file_pairs.is_empty(),
1607            "a package reference must not manufacture a File→File edge: {file_pairs:?}"
1608        );
1609    }
1610
1611    #[test]
1612    fn overload_identity_keeps_last_exact_duplicate_and_leaves_ordinary_ids_alone() {
1613        let ordinary = FunctionInfo {
1614            name: "run".into(),
1615            qualified_name: "demo.run".into(),
1616            signature: "run()".into(),
1617            ..FunctionInfo::default()
1618        };
1619        let first = FunctionInfo {
1620            name: "pick".into(),
1621            qualified_name: "demo.Picker.pick".into(),
1622            signature: "pick(int)".into(),
1623            line_number: 1,
1624            ..FunctionInfo::default()
1625        };
1626        let duplicate = FunctionInfo {
1627            line_number: 2,
1628            ..first.clone()
1629        };
1630        let second = FunctionInfo {
1631            signature: "pick(string)".into(),
1632            line_number: 3,
1633            ..first.clone()
1634        };
1635        let mut functions = vec![ordinary, first, duplicate, second];
1636        let mut relationships = vec![TypeRelationship {
1637            source_type: "demo.Picker".into(),
1638            relationship: "inherent".into(),
1639            methods: functions[1..].to_vec(),
1640            ..TypeRelationship::default()
1641        }];
1642
1643        preserve_overloaded_function_ids(&mut functions, &mut relationships);
1644
1645        assert_eq!(functions.len(), 3);
1646        assert_eq!(functions[0].qualified_name, "demo.run");
1647        let overloads: Vec<_> = functions
1648            .iter()
1649            .filter(|function| function.name == "pick")
1650            .collect();
1651        assert_eq!(overloads.len(), 2);
1652        assert!(overloads.iter().any(|function| function.line_number == 2));
1653        assert!(!overloads.iter().any(|function| function.line_number == 1));
1654        for function in overloads {
1655            assert_eq!(
1656                overload_base_qualified_name(&function.qualified_name),
1657                Some("demo.Picker.pick")
1658            );
1659        }
1660        assert!(relationships[0]
1661            .methods
1662            .iter()
1663            .all(
1664                |method| overload_base_qualified_name(&method.qualified_name)
1665                    == Some("demo.Picker.pick")
1666            ));
1667    }
1668
1669    #[test]
1670    fn polyglot_overloads_have_stable_nodes_and_conservative_calls() {
1671        let fixtures = [
1672            (
1673                "cpp",
1674                "Picker.cpp",
1675                r#"class Picker {
1676public:
1677  int pick(int value) { return value; }
1678  int pick(double value) { return static_cast<int>(value); }
1679  int run() { return pick(1); }
1680};
1681"#,
1682            ),
1683            (
1684                "java",
1685                "Picker.java",
1686                r#"class Picker {
1687  int pick(int value) { return value; }
1688  int pick(long value) { return (int) value; }
1689  int run() { return pick(1); }
1690}
1691"#,
1692            ),
1693            (
1694                "csharp",
1695                "Picker.cs",
1696                r#"class Picker {
1697  public int pick(int value) { return value; }
1698  public int pick(string value) { return value.Length; }
1699  public int run() { return pick(1); }
1700}
1701"#,
1702            ),
1703            (
1704                "typescript",
1705                "Picker.ts",
1706                r#"class Picker {
1707  pick(value: number) { return value; }
1708  pick(value: string) { return value.length; }
1709  run() { return this.pick(1); }
1710}
1711"#,
1712            ),
1713        ];
1714
1715        for (language, filename, source) in fixtures {
1716            let temp = tempfile::tempdir().expect("tempdir");
1717            let project = temp.path().join("project");
1718            std::fs::create_dir(&project).expect("create fixture root");
1719            std::fs::write(project.join(filename), source).expect("write overload fixture");
1720
1721            let first = run_with_options(&project, false, false, None, None, false)
1722                .unwrap_or_else(|error| panic!("{language} first build: {error}"));
1723            let second = run_with_options(&project, false, false, None, None, false)
1724                .unwrap_or_else(|error| panic!("{language} second build: {error}"));
1725
1726            let overload_rows = |graph: &DirGraph| {
1727                let mut rows: Vec<(String, String)> = graph
1728                    .graph
1729                    .node_indices()
1730                    .filter_map(|index| graph.node_view(index))
1731                    .filter(|node| {
1732                        node.node_type_str(&graph.interner) == "Function"
1733                            && node.title().as_ref() == &Value::String("pick".into())
1734                    })
1735                    .map(|node| {
1736                        let signature = match node.get_property_value("signature") {
1737                            Some(Value::String(value)) => value,
1738                            value => panic!("{language} signature missing: {value:?}"),
1739                        };
1740                        let qualified_name = match node.id().as_ref() {
1741                            Value::String(value) => value.clone(),
1742                            value => panic!("{language} function ID is not text: {value:?}"),
1743                        };
1744                        (qualified_name, signature)
1745                    })
1746                    .collect();
1747                rows.sort();
1748                rows
1749            };
1750            let first_rows = overload_rows(&first);
1751            let second_rows = overload_rows(&second);
1752            assert_eq!(first_rows.len(), 2, "{language}: {first_rows:?}");
1753            assert_eq!(first_rows, second_rows, "{language} IDs changed");
1754            assert_ne!(first_rows[0].0, first_rows[1].0, "{language}");
1755            assert_ne!(first_rows[0].1, first_rows[1].1, "{language}");
1756            for (qualified_name, _) in &first_rows {
1757                let (_, discriminator) = qualified_name
1758                    .rsplit_once(OVERLOAD_ID_SEPARATOR)
1759                    .unwrap_or_else(|| {
1760                        panic!("{language} ID lacks discriminator: {qualified_name}")
1761                    });
1762                assert_eq!(discriminator.len(), SHA256_HEX_LEN, "{language}");
1763                assert!(discriminator.bytes().all(|byte| byte.is_ascii_hexdigit()));
1764            }
1765
1766            let calls_to_overloads = first
1767                .graph
1768                .edge_indices()
1769                .filter(|&edge_index| {
1770                    let Some(edge) = first.graph.edge_weight(edge_index) else {
1771                        return false;
1772                    };
1773                    if edge.connection_type_str(&first.interner) != "CALLS" {
1774                        return false;
1775                    }
1776                    let Some((source, target)) = first.graph.edge_endpoints(edge_index) else {
1777                        return false;
1778                    };
1779                    let (Some(source), Some(target)) =
1780                        (first.node_view(source), first.node_view(target))
1781                    else {
1782                        return false;
1783                    };
1784                    source.title().as_ref() == &Value::String("run".into())
1785                        && target.title().as_ref() == &Value::String("pick".into())
1786                })
1787                .count();
1788            assert_eq!(calls_to_overloads, 2, "{language}");
1789        }
1790    }
1791
1792    // ── inferred Project anchoring (manifestless repositories) ──────────────
1793
1794    fn count_label(graph: &DirGraph, label: &str) -> usize {
1795        graph
1796            .graph
1797            .node_indices()
1798            .filter_map(|index| graph.node_view(index))
1799            .filter(|node| node.node_type_str(&graph.interner) == label)
1800            .count()
1801    }
1802
1803    fn count_conn(graph: &DirGraph, conn: &str) -> usize {
1804        graph
1805            .graph
1806            .edge_indices()
1807            .filter_map(|index| graph.graph.edge_weight(index))
1808            .filter(|edge| edge.connection_type_str(&graph.interner) == conn)
1809            .count()
1810    }
1811
1812    /// The single `:Project` node's id plus its property map. Panics unless
1813    /// exactly one exists — "exactly one" is part of every assertion here.
1814    fn sole_project(graph: &DirGraph) -> (String, BTreeMap<String, Value>) {
1815        let mut found: Vec<(String, BTreeMap<String, Value>)> = graph
1816            .graph
1817            .node_indices()
1818            .filter_map(|index| graph.node_view(index))
1819            .filter(|node| node.node_type_str(&graph.interner) == "Project")
1820            .map(|node| {
1821                let id = match &*node.id() {
1822                    Value::String(name) => name.clone(),
1823                    other => panic!("unexpected :Project id {other:?}"),
1824                };
1825                (
1826                    id,
1827                    node.properties_cloned(&graph.interner)
1828                        .into_iter()
1829                        .collect(),
1830                )
1831            })
1832            .collect();
1833        assert_eq!(found.len(), 1, "expected exactly one :Project node");
1834        found.pop().expect("one project")
1835    }
1836
1837    /// A manifestless repository (the common case: JS/TS, Go, Java, C++, or a
1838    /// plain directory) must still get a `:Project` anchor — named after the
1839    /// project root — owning every source file via HAS_SOURCE and every doc
1840    /// via HAS_DOC. Before the inferred-project fix this graph had ZERO
1841    /// `:Project` nodes and no ownership edges at all.
1842    #[test]
1843    fn manifestless_repository_gets_an_inferred_project_anchor() {
1844        let temp = tempfile::tempdir().expect("tempdir");
1845        let project = temp.path().join("plainrepo");
1846        std::fs::create_dir_all(&project).expect("create project dir");
1847        std::fs::write(project.join("app.py"), "def compute():\n    return 1\n")
1848            .expect("write source");
1849        std::fs::write(
1850            project.join("README.md"),
1851            "# Plain repo\nCalls `compute` on start.\n",
1852        )
1853        .expect("write doc");
1854
1855        let include_docs = cfg!(feature = "docs");
1856        let graph = run_with_options(&project, false, true, None, None, include_docs)
1857            .expect("build manifestless project");
1858
1859        let (id, props) = sole_project(&graph);
1860        assert_eq!(id, "plainrepo", "project is named after the project root");
1861        assert_eq!(
1862            props.get("manifest"),
1863            Some(&Value::String(crate::models::INFERRED_MANIFEST.to_string())),
1864            "inferred projects carry the sentinel in `manifest`: {props:?}"
1865        );
1866        assert_eq!(
1867            props.get("languages"),
1868            Some(&Value::String("python".to_string())),
1869            "languages are reconciled from the parsed files: {props:?}"
1870        );
1871
1872        let files = count_label(&graph, "File");
1873        assert!(files >= 1, "the source file was parsed");
1874        assert_eq!(
1875            count_conn(&graph, "HAS_SOURCE"),
1876            files,
1877            "every File is owned by the inferred Project"
1878        );
1879
1880        #[cfg(feature = "docs")]
1881        {
1882            let docs = count_label(&graph, "Doc");
1883            assert!(docs >= 1, "the README became a :Doc node");
1884            assert_eq!(
1885                count_conn(&graph, "HAS_DOC"),
1886                docs,
1887                "every Doc is anchored to the Project"
1888            );
1889        }
1890    }
1891
1892    /// Every property column the `:Project` dataframe emits, except `name`
1893    /// (which becomes the node's id and title rather than a property).
1894    /// Inference must not grow this list — a new column would change the
1895    /// shape of manifest-backed Project nodes too, and the point of the
1896    /// inferred project is to be additive for them.
1897    const PROJECT_PROPERTY_COLUMNS: &[&str] = &[
1898        "version",
1899        "description",
1900        "languages",
1901        "authors",
1902        "license",
1903        "repository",
1904        "build_system",
1905        "crate_type",
1906        "manifest",
1907    ];
1908
1909    /// The manifest-backed twin of the fixture above. Three invariants:
1910    /// the manifest path is real (not the sentinel); neither build emits a
1911    /// property outside the fixed Project column vocabulary; and the inferred
1912    /// build's keys are a subset of a fully-populated manifest-backed build's
1913    /// — i.e. inference introduces no property of its own. (The two key sets
1914    /// are compared by subset, not equality, because a null property is
1915    /// dropped from the node rather than stored: an inferred project has no
1916    /// `build_system`, so equality would be measuring metadata richness
1917    /// rather than the column set.) HAS_DOC is asserted here too, because no
1918    /// corpus covers a manifest-backed repository that also has docs.
1919    #[test]
1920    fn manifest_backed_project_keeps_the_same_property_columns() {
1921        let temp = tempfile::tempdir().expect("tempdir");
1922        let include_docs = cfg!(feature = "docs");
1923
1924        let inferred_root = temp.path().join("inferred");
1925        std::fs::create_dir_all(&inferred_root).expect("create dir");
1926        std::fs::write(
1927            inferred_root.join("app.py"),
1928            "def compute():\n    return 1\n",
1929        )
1930        .expect("write source");
1931        std::fs::write(inferred_root.join("README.md"), "# Demo\nRuns `compute`.\n")
1932            .expect("write doc");
1933        let inferred = run_with_options(&inferred_root, false, true, None, None, include_docs)
1934            .expect("build manifestless project");
1935
1936        let backed_root = temp.path().join("backed");
1937        std::fs::create_dir_all(&backed_root).expect("create dir");
1938        std::fs::write(backed_root.join("app.py"), "def compute():\n    return 1\n")
1939            .expect("write source");
1940        std::fs::write(backed_root.join("README.md"), "# Demo\nRuns `compute`.\n")
1941            .expect("write doc");
1942        std::fs::write(
1943            backed_root.join("pyproject.toml"),
1944            // Populate every column a pyproject can reach, so the subset
1945            // assertion below is a real constraint and not an artifact of a
1946            // sparse manifest.
1947            "[project]\nname = \"demo\"\nversion = \"1.2.3\"\ndescription = \"demo project\"\n\
1948             license = \"MIT\"\nauthors = [{ name = \"A. Dev\" }]\ndependencies = [\"requests\"]\n\
1949             [project.urls]\nRepository = \"https://example.invalid/demo\"\n\
1950             [build-system]\nbuild-backend = \"setuptools.build_meta\"\n",
1951        )
1952        .expect("write manifest");
1953        let backed = run_with_options(&backed_root, false, true, None, None, include_docs)
1954            .expect("build manifest-backed project");
1955
1956        let (inferred_id, inferred_props) = sole_project(&inferred);
1957        let (backed_id, backed_props) = sole_project(&backed);
1958        assert_eq!(inferred_id, "inferred");
1959        assert_eq!(backed_id, "demo", "manifest-declared name wins");
1960        assert_eq!(
1961            backed_props.get("manifest"),
1962            Some(&Value::String("pyproject.toml".to_string())),
1963            "manifest-backed projects keep the real manifest path: {backed_props:?}"
1964        );
1965
1966        let vocabulary: BTreeSet<&str> = PROJECT_PROPERTY_COLUMNS.iter().copied().collect();
1967        let inferred_keys: BTreeSet<&str> = inferred_props.keys().map(|key| key.as_str()).collect();
1968        let backed_keys: BTreeSet<&str> = backed_props.keys().map(|key| key.as_str()).collect();
1969        assert!(
1970            inferred_keys.is_subset(&vocabulary) && backed_keys.is_subset(&vocabulary),
1971            "a Project property outside the fixed column set: inferred={inferred_keys:?} backed={backed_keys:?}"
1972        );
1973        assert!(
1974            inferred_keys.is_subset(&backed_keys),
1975            "inference must not introduce a Project property column: inferred={inferred_keys:?} backed={backed_keys:?}"
1976        );
1977
1978        #[cfg(feature = "docs")]
1979        {
1980            let docs = count_label(&backed, "Doc");
1981            assert!(docs >= 1, "the README became a :Doc node");
1982            assert_eq!(
1983                count_conn(&backed, "HAS_DOC"),
1984                docs,
1985                "manifest-backed projects anchor their docs too"
1986            );
1987        }
1988    }
1989}