Skip to main content

fallow_engine/
trace_error.rs

1//! `fallow trace-error`: resolve a runtime stack trace's frames against the
2//! project graph.
3//!
4//! Two independent halves, kept apart so each is testable on its own:
5//!
6//! 1. [`parse_stack_trace`] turns text into frames. Pure, no graph, no I/O.
7//! 2. [`resolve_stack_trace`] asks the module graph which definitions each
8//!    frame's identifier names.
9//!
10//! The graph is asked ONLY about frames whose file resolved to project source.
11//! Everything else keeps its place in the reported array with its origin
12//! recorded, so the reported frame numbering matches the trace as pasted and
13//! the counts close.
14//!
15//! No source-map layer. A frame pointing into a build artifact reports that
16//! fact rather than being rebound through a map that may be stale, because a
17//! confidently wrong line is worse than a named refusal.
18
19use std::path::{Path, PathBuf};
20
21use fallow_types::discover::FileId;
22use fallow_types::extract::MemberKind;
23use fallow_types::trace_error::{
24    ErrorTrace, ErrorTraceCandidate, ErrorTraceCounts, ErrorTraceFrame, ErrorTraceSchemaVersion,
25    FrameOrigin, FrameResolution,
26};
27use rustc_hash::FxHashMap;
28
29use crate::graph::ModuleGraph;
30use crate::module_graph::RetainedModuleGraph;
31use crate::trace::trace_impl::{matching_module_indexes, relativize};
32
33/// Largest stack trace this verb will read, from a file or from stdin.
34///
35/// A stack trace is a handful of kilobytes. The cap exists so a redirected log
36/// file cannot turn a bounded question into an unbounded one.
37pub const MAX_STACK_TRACE_BYTES: u64 = 1024 * 1024;
38
39/// Largest number of frames reported. Runtimes cap their own traces well below
40/// this (V8's default is 10), so reaching it means the input is a log rather
41/// than a trace. Frames past the cap are counted in `frames_omitted`.
42const MAX_REPORTED_FRAMES: usize = 256;
43
44/// Largest number of definitions listed for one ambiguous frame. The true match
45/// count stays visible through `candidates_omitted`, so capping the list never
46/// makes a frame look less ambiguous than it is.
47const MAX_FRAME_CANDIDATES: usize = 10;
48
49/// Path segments whose presence means the frame points at generated bundle
50/// output rather than at source. Resolving those needs a source map, which this
51/// verb deliberately does not do.
52const BUILD_OUTPUT_SEGMENTS: &[&str] = &["dist", "build", "out", ".next"];
53
54/// The installed dependency tree's directory name.
55const DEPENDENCY_SEGMENT: &str = "node_modules";
56
57/// One frame as read from the input, before the graph is consulted.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct RawFrame {
60    /// The input line, trimmed and otherwise verbatim.
61    pub raw: String,
62    /// The function identifier the runtime printed, with the `async` and `new`
63    /// markers removed.
64    pub function: Option<String>,
65    /// Whether the runtime marked the frame as a constructor call.
66    pub is_constructor: bool,
67    /// Whether the runtime marked the frame as an async call.
68    pub is_async: bool,
69    /// The frame's file, unwrapped from any URL scheme and forward-slashed.
70    pub file: Option<String>,
71    /// 1-based line, when the runtime supplied one.
72    pub line: Option<u32>,
73    /// 1-based column, when the runtime supplied one.
74    pub column: Option<u32>,
75}
76
77/// A stack trace after parsing and before resolution.
78#[derive(Debug, Clone, Default, PartialEq, Eq)]
79pub struct ParsedStackTrace {
80    /// The first non-blank line preceding any frame, verbatim.
81    pub header: Option<String>,
82    /// Recognised frames, in input order.
83    pub frames: Vec<RawFrame>,
84    /// Non-blank lines that were neither a frame nor the header.
85    pub unparsed_lines: usize,
86}
87
88/// Parse a runtime stack trace into frames.
89///
90/// Recognises the V8 / Node form (`    at name (file:line:col)`) and the
91/// SpiderMonkey / JavaScriptCore form (`name@file:line:col`). A line matching
92/// neither is counted, never silently dropped.
93#[must_use]
94pub fn parse_stack_trace(input: &str) -> ParsedStackTrace {
95    let mut parsed = ParsedStackTrace::default();
96    for line in input.lines() {
97        let trimmed = line.trim();
98        if trimmed.is_empty() {
99            continue;
100        }
101        if let Some(frame) = parse_frame(trimmed) {
102            parsed.frames.push(frame);
103            continue;
104        }
105        if parsed.header.is_none() && parsed.frames.is_empty() {
106            parsed.header = Some(trimmed.to_string());
107            continue;
108        }
109        parsed.unparsed_lines += 1;
110    }
111    parsed
112}
113
114/// Recognise one line as a frame in either supported runtime form.
115fn parse_frame(trimmed: &str) -> Option<RawFrame> {
116    parse_v8_frame(trimmed).or_else(|| parse_at_sign_frame(trimmed))
117}
118
119/// V8 / Node: `at name (file:line:col)`, `at file:line:col`, `at name (native)`.
120fn parse_v8_frame(trimmed: &str) -> Option<RawFrame> {
121    let rest = trimmed.strip_prefix("at ")?.trim_start();
122    // The location is parenthesised whenever the runtime printed a name, and a
123    // function name may itself contain a parenthesis only in pathological
124    // cases, so the LAST opening parenthesis of a parenthesised tail is the
125    // separator.
126    let (name_part, location_part) = match rest.strip_suffix(')') {
127        Some(head) => match head.rfind(" (") {
128            Some(index) => (&head[..index], &head[index + 2..]),
129            None => ("", rest),
130        },
131        None => ("", rest),
132    };
133
134    let mut name = name_part.trim();
135    let mut is_async = false;
136    let mut is_constructor = false;
137    if let Some(stripped) = name.strip_prefix("async ") {
138        is_async = true;
139        name = stripped.trim_start();
140    }
141    if let Some(stripped) = name.strip_prefix("new ") {
142        is_constructor = true;
143        name = stripped.trim_start();
144    }
145
146    let (file, line, column) = parse_location(location_part);
147    Some(RawFrame {
148        raw: trimmed.to_string(),
149        function: named_function(name),
150        is_constructor,
151        is_async,
152        file,
153        line,
154        column,
155    })
156}
157
158/// SpiderMonkey / JavaScriptCore: `name@file:line:col`, `@file:line:col`.
159///
160/// A location without a line number is rejected rather than accepted, so an
161/// ordinary line that happens to contain an `@` does not become a frame.
162fn parse_at_sign_frame(trimmed: &str) -> Option<RawFrame> {
163    let (name_part, location_part) = trimmed.rsplit_once('@')?;
164    let (file, line, column) = parse_location(location_part);
165    let file = file?;
166    line?;
167    Some(RawFrame {
168        raw: trimmed.to_string(),
169        function: named_function(name_part.trim()),
170        is_constructor: false,
171        is_async: false,
172        file: Some(file),
173        line,
174        column,
175    })
176}
177
178/// A frame's printed name, or `None` when the runtime printed a placeholder.
179fn named_function(name: &str) -> Option<String> {
180    if name.is_empty() || name == "<anonymous>" {
181        return None;
182    }
183    Some(name.to_string())
184}
185
186/// Split a `file:line:col` location, peeling the numeric tail from the right so
187/// a Windows drive letter and a URL scheme keep their own colons.
188fn parse_location(location: &str) -> (Option<String>, Option<u32>, Option<u32>) {
189    let location = location.trim();
190    if location.is_empty() || location == "native" || location == "<anonymous>" {
191        return (None, None, None);
192    }
193    let (head, column) = peel_number(location);
194    let (head, line) = if column.is_some() {
195        peel_number(head)
196    } else {
197        (head, None)
198    };
199    // Only a `file:line:col` tail yields both numbers. A `file:line` tail
200    // leaves the single number in `column`, where it belongs on `line`.
201    let (line, column) = match (line, column) {
202        (Some(line), column) => (Some(line), column),
203        (None, Some(single)) => (Some(single), None),
204        (None, None) => (None, None),
205    };
206    (normalize_frame_path(head), line, column)
207}
208
209/// Split a trailing `:<digits>` off a location, when there is one.
210fn peel_number(text: &str) -> (&str, Option<u32>) {
211    let Some((head, tail)) = text.rsplit_once(':') else {
212        return (text, None);
213    };
214    if tail.is_empty() || !tail.bytes().all(|byte| byte.is_ascii_digit()) {
215        return (text, None);
216    }
217    match tail.parse::<u32>() {
218        Ok(value) => (head, Some(value)),
219        Err(_) => (text, None),
220    }
221}
222
223/// Unwrap a frame path from its URL scheme and forward-slash it.
224///
225/// `file://` and `http(s)://` are unwrapped because the remainder is a real
226/// path that can match a module. Every other scheme (`node:`, `webpack://`,
227/// extension schemes) is left intact, because rewriting it would invent a path
228/// the runtime never named.
229fn normalize_frame_path(path: &str) -> Option<String> {
230    let path = path.trim().replace('\\', "/");
231    if path.is_empty() {
232        return None;
233    }
234    if let Some(rest) = path.strip_prefix("file://") {
235        // `file:///C:/src/a.ts` carries a leading slash before the drive
236        // letter that is not part of the filesystem path.
237        let rest = rest
238            .strip_prefix('/')
239            .filter(|tail| is_windows_drive_prefixed(tail))
240            .unwrap_or(rest);
241        return (!rest.is_empty()).then(|| rest.to_string());
242    }
243    for scheme in ["https://", "http://"] {
244        if let Some(rest) = path.strip_prefix(scheme) {
245            // Drop the authority; the path component is the only part that can
246            // correspond to a file in the project.
247            let authority_end = rest.find('/')?;
248            let remainder = &rest[authority_end..];
249            return (remainder.len() > 1).then(|| remainder.to_string());
250        }
251    }
252    Some(path)
253}
254
255/// Whether a path begins with a `C:/`-style Windows drive prefix.
256fn is_windows_drive_prefixed(path: &str) -> bool {
257    let mut bytes = path.bytes();
258    matches!(
259        (bytes.next(), bytes.next(), bytes.next()),
260        (Some(drive), Some(b':'), Some(b'/')) if drive.is_ascii_alphabetic()
261    )
262}
263
264/// Whether a normalized path contains `segment` as a whole path component.
265fn has_path_segment(path: &str, segment: &str) -> bool {
266    path.split('/').any(|component| component == segment)
267}
268
269/// Whether a frame path names a runtime internal rather than a project file.
270fn is_runtime_internal(path: &str) -> bool {
271    path.starts_with("node:") || path.starts_with("internal/")
272}
273
274/// A frame's printed name, split into the parts a graph lookup addresses.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276struct Identifier<'a> {
277    /// The name exactly as the runtime printed it, for diagnostics.
278    printed: &'a str,
279    /// The segment before the last dot, when there is one. V8 prints a
280    /// receiver here (`Task` in `Task.run`, or the synthetic `Object`).
281    owner: Option<&'a str>,
282    /// The last segment: the function or member the frame is executing.
283    name: &'a str,
284}
285
286/// Split a printed frame name into an optional owner and the member or function
287/// name it addresses.
288///
289/// Returns `None` for a name that is not an addressable identifier, such as
290/// `Object.<anonymous>` or a SpiderMonkey `outer/<` closure marker. Those are
291/// reported as frames and are simply never looked up.
292fn identifier_parts(function: &str) -> Option<Identifier<'_>> {
293    if function.is_empty()
294        || function
295            .chars()
296            .any(|c| c.is_whitespace() || c == '<' || c == '>' || c == '/' || c == '\\')
297    {
298        return None;
299    }
300    let mut segments = function.split('.');
301    let mut owner = None;
302    let mut name = segments.next()?;
303    if name.is_empty() {
304        return None;
305    }
306    for segment in segments {
307        if segment.is_empty() {
308            return None;
309        }
310        owner = Some(name);
311        name = segment;
312    }
313    Some(Identifier {
314        printed: function,
315        owner,
316        name,
317    })
318}
319
320/// Stable wire token for a member kind, matching `--trace FILE:MEMBER`.
321const fn member_kind_label(kind: MemberKind) -> &'static str {
322    match kind {
323        MemberKind::ClassMethod => "class-method",
324        MemberKind::ClassProperty => "class-property",
325        MemberKind::EnumMember => "enum-member",
326        MemberKind::StoreMember => "store-member",
327        MemberKind::NamespaceMember => "namespace-member",
328    }
329}
330
331/// A candidate before its declaration line has been resolved.
332struct LocatedCandidate {
333    file_id: FileId,
334    file: String,
335    symbol: String,
336    member: Option<String>,
337    kind: &'static str,
338    span_start: u32,
339}
340
341/// Per-run state every frame's resolution shares.
342///
343/// The two caches exist because a stack trace repeats itself: a recursive or
344/// deeply nested trace names the same file in frame after frame, and resolving
345/// or reading that file once per frame would turn a bounded question into a
346/// syscall per frame.
347struct TraceContext<'a> {
348    /// The project root as the caller spelled it.
349    root: &'a Path,
350    /// The project root with symlinks resolved, when it could be read.
351    canonical_root: Option<PathBuf>,
352    /// An absolute frame path mapped to its project-root-relative spelling,
353    /// or to `None` when it resolves to nothing inside the project.
354    resolved_paths: FxHashMap<String, Option<String>>,
355    /// A module's line offsets, or `None` when the file could not be read.
356    line_offsets: FxHashMap<FileId, Option<Vec<u32>>>,
357}
358
359impl<'a> TraceContext<'a> {
360    fn new(root: &'a Path) -> Self {
361        Self {
362            root,
363            canonical_root: dunce::canonicalize(root).ok(),
364            resolved_paths: FxHashMap::default(),
365            line_offsets: FxHashMap::default(),
366        }
367    }
368
369    /// The project-root-relative spelling of a frame's file, or `None` when
370    /// the frame path is relative, unreadable, or outside the project root.
371    ///
372    /// This verb is the one command whose input is written by a machine rather
373    /// than typed by a human: the runtime prints the absolute path ITS process
374    /// saw. A project reached through a symlink (macOS `/tmp`, a checkout
375    /// linked into place) therefore prints a prefix no module path carries,
376    /// and comparing that spelling against the canonicalized paths discovery
377    /// stored reports a real project file as out of corpus. Resolving both
378    /// sides once turns it back into the ordinary root-relative comparison.
379    ///
380    /// A path that does not exist is NOT rewritten: it resolves to `None` and
381    /// the frame is compared as the runtime spelled it, so a missing file can
382    /// never be turned into a match on something else.
383    fn root_relative(&mut self, path: &str) -> Option<String> {
384        if !Path::new(path).is_absolute() {
385            return None;
386        }
387        if let Some(cached) = self.resolved_paths.get(path) {
388            return cached.clone();
389        }
390        let resolved = dunce::canonicalize(path).ok().and_then(|canonical| {
391            let root = self.canonical_root.as_deref().unwrap_or(self.root);
392            let relative = canonical.strip_prefix(root).ok()?;
393            Some(relative.to_string_lossy().replace('\\', "/"))
394        });
395        self.resolved_paths
396            .insert(path.to_string(), resolved.clone());
397        resolved
398    }
399}
400
401/// Resolve a parsed stack trace against the module graph.
402#[must_use]
403pub fn resolve_stack_trace(
404    graph: &RetainedModuleGraph,
405    root: &Path,
406    parsed: ParsedStackTrace,
407    source: String,
408) -> ErrorTrace {
409    resolve_with_graph(graph.as_graph(), root, parsed, source)
410}
411
412fn resolve_with_graph(
413    graph: &ModuleGraph,
414    root: &Path,
415    parsed: ParsedStackTrace,
416    source: String,
417) -> ErrorTrace {
418    let ParsedStackTrace {
419        header,
420        frames,
421        unparsed_lines,
422    } = parsed;
423
424    let frames_omitted = frames.len().saturating_sub(MAX_REPORTED_FRAMES);
425    let mut context = TraceContext::new(root);
426    let mut counts = ErrorTraceCounts {
427        frames_omitted,
428        unparsed_lines,
429        ..ErrorTraceCounts::default()
430    };
431
432    let resolved_frames: Vec<ErrorTraceFrame> = frames
433        .into_iter()
434        .take(MAX_REPORTED_FRAMES)
435        .enumerate()
436        .map(|(index, frame)| resolve_frame(graph, &mut context, index, frame))
437        .collect();
438
439    counts.frames = resolved_frames.len();
440    for frame in &resolved_frames {
441        match frame.origin {
442            FrameOrigin::InProject => counts.in_project += 1,
443            FrameOrigin::NodeModules => counts.node_modules += 1,
444            FrameOrigin::OutOfCorpus => counts.out_of_corpus += 1,
445        }
446        match frame.resolution {
447            FrameResolution::Resolved => counts.resolved += 1,
448            FrameResolution::Ambiguous => counts.ambiguous += 1,
449            FrameResolution::NotFound => counts.not_found += 1,
450            FrameResolution::NotAttempted => counts.not_attempted += 1,
451        }
452    }
453
454    let reason = summary_reason(&counts, header.is_some());
455
456    ErrorTrace {
457        schema_version: ErrorTraceSchemaVersion::V1,
458        source,
459        header,
460        reason,
461        frames: resolved_frames,
462        counts,
463    }
464}
465
466/// One sentence stating what the run answered and what it did not.
467///
468/// `has_header` is the count `counts` cannot carry: the first non-blank line of
469/// a frameless input is taken as the error header and reported under `header`,
470/// so it is deliberately absent from `unparsed_lines`. A sentence that claims to
471/// describe the INPUT has to count it back in, or its number contradicts the
472/// lines the caller pasted. The frames branch instead says `further`, because
473/// there the header and the frames are both already reported above it.
474fn summary_reason(counts: &ErrorTraceCounts, has_header: bool) -> String {
475    use std::fmt::Write as _;
476
477    if counts.frames == 0 {
478        let input_lines = counts.unparsed_lines + usize::from(has_header);
479        return if input_lines == 0 {
480            "no stack frames in the input".to_string()
481        } else {
482            format!(
483                "no stack frames recognised in {input_lines} non-blank input {}",
484                plural(input_lines, "line", "lines")
485            )
486        };
487    }
488    let mut reason = format!(
489        "{} {}: {} resolved, {} ambiguous, {} not found, {} not attempted",
490        counts.frames,
491        plural(counts.frames, "frame", "frames"),
492        counts.resolved,
493        counts.ambiguous,
494        counts.not_found,
495        counts.not_attempted
496    );
497    if counts.frames_omitted > 0 {
498        let _ = write!(
499            reason,
500            " ({} further frames omitted)",
501            counts.frames_omitted
502        );
503    }
504    if counts.unparsed_lines > 0 {
505        let _ = write!(
506            reason,
507            "; {} further input {} not recognised as a frame",
508            counts.unparsed_lines,
509            plural(counts.unparsed_lines, "line", "lines")
510        );
511    }
512    reason
513}
514
515fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
516    if count == 1 { one } else { many }
517}
518
519/// What asking (or declining to ask) the graph produced for one frame.
520struct FrameOutcome {
521    resolution: FrameResolution,
522    candidates: Vec<ErrorTraceCandidate>,
523    candidates_omitted: usize,
524    line_mismatch: bool,
525    reason: String,
526}
527
528impl FrameOutcome {
529    /// The graph was not consulted, and `reason` says why.
530    fn not_attempted(reason: String) -> Self {
531        Self {
532            resolution: FrameResolution::NotAttempted,
533            candidates: Vec::new(),
534            candidates_omitted: 0,
535            line_mismatch: false,
536            reason,
537        }
538    }
539}
540
541/// Classify one frame and, when it points at project source with an
542/// addressable identifier, ask the graph what that identifier names.
543fn resolve_frame(
544    graph: &ModuleGraph,
545    context: &mut TraceContext<'_>,
546    index: usize,
547    frame: RawFrame,
548) -> ErrorTraceFrame {
549    let (origin, outcome) = classify_and_look_up(graph, context, &frame);
550    let RawFrame {
551        raw,
552        function,
553        is_constructor,
554        is_async,
555        file,
556        line,
557        column,
558    } = frame;
559    ErrorTraceFrame {
560        index,
561        raw,
562        function,
563        is_constructor,
564        is_async,
565        file,
566        line,
567        column,
568        origin,
569        resolution: outcome.resolution,
570        candidates: outcome.candidates,
571        candidates_omitted: outcome.candidates_omitted,
572        line_mismatch: outcome.line_mismatch,
573        reason: outcome.reason,
574    }
575}
576
577/// The four gates a frame passes before the graph is asked anything, in order:
578/// it must carry a location, that location must not be a dependency, it must
579/// match a module, and it must carry an addressable identifier. Failing any one
580/// of them is `not_attempted` with the gate named, never `not_found`.
581fn classify_and_look_up(
582    graph: &ModuleGraph,
583    context: &mut TraceContext<'_>,
584    frame: &RawFrame,
585) -> (FrameOrigin, FrameOutcome) {
586    let Some(path) = frame.file.as_deref() else {
587        return (
588            FrameOrigin::OutOfCorpus,
589            FrameOutcome::not_attempted("frame carries no source location".to_string()),
590        );
591    };
592
593    if has_path_segment(path, DEPENDENCY_SEGMENT) {
594        return (
595            FrameOrigin::NodeModules,
596            FrameOutcome::not_attempted(
597                "frame is in an installed dependency, not in project source".to_string(),
598            ),
599        );
600    }
601
602    // An absolute frame path is resolved to its root-relative spelling FIRST,
603    // and only once, so a symlinked project root does not make every frame
604    // look out of corpus and so the resolution costs one stat rather than one
605    // per module.
606    let resolved = context.root_relative(path);
607    let match_path = resolved.as_deref().unwrap_or(path);
608    let root = context.root;
609    let module_indexes = matching_module_indexes(graph, root, match_path);
610    if module_indexes.is_empty() {
611        return (
612            FrameOrigin::OutOfCorpus,
613            FrameOutcome::not_attempted(out_of_corpus_reason(path)),
614        );
615    }
616
617    let Some(name) = frame.function.as_deref() else {
618        return (
619            FrameOrigin::InProject,
620            FrameOutcome::not_attempted(
621                "frame carries no function identifier to look up".to_string(),
622            ),
623        );
624    };
625
626    let Some(identifier) = identifier_parts(name) else {
627        return (
628            FrameOrigin::InProject,
629            FrameOutcome::not_attempted(format!("'{name}' is not an addressable identifier")),
630        );
631    };
632
633    (
634        FrameOrigin::InProject,
635        look_up(graph, context, &module_indexes, identifier, frame.line),
636    )
637}
638
639/// Why a frame that matched no module is out of corpus. The generated-output
640/// case is named specifically, because "not in the project" would read as a
641/// missing file when the real answer is "this is the compiled form of a file
642/// that IS in the project, and reading it back needs a source map".
643fn out_of_corpus_reason(path: &str) -> String {
644    if is_runtime_internal(path) {
645        return format!("'{path}' is a runtime internal, not project source");
646    }
647    if BUILD_OUTPUT_SEGMENTS
648        .iter()
649        .any(|segment| has_path_segment(path, segment))
650    {
651        return format!(
652            "'{path}' is generated build output; resolving it back to source needs a source map, which this command does not read"
653        );
654    }
655    format!("'{path}' is not a module in the analysed project")
656}
657
658/// Ask the graph which definitions an identifier names, and turn the answer
659/// into a frame outcome without preferring any one match.
660fn look_up(
661    graph: &ModuleGraph,
662    context: &mut TraceContext<'_>,
663    module_indexes: &[usize],
664    identifier: Identifier<'_>,
665    frame_line: Option<u32>,
666) -> FrameOutcome {
667    let name = identifier.printed;
668    let located = collect_candidates(
669        graph,
670        context.root,
671        module_indexes,
672        identifier.owner,
673        identifier.name,
674    );
675    let total = located.len();
676    let candidates_omitted = total.saturating_sub(MAX_FRAME_CANDIDATES);
677    let single_file_id = located.first().map(|candidate| candidate.file_id);
678    let candidates: Vec<ErrorTraceCandidate> = located
679        .into_iter()
680        .take(MAX_FRAME_CANDIDATES)
681        .map(|candidate| resolve_candidate_line(graph, candidate, &mut context.line_offsets))
682        .collect();
683
684    let mut line_mismatch = false;
685    let (resolution, mut reason) = match total {
686        0 => (
687            FrameResolution::NotFound,
688            format!(
689                "no definition named '{name}' is exported from the module this frame points at; a module-local function is not in the graph's definition set"
690            ),
691        ),
692        1 => {
693            let hit = &candidates[0];
694            let target = hit.member.as_ref().map_or_else(
695                || format!("{}:{}", hit.file, hit.symbol),
696                |member| format!("{}:{}.{member}", hit.file, hit.symbol),
697            );
698            (
699                FrameResolution::Resolved,
700                format!("'{name}' names {target} ({})", hit.kind),
701            )
702        }
703        _ => (
704            FrameResolution::Ambiguous,
705            format!("'{name}' names {total} definitions; none is preferred"),
706        ),
707    };
708
709    // The look-up matches on the identifier alone, so a single match is
710    // reported as `resolved` however far the frame's line sits from it. The
711    // frame's line is checked against the definitions of the file it points
712    // at, and a disagreement is stated rather than silently carried.
713    if resolution == FrameResolution::Resolved
714        && let Some(file_id) = single_file_id
715        && let Some(note) = line_mismatch_note(graph, context, file_id, &candidates[0], frame_line)
716    {
717        line_mismatch = true;
718        reason.push_str(&note);
719    }
720
721    FrameOutcome {
722        resolution,
723        candidates,
724        candidates_omitted,
725        line_mismatch,
726        reason,
727    }
728}
729
730/// Why the frame's own line disagrees with the definition its identifier
731/// matched, or `None` when the two agree or the comparison cannot be made.
732///
733/// The check is the cheapest one that answers the question a reader would ask
734/// next: which definition in this file is declared closest above the line the
735/// runtime reported? When that is the matched definition, the frame's line and
736/// the match tell the same story. When it is a DIFFERENT definition, the
737/// runtime was executing past a declaration the match does not cover, so the
738/// match is more likely a same-named definition elsewhere in the file. The
739/// frame stays `resolved`, because the graph's answer to the question asked is
740/// still correct; only the caller can decide what to do with the disagreement.
741fn line_mismatch_note(
742    graph: &ModuleGraph,
743    context: &mut TraceContext<'_>,
744    file_id: FileId,
745    candidate: &ErrorTraceCandidate,
746    frame_line: Option<u32>,
747) -> Option<String> {
748    let frame_line = frame_line?;
749    let candidate_line = candidate.line?;
750    let nearest = nearest_declaration_at_or_above(graph, context, file_id, frame_line);
751    if nearest
752        .as_ref()
753        .is_some_and(|(name, line)| *line == candidate_line && name == &candidate_label(candidate))
754    {
755        return None;
756    }
757    let matched = candidate_label(candidate);
758    Some(match nearest {
759        Some((name, line)) => format!(
760            "; the frame's line {frame_line} sits after the declaration of '{name}' at line {line}, not after '{matched}' at line {candidate_line}, so verify this is the definition that ran"
761        ),
762        None => format!(
763            "; the frame's line {frame_line} is above every definition this file declares, including '{matched}' at line {candidate_line}, so verify this is the definition that ran"
764        ),
765    })
766}
767
768/// The printed name of a candidate, matching how the frame's identifier reads.
769fn candidate_label(candidate: &ErrorTraceCandidate) -> String {
770    candidate.member.as_ref().map_or_else(
771        || candidate.symbol.clone(),
772        |member| format!("{}.{member}", candidate.symbol),
773    )
774}
775
776/// The definition declared closest at or above `line` in a module, as its
777/// printed name and 1-based declaration line.
778///
779/// Exports and their members are the graph's whole definition set, so this
780/// answers from what the look-up already consulted, using the line offsets the
781/// candidate resolution cached.
782fn nearest_declaration_at_or_above(
783    graph: &ModuleGraph,
784    context: &mut TraceContext<'_>,
785    file_id: FileId,
786    line: u32,
787) -> Option<(String, u32)> {
788    let module = graph.modules.get(file_id.0 as usize)?;
789    let offsets = read_line_offsets(graph, file_id, &mut context.line_offsets)?;
790    let declaration_line =
791        |start: u32| fallow_types::extract::byte_offset_to_line_col(offsets, start).0;
792    // The winner is tracked by position so a losing declaration never pays for
793    // a formatted name.
794    let mut nearest: Option<(usize, Option<usize>, u32)> = None;
795    let mut consider = |export_index: usize, member_index: Option<usize>, declared: u32| {
796        if declared <= line && nearest.is_none_or(|(_, _, best)| declared > best) {
797            nearest = Some((export_index, member_index, declared));
798        }
799    };
800    for (export_index, export) in module.exports.iter().enumerate() {
801        consider(export_index, None, declaration_line(export.span.start));
802        for (member_index, member) in export.members.iter().enumerate() {
803            consider(
804                export_index,
805                Some(member_index),
806                declaration_line(member.span.start),
807            );
808        }
809    }
810
811    let (export_index, member_index, declared) = nearest?;
812    let export = module.exports.get(export_index)?;
813    let name = match member_index.and_then(|index| export.members.get(index)) {
814        Some(member) => format!("{}.{}", export.name, member.name),
815        None => export.name.to_string(),
816    };
817    Some((name, declared))
818}
819
820/// A module's line offsets, computed at most once per trace.
821fn read_line_offsets<'a>(
822    graph: &ModuleGraph,
823    file_id: FileId,
824    line_offsets: &'a mut FxHashMap<FileId, Option<Vec<u32>>>,
825) -> Option<&'a Vec<u32>> {
826    line_offsets
827        .entry(file_id)
828        .or_insert_with(|| {
829            graph
830                .modules
831                .get(file_id.0 as usize)
832                .and_then(|module| std::fs::read_to_string(&module.path).ok())
833                .map(|source| fallow_types::extract::compute_line_offsets(&source))
834        })
835        .as_ref()
836}
837
838/// Every definition the frame's identifier could name, deterministically
839/// ordered and deduplicated.
840///
841/// Two rules run, and both are reported when both hit. An export whose name
842/// equals the frame's last segment is a candidate because V8 prints a synthetic
843/// receiver (`Object.run`) for a plain function; a member of an export named by
844/// the second-to-last segment is a candidate because that is what `Task.run`
845/// literally reads as. Choosing between them is the caller's judgment, not this
846/// command's.
847fn collect_candidates(
848    graph: &ModuleGraph,
849    root: &Path,
850    module_indexes: &[usize],
851    owner: Option<&str>,
852    name: &str,
853) -> Vec<LocatedCandidate> {
854    let mut candidates: Vec<LocatedCandidate> = Vec::new();
855    for &index in module_indexes {
856        let Some(module) = graph.modules.get(index) else {
857            continue;
858        };
859        let file = relativize(&module.path, root);
860        for export in &module.exports {
861            if export.name.matches_str(name) {
862                candidates.push(LocatedCandidate {
863                    file_id: module.file_id,
864                    file: file.clone(),
865                    symbol: export.name.to_string(),
866                    member: None,
867                    kind: "export",
868                    span_start: export.span.start,
869                });
870            }
871            if owner.is_some_and(|owner| export.name.matches_str(owner)) {
872                for member in &export.members {
873                    if member.name == name {
874                        candidates.push(LocatedCandidate {
875                            file_id: module.file_id,
876                            file: file.clone(),
877                            symbol: export.name.to_string(),
878                            member: Some(member.name.clone()),
879                            kind: member_kind_label(member.kind),
880                            span_start: member.span.start,
881                        });
882                    }
883                }
884            }
885        }
886    }
887    candidates.sort_by(|left, right| {
888        left.file
889            .cmp(&right.file)
890            .then_with(|| left.symbol.cmp(&right.symbol))
891            .then_with(|| left.member.cmp(&right.member))
892    });
893    candidates.dedup_by(|left, right| {
894        left.file == right.file && left.symbol == right.symbol && left.member == right.member
895    });
896    candidates
897}
898
899/// Turn a candidate's declaration span into a 1-based line, reading each source
900/// file at most once per trace. A file that cannot be read yields no line
901/// rather than a guessed one.
902fn resolve_candidate_line(
903    graph: &ModuleGraph,
904    candidate: LocatedCandidate,
905    line_offsets: &mut FxHashMap<FileId, Option<Vec<u32>>>,
906) -> ErrorTraceCandidate {
907    let line = read_line_offsets(graph, candidate.file_id, line_offsets).map(|offsets| {
908        fallow_types::extract::byte_offset_to_line_col(offsets, candidate.span_start).0
909    });
910    ErrorTraceCandidate {
911        file: candidate.file,
912        symbol: candidate.symbol,
913        member: candidate.member,
914        kind: candidate.kind.to_string(),
915        line,
916    }
917}
918
919/// Resolve a stack trace through an existing analysis session.
920///
921/// # Errors
922///
923/// Returns an error if parsing or graph construction fails.
924pub fn trace_error_with_session(
925    session: &crate::session::AnalysisSession,
926    input: &str,
927    source: String,
928) -> crate::EngineResult<ErrorTrace> {
929    let output = session.analyze_dead_code_with_shared_artifacts(false, true)?;
930    let graph = output
931        .graph
932        .as_ref()
933        .ok_or_else(|| crate::EngineError::new("trace-error requires a retained module graph"))?;
934    Ok(resolve_stack_trace(
935        graph,
936        session.root(),
937        parse_stack_trace(input),
938        source,
939    ))
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945
946    fn frames(input: &str) -> Vec<RawFrame> {
947        parse_stack_trace(input).frames
948    }
949
950    #[test]
951    fn reads_the_v8_frame_forms() {
952        let parsed = parse_stack_trace(
953            "TypeError: user.load is not a function\n\
954             \x20   at loadUser (/proj/src/services/user.ts:42:11)\n\
955             \x20   at async UserService.load (/proj/src/user.ts:10:3)\n\
956             \x20   at new Widget (/proj/src/widget.tsx:5:9)\n\
957             \x20   at /proj/src/bare.ts:7:2\n",
958        );
959
960        assert_eq!(
961            parsed.header.as_deref(),
962            Some("TypeError: user.load is not a function")
963        );
964        assert_eq!(parsed.unparsed_lines, 0);
965        assert_eq!(parsed.frames.len(), 4);
966
967        assert_eq!(parsed.frames[0].function.as_deref(), Some("loadUser"));
968        assert_eq!(
969            parsed.frames[0].file.as_deref(),
970            Some("/proj/src/services/user.ts")
971        );
972        assert_eq!(parsed.frames[0].line, Some(42));
973        assert_eq!(parsed.frames[0].column, Some(11));
974
975        assert_eq!(
976            parsed.frames[1].function.as_deref(),
977            Some("UserService.load")
978        );
979        assert!(parsed.frames[1].is_async);
980        assert!(!parsed.frames[1].is_constructor);
981
982        assert_eq!(parsed.frames[2].function.as_deref(), Some("Widget"));
983        assert!(parsed.frames[2].is_constructor);
984
985        assert_eq!(parsed.frames[3].function, None);
986        assert_eq!(parsed.frames[3].file.as_deref(), Some("/proj/src/bare.ts"));
987        assert_eq!(parsed.frames[3].line, Some(7));
988    }
989
990    #[test]
991    fn reads_the_at_sign_frame_form() {
992        let parsed = parse_stack_trace(
993            "loadUser@/proj/src/user.ts:42:11\n@/proj/src/index.ts:3:1\nboot@https://app.test/assets/main.js:1:20\n",
994        );
995
996        assert_eq!(parsed.frames.len(), 3);
997        assert_eq!(parsed.frames[0].function.as_deref(), Some("loadUser"));
998        assert_eq!(parsed.frames[0].file.as_deref(), Some("/proj/src/user.ts"));
999        assert_eq!(parsed.frames[0].line, Some(42));
1000        assert_eq!(parsed.frames[1].function, None);
1001        assert_eq!(
1002            parsed.frames[2].file.as_deref(),
1003            Some("/assets/main.js"),
1004            "an http location keeps its path component and drops the authority"
1005        );
1006    }
1007
1008    #[test]
1009    fn a_line_with_a_stray_at_sign_is_not_a_frame() {
1010        let parsed = parse_stack_trace("reported by dev@example.com\n");
1011
1012        assert!(parsed.frames.is_empty());
1013        assert_eq!(
1014            parsed.header.as_deref(),
1015            Some("reported by dev@example.com")
1016        );
1017        assert_eq!(parsed.unparsed_lines, 0);
1018    }
1019
1020    #[test]
1021    fn unrecognised_lines_are_counted_never_dropped() {
1022        let parsed = parse_stack_trace("Error: boom\nnot a frame\nalso not a frame\n");
1023
1024        assert!(parsed.frames.is_empty());
1025        assert_eq!(parsed.header.as_deref(), Some("Error: boom"));
1026        assert_eq!(parsed.unparsed_lines, 2);
1027    }
1028
1029    #[test]
1030    fn an_empty_input_parses_to_nothing() {
1031        let parsed = parse_stack_trace("");
1032
1033        assert_eq!(parsed, ParsedStackTrace::default());
1034    }
1035
1036    #[test]
1037    fn the_frameless_reason_counts_the_header_line_it_describes() {
1038        // Three non-blank lines in, none of them a frame: the first is taken as
1039        // the header and reported separately, so `unparsed_lines` is 2. A
1040        // sentence about the INPUT must still say three, or its number
1041        // contradicts what the caller pasted.
1042        let parsed = parse_stack_trace("something went wrong\nsee the logs\nand the dashboard\n");
1043        assert_eq!(parsed.unparsed_lines, 2);
1044        let counts = ErrorTraceCounts {
1045            unparsed_lines: parsed.unparsed_lines,
1046            ..ErrorTraceCounts::default()
1047        };
1048
1049        assert_eq!(
1050            summary_reason(&counts, parsed.header.is_some()),
1051            "no stack frames recognised in 3 non-blank input lines"
1052        );
1053    }
1054
1055    #[test]
1056    fn a_single_unrecognised_line_is_reported_as_one_input_line() {
1057        let parsed = parse_stack_trace("something went wrong\n");
1058        assert_eq!(parsed.unparsed_lines, 0);
1059        let counts = ErrorTraceCounts {
1060            unparsed_lines: parsed.unparsed_lines,
1061            ..ErrorTraceCounts::default()
1062        };
1063
1064        assert_eq!(
1065            summary_reason(&counts, parsed.header.is_some()),
1066            "no stack frames recognised in 1 non-blank input line"
1067        );
1068    }
1069
1070    #[test]
1071    fn an_input_with_no_lines_at_all_still_reads_as_empty() {
1072        let counts = ErrorTraceCounts::default();
1073
1074        assert_eq!(
1075            summary_reason(&counts, false),
1076            "no stack frames in the input"
1077        );
1078    }
1079
1080    #[test]
1081    fn the_frame_reason_marks_unparsed_lines_as_further_than_what_it_listed() {
1082        // With frames present the header and the frames are already reported
1083        // above the sentence, so the trailing count is explicitly the remainder
1084        // rather than a second claim about the whole input.
1085        let counts = ErrorTraceCounts {
1086            frames: 1,
1087            resolved: 1,
1088            unparsed_lines: 2,
1089            ..ErrorTraceCounts::default()
1090        };
1091
1092        assert_eq!(
1093            summary_reason(&counts, true),
1094            "1 frame: 1 resolved, 0 ambiguous, 0 not found, 0 not attempted; \
1095             2 further input lines not recognised as a frame"
1096        );
1097    }
1098
1099    #[test]
1100    fn a_native_or_anonymous_location_carries_no_path() {
1101        let parsed = frames("    at doThing (native)\n    at Array.forEach (<anonymous>)\n");
1102
1103        assert_eq!(parsed.len(), 2);
1104        assert_eq!(parsed[0].file, None);
1105        assert_eq!(parsed[0].line, None);
1106        assert_eq!(parsed[1].file, None);
1107    }
1108
1109    #[test]
1110    fn a_file_url_keeps_a_windows_drive_letter() {
1111        let parsed = frames(
1112            "    at run (file:///C:/proj/src/a.ts:3:4)\n    at run (file:///proj/src/b.ts:5:6)\n",
1113        );
1114
1115        assert_eq!(parsed[0].file.as_deref(), Some("C:/proj/src/a.ts"));
1116        assert_eq!(parsed[0].line, Some(3));
1117        assert_eq!(parsed[1].file.as_deref(), Some("/proj/src/b.ts"));
1118    }
1119
1120    #[test]
1121    fn a_backslash_windows_path_is_forward_slashed_and_keeps_its_numbers() {
1122        let parsed = frames("    at run (C:\\proj\\src\\a.ts:12:4)\n");
1123
1124        assert_eq!(parsed[0].file.as_deref(), Some("C:/proj/src/a.ts"));
1125        assert_eq!(parsed[0].line, Some(12));
1126        assert_eq!(parsed[0].column, Some(4));
1127    }
1128
1129    #[test]
1130    fn a_node_internal_frame_keeps_its_scheme() {
1131        let parsed = frames(
1132            "    at process.processTicksAndRejections (node:internal/process/task_queues:95:5)\n",
1133        );
1134
1135        assert_eq!(
1136            parsed[0].file.as_deref(),
1137            Some("node:internal/process/task_queues")
1138        );
1139        assert!(is_runtime_internal("node:internal/process/task_queues"));
1140    }
1141
1142    #[test]
1143    fn a_location_without_a_column_puts_its_number_on_the_line() {
1144        let parsed = frames("    at run (/proj/src/a.ts:12)\n");
1145
1146        assert_eq!(parsed[0].file.as_deref(), Some("/proj/src/a.ts"));
1147        assert_eq!(parsed[0].line, Some(12));
1148        assert_eq!(parsed[0].column, None);
1149    }
1150
1151    #[test]
1152    fn identifier_parts_split_on_the_last_dot() {
1153        let parts = |name| identifier_parts(name).map(|id| (id.owner, id.name));
1154        assert_eq!(parts("run"), Some((None, "run")));
1155        assert_eq!(parts("Task.run"), Some((Some("Task"), "run")));
1156        assert_eq!(parts("ns.Task.run"), Some((Some("Task"), "run")));
1157    }
1158
1159    #[test]
1160    fn identifier_parts_reject_non_addressable_names() {
1161        for name in [
1162            "Object.<anonymous>",
1163            "outer/<",
1164            "global code",
1165            "Task..run",
1166            "",
1167        ] {
1168            assert_eq!(identifier_parts(name), None, "name was {name:?}");
1169        }
1170    }
1171
1172    #[test]
1173    fn an_absolute_frame_path_resolves_through_a_symlinked_root() {
1174        let dir = tempfile::tempdir().expect("tempdir");
1175        let real = dir.path().join("real/src");
1176        std::fs::create_dir_all(&real).expect("project dirs");
1177        std::fs::write(real.join("index.ts"), "export const run = () => 0;\n").expect("source");
1178        let linked = dir.path().join("linked");
1179        #[cfg(unix)]
1180        std::os::unix::fs::symlink(dir.path().join("real"), &linked).expect("symlink");
1181        #[cfg(windows)]
1182        std::os::windows::fs::symlink_dir(dir.path().join("real"), &linked).expect("symlink");
1183
1184        let root = dir.path().join("real");
1185        let mut context = TraceContext::new(&root);
1186        let frame = linked
1187            .join("src/index.ts")
1188            .to_string_lossy()
1189            .replace('\\', "/");
1190
1191        assert_eq!(
1192            context.root_relative(&frame).as_deref(),
1193            Some("src/index.ts"),
1194            "a frame spelled through a symlink must resolve to the module's own \
1195             root-relative path"
1196        );
1197    }
1198
1199    #[test]
1200    fn a_relative_or_missing_frame_path_is_never_rewritten() {
1201        let dir = tempfile::tempdir().expect("tempdir");
1202        let mut context = TraceContext::new(dir.path());
1203
1204        assert_eq!(context.root_relative("src/index.ts"), None);
1205        assert_eq!(
1206            context
1207                .root_relative(&dir.path().join("gone.ts").to_string_lossy())
1208                .as_deref(),
1209            None,
1210            "a path that does not exist resolves to nothing rather than to \
1211             something else"
1212        );
1213    }
1214
1215    #[test]
1216    fn path_segment_matching_is_component_wise() {
1217        assert!(has_path_segment(
1218            "/proj/node_modules/x/index.js",
1219            "node_modules"
1220        ));
1221        assert!(!has_path_segment(
1222            "/proj/my_node_modules_shim/a.js",
1223            "node_modules"
1224        ));
1225        assert!(has_path_segment("/proj/dist/main.js", "dist"));
1226        assert!(!has_path_segment("/proj/src/distance.ts", "dist"));
1227    }
1228}