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}