Skip to main content

fallow_types/
trace_error.rs

1//! Stack-trace frame resolution contract for `fallow trace-error`.
2//!
3//! The verb answers one narrow question: for each frame of a runtime stack
4//! trace, which definitions in the analysed project does that frame's
5//! identifier name? It is deliberately easy to overclaim here, so the wire
6//! shape is built so that it cannot:
7//!
8//! - a frame that matches several definitions reports `ambiguous` and lists
9//!   every candidate, instead of picking one and calling it the answer;
10//! - a frame that matches nothing reports `not_found` instead of being dropped;
11//! - a frame that matches one definition but whose own line sits at a
12//!   different declaration carries `line_mismatch`, because the look-up asks
13//!   about the identifier and not about the line;
14//! - a frame the project graph was never asked about (a dependency frame, a
15//!   runtime-internal frame, a generated bundle, or a frame carrying no
16//!   identifier to look up) reports `not_attempted` rather than borrowing
17//!   `not_found`'s meaning;
18//! - every frame read from the input appears in `frames`, in input order, and
19//!   [`ErrorTraceCounts`](crate::trace_error::ErrorTraceCounts) publishes the
20//!   per-outcome totals, so a caller can see exactly how much of its trace
21//!   went unanswered.
22//!
23//! No source-map resolution is performed. A frame pointing into a build
24//! artifact is reported as such, because a stale map rebinds silently to the
25//! wrong line and a wrong line is worse than an honest refusal.
26
27use serde::Serialize;
28
29/// Wire-version discriminator for [`ErrorTrace`]. Independent from the global
30/// `SchemaVersion` and from the other trace payloads, like
31/// [`crate::trace::ImportPathTraceSchemaVersion`]. Serializes as a string
32/// `const` so JSON consumers can switch on it.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
34#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
35pub enum ErrorTraceSchemaVersion {
36    /// First release of the `fallow trace-error` shape.
37    #[serde(rename = "1")]
38    V1,
39}
40
41/// Where a frame's source location sits relative to the analysed project.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
43#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
44#[serde(rename_all = "snake_case")]
45pub enum FrameOrigin {
46    /// The frame's file resolved to at least one module in the project graph.
47    /// Only these frames are looked up against the graph's definitions.
48    InProject,
49    /// The frame's file lives under an installed dependency tree.
50    NodeModules,
51    /// Everything else: a runtime-internal frame, a generated bundle, a file
52    /// outside the analysed corpus, or a frame carrying no source location.
53    OutOfCorpus,
54}
55
56impl FrameOrigin {
57    /// Stable kebab-case token for human output and diagnostics.
58    #[must_use]
59    pub const fn label(self) -> &'static str {
60        match self {
61            Self::InProject => "in-project",
62            Self::NodeModules => "node-modules",
63            Self::OutOfCorpus => "out-of-corpus",
64        }
65    }
66}
67
68/// What the project graph could say about a frame's identifier.
69///
70/// `not_attempted` is not a softer `not_found`: it records that the graph was
71/// never consulted, because the frame does not point at project source. Keeping
72/// them apart is what lets `resolved + ambiguous + not_found + not_attempted`
73/// equal the frame count without any of the four lying about what it measured.
74#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
75#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
76#[serde(rename_all = "snake_case")]
77pub enum FrameResolution {
78    /// Exactly one definition matched. `candidates` holds that one entry.
79    Resolved,
80    /// More than one definition matched. Every match is listed and none is
81    /// preferred; the caller decides, or narrows the question.
82    Ambiguous,
83    /// The graph was asked and knows no definition under this identifier. A
84    /// module-local function is not in the graph's definition set, so this is
85    /// also the answer for a frame naming one.
86    NotFound,
87    /// The graph was not asked. Either the frame does not point at project
88    /// source, or it points at project source but carries nothing addressable
89    /// to ask about: no printed function name, or a placeholder such as
90    /// `Object.<anonymous>`. `reason` names which case applies, so this is
91    /// never a silent shrug.
92    NotAttempted,
93}
94
95impl FrameResolution {
96    /// Stable kebab-case token for human output and diagnostics.
97    #[must_use]
98    pub const fn label(self) -> &'static str {
99        match self {
100            Self::Resolved => "resolved",
101            Self::Ambiguous => "ambiguous",
102            Self::NotFound => "not-found",
103            Self::NotAttempted => "not-attempted",
104        }
105    }
106}
107
108/// One definition a frame's identifier could name.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
110#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
111pub struct ErrorTraceCandidate {
112    /// Root-relative file declaring the definition.
113    pub file: String,
114    /// The exported name. For a member match this is the owning export.
115    pub symbol: String,
116    /// The member name, when the frame's identifier named a member of
117    /// `symbol` rather than `symbol` itself. Absent for a direct export match.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub member: Option<String>,
120    /// What kind of definition this is: `export`, or the member kind
121    /// (`class-method`, `class-property`, `enum-member`, `store-member`,
122    /// `namespace-member`).
123    pub kind: String,
124    /// 1-based declaration line of the definition's identifier. Absent when the
125    /// source file could not be read; never guessed.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub line: Option<u32>,
128}
129
130/// One frame read from the input stack trace.
131#[derive(Debug, Clone, Serialize)]
132#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
133pub struct ErrorTraceFrame {
134    /// 0-based position in the input trace, so a caller can quote a frame back
135    /// even after filtering the array.
136    pub index: usize,
137    /// The input line this frame was read from, trimmed of surrounding
138    /// whitespace and otherwise verbatim.
139    pub raw: String,
140    /// The frame's function identifier as written by the runtime, with the
141    /// `async` and `new` markers stripped and recorded separately. Absent for a
142    /// frame the runtime emitted without one.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub function: Option<String>,
145    /// Whether the runtime marked this frame as a constructor call (`new X`).
146    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
147    pub is_constructor: bool,
148    /// Whether the runtime marked this frame as an async call.
149    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
150    pub is_async: bool,
151    /// The frame's file as read from the trace, with any `file://` or
152    /// `http(s)://` wrapper removed and separators forward-slashed. Reported as
153    /// read: it is NOT rewritten to the module path it matched, so a caller can
154    /// see what its runtime actually said. Absent for a frame with no location.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub file: Option<String>,
157    /// 1-based line from the frame's location, when the runtime supplied one.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub line: Option<u32>,
160    /// 1-based column from the frame's location, when the runtime supplied one.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub column: Option<u32>,
163    /// Where the frame's file sits relative to the analysed project.
164    pub origin: FrameOrigin,
165    /// What the project graph could say about this frame's identifier.
166    pub resolution: FrameResolution,
167    /// Every definition the identifier could name, in deterministic order.
168    /// Exactly one entry when `resolution` is `resolved`, more than one when it
169    /// is `ambiguous`, and empty otherwise.
170    pub candidates: Vec<ErrorTraceCandidate>,
171    /// How many further candidates a presentation cap withheld.
172    /// `candidates.len() + candidates_omitted` is the true match count, so an
173    /// `ambiguous` frame never understates how ambiguous it is.
174    pub candidates_omitted: usize,
175    /// Set when this frame's own line disagrees with the definition its
176    /// identifier matched: some OTHER definition in the same file is declared
177    /// closer above the line the runtime reported.
178    ///
179    /// The look-up matches on the identifier alone, so a `resolved` frame is
180    /// resolved however far its line sits from the match. That is honest about
181    /// the question asked and silent about a question a reader would ask next,
182    /// which is why the disagreement is published instead of left to be
183    /// noticed. The frame is NOT reclassified: the graph does know a
184    /// definition under this identifier, and only the caller can say whether
185    /// the runtime ran that one or a same-named definition elsewhere.
186    /// `reason` names the declaration that sits closer. Only set on a
187    /// `resolved` frame that carried a line and matched a definition whose own
188    /// line could be read.
189    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
190    pub line_mismatch: bool,
191    /// Human-readable statement of what happened to this frame.
192    pub reason: String,
193}
194
195/// Per-outcome totals for an [`ErrorTrace`].
196///
197/// `resolved + ambiguous + not_found + not_attempted == frames`, and
198/// `in_project + node_modules + out_of_corpus == frames`. Both identities hold
199/// on every run, so a caller can verify that nothing was dropped.
200#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
201#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
202pub struct ErrorTraceCounts {
203    /// Frames reported in `frames`.
204    pub frames: usize,
205    /// Frames a cap withheld from `frames`. Their outcomes are NOT counted in
206    /// the fields below, which describe the reported frames only.
207    pub frames_omitted: usize,
208    /// Frames whose file resolved to project source.
209    pub in_project: usize,
210    /// Frames whose file lives under an installed dependency tree.
211    pub node_modules: usize,
212    /// Frames outside the analysed corpus, including frames with no location.
213    pub out_of_corpus: usize,
214    /// Frames that matched exactly one definition.
215    pub resolved: usize,
216    /// Frames that matched more than one definition.
217    pub ambiguous: usize,
218    /// Frames the graph was asked about and could not name.
219    pub not_found: usize,
220    /// Frames the graph was never asked about.
221    pub not_attempted: usize,
222    /// Non-blank input lines that were neither recognised as a frame nor taken
223    /// as `header`. A trace that is entirely unrecognised reports zero frames
224    /// and a non-zero count here, rather than looking like an empty trace.
225    pub unparsed_lines: usize,
226}
227
228/// Result of resolving a runtime stack trace against the project graph.
229#[derive(Debug, Clone, Serialize)]
230#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
231#[cfg_attr(feature = "schema", schemars(title = "fallow trace-error"))]
232pub struct ErrorTrace {
233    /// Wire-shape version of this payload.
234    pub schema_version: ErrorTraceSchemaVersion,
235    /// Where the trace was read from: `stdin`, or the path as the caller wrote
236    /// it.
237    pub source: String,
238    /// The first non-blank input line that preceded any recognised frame,
239    /// verbatim. Conventionally the error type and message, but it is reported
240    /// as read and NOT parsed into parts. Absent when the input began with a
241    /// frame or was empty.
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub header: Option<String>,
244    /// Every recognised frame, in input order. Nothing is filtered out: a
245    /// dependency or runtime-internal frame stays in the array with its origin
246    /// recorded, so hop numbering matches the trace the caller pasted.
247    pub frames: Vec<ErrorTraceFrame>,
248    /// Per-outcome totals.
249    pub counts: ErrorTraceCounts,
250    /// Human-readable summary of the outcome.
251    pub reason: String,
252}