1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
//! `fallow trace-error [FILE|-]`: resolve a runtime stack trace's frames
//! against the project graph.
//!
//! Its own surface (`kind: "trace-error"`, `schema_version: "1"`), like the
//! other trace shapes: never folded into the ranked brief and never an input to
//! the focus map. A trace nothing in it resolves is an ANSWER, not an error, so
//! it exits 0 and publishes the counts that say so; only unreadable input or a
//! failed analysis exits 2.
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use fallow_config::{OutputFormat, ProductionAnalysis};
use fallow_engine::trace_error::MAX_STACK_TRACE_BYTES;
use fallow_types::trace_error::{ErrorTrace, FrameResolution};
use crate::error::emit_error;
use crate::report;
use crate::report::sink::outln;
use crate::{ConfigLoadOptions, load_config_for_analysis};
/// The stdin sentinel, matching `--diff-file -`.
const STDIN_SENTINEL: &str = "-";
/// Options for `fallow trace-error`.
pub struct TraceErrorOptions<'a> {
pub root: &'a Path,
pub config_path: &'a Option<PathBuf>,
pub output: OutputFormat,
pub json_style: crate::json_style::JsonStyle,
pub no_cache: bool,
pub threads: usize,
pub quiet: bool,
pub allow_remote_extends: bool,
/// The trace file, or `-` / `None` to read stdin.
pub trace_file: Option<&'a str>,
}
/// Read the stack trace, resolve its frames, and emit the result.
pub fn run_trace_error(opts: &TraceErrorOptions<'_>) -> ExitCode {
let (input, source) = match read_trace(opts.root, opts.trace_file) {
Ok(pair) => pair,
Err((message, hint)) => {
return crate::error::emit_error_with_hint(&message, hint, 2, opts.output);
}
};
let config = match load_config_for_analysis(
opts.root,
opts.config_path,
ConfigLoadOptions {
output: opts.output,
no_cache: opts.no_cache,
threads: opts.threads,
production_override: None,
quiet: opts.quiet,
allow_remote_extends: opts.allow_remote_extends,
},
ProductionAnalysis::DeadCode,
) {
Ok(config) => config,
Err(code) => return code,
};
let session = match fallow_engine::session::AnalysisSession::from_resolved_config(config) {
Ok(session) => session,
Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
};
let trace = match fallow_engine::trace_error::trace_error_with_session(&session, &input, source)
{
Ok(trace) => trace,
Err(err) => return emit_error(&format!("Analysis error: {err}"), 2, opts.output),
};
emit_trace_error(trace, opts)
}
/// Read the trace from a file or from stdin, returning the text and the label
/// the payload reports as its `source`.
///
/// A relative path is resolved against the project root, matching how
/// `--diff-file` resolves its input. The reported `source` keeps the caller's
/// own spelling rather than the resolved absolute path.
///
/// Every failure carries the remedy for it. `Err` is `(message, hint)`, which
/// the caller renders in the shared `Error: ... hint: ...` shape.
fn read_trace(
root: &Path,
trace_file: Option<&str>,
) -> Result<(String, String), (String, &'static str)> {
let path = match trace_file {
None | Some(STDIN_SENTINEL) => {
let mut buffer = Vec::new();
std::io::stdin()
.take(MAX_STACK_TRACE_BYTES + 1)
.read_to_end(&mut buffer)
.map_err(|err| {
(
format!("failed to read stack trace from stdin: {err}"),
PIPE_HINT,
)
})?;
if buffer.len() as u64 > MAX_STACK_TRACE_BYTES {
return Err((
format!(
"stack trace from stdin exceeds the {MAX_STACK_TRACE_BYTES}-byte limit"
),
TRIM_HINT,
));
}
let text = String::from_utf8(buffer).map_err(|_| {
(
"stack trace from stdin is not valid UTF-8".to_string(),
"pipe the trace as text; a captured binary log or a mixed encoding cannot be \
parsed",
)
})?;
return Ok((text, "stdin".to_string()));
}
Some(path) => path,
};
let resolved = {
let candidate = Path::new(path);
if candidate.is_absolute() {
candidate.to_path_buf()
} else {
root.join(candidate)
}
};
let file = std::fs::File::open(&resolved).map_err(|err| {
(
format!("failed to read stack trace from '{path}': {err}"),
PIPE_HINT,
)
})?;
let mut buffer = Vec::new();
file.take(MAX_STACK_TRACE_BYTES + 1)
.read_to_end(&mut buffer)
.map_err(|err| {
(
format!("failed to read stack trace from '{path}': {err}"),
PIPE_HINT,
)
})?;
if buffer.len() as u64 > MAX_STACK_TRACE_BYTES {
return Err((
format!("stack trace '{path}' exceeds the {MAX_STACK_TRACE_BYTES}-byte limit"),
TRIM_HINT,
));
}
let text = String::from_utf8(buffer).map_err(|_| {
(
format!("stack trace from '{path}' is not valid UTF-8"),
PIPE_HINT,
)
})?;
Ok((text, path.to_string()))
}
fn emit_trace_error(trace: ErrorTrace, opts: &TraceErrorOptions<'_>) -> ExitCode {
match opts.output {
OutputFormat::Json => {
let value = match fallow_output::serialize_trace_error_json_output(
trace,
crate::output_runtime::current_root_envelope_mode(),
crate::output_runtime::telemetry_analysis_run_id().as_deref(),
) {
Ok(value) => value,
Err(err) => {
return emit_error(
&format!("failed to serialize trace-error output: {err}"),
2,
opts.output,
);
}
};
report::emit_report_json(&value, "trace-error", opts.json_style)
}
OutputFormat::Human => {
print_human(&trace, opts.quiet);
ExitCode::SUCCESS
}
_ => crate::error::emit_error_with_hint(
"trace-error supports --format json or human",
"re-run with `--format json` for a machine-readable answer, or drop `--format`",
2,
opts.output,
),
}
}
/// The next step for an input that produced no frames, and the one thing the
/// help text does not make obvious at the point of failure.
const PIPE_HINT: &str =
"pass a stack-trace file, or pipe one: `node app.js 2>&1 | fallow trace-error -`";
/// The remedy for an input over the size ceiling. The frames that matter sit at
/// the top of a stack, so trimming is a real fix rather than a workaround.
const TRIM_HINT: &str =
"keep the top frames and drop the rest; the innermost frames are the ones this resolves";
fn print_human(trace: &ErrorTrace, quiet: bool) {
outln!("Stack-trace frames (syntactic; OFF the ranked path)");
outln!();
outln!(" source: {}", trace.source);
if let Some(header) = &trace.header {
outln!(" error: {header}");
}
outln!();
if trace.frames.is_empty() {
print_empty_human(trace);
return;
}
// A reason explains a CLASS of frame, and a stack is usually one class
// repeated. Printing it per frame turned a 60-frame node_modules stack into
// 60 byte-identical lines between the reader and the counts. Each frame
// still carries its own `[origin/resolution]` labels, so a suppressed
// repeat loses nothing; a reason that CHANGES prints again.
let mut last_reason: Option<&str> = None;
for frame in &trace.frames {
let location = match (&frame.file, frame.line) {
(Some(file), Some(line)) => format!("{file}:{line}"),
(Some(file), None) => file.clone(),
(None, _) => "<no location>".to_string(),
};
outln!(
" [{}] {} {} [{}/{}]",
frame.index,
frame.function.as_deref().unwrap_or("<anonymous>"),
location,
frame.origin.label(),
frame.resolution.label()
);
for candidate in &frame.candidates {
// An ambiguous frame prints every candidate. Printing only the
// first would restate the exact overclaim the payload refuses.
let member = candidate
.member
.as_ref()
.map_or_else(String::new, |member| format!(".{member}"));
let line = candidate
.line
.map_or_else(String::new, |line| format!(":{line}"));
outln!(
" -> {}{} {}{} ({})",
candidate.file,
line,
candidate.symbol,
member,
candidate.kind
);
}
if frame.candidates_omitted > 0 {
outln!(
" -> {} further matches omitted",
frame.candidates_omitted
);
}
// A resolved frame normally needs no explanation, but one whose own
// line disagrees with the definition it matched does: the note is the
// only place that disagreement is written out.
if (frame.resolution != FrameResolution::Resolved || frame.line_mismatch)
&& last_reason != Some(frame.reason.as_str())
{
outln!(" {}", frame.reason);
last_reason = Some(&frame.reason);
}
}
outln!();
outln!("{}", counts_line(&trace.counts));
// Prose, like the other trace surfaces: `--quiet` drops the explanation from
// human output while the JSON payload keeps it either way.
if !quiet {
outln!();
outln!("{}", trace.reason);
}
}
/// The empty state: one statement of the fact, then what to do about it.
///
/// It used to state the same thing three times (a literal `No stack frames
/// recognised.`, a counts line that was all zeroes, and the prose `reason`) and
/// then stop, with no next step and nothing saying the input can be piped. The
/// unparsed-line count is folded into the sentence rather than left on a counts
/// line so it survives `--quiet`, which drops prose.
fn print_empty_human(trace: &ErrorTrace) {
let unparsed = trace.counts.unparsed_lines;
if unparsed > 0 {
outln!(
"No stack frames recognised ({unparsed} input line{} did not parse as a frame).",
if unparsed == 1 { "" } else { "s" }
);
} else {
outln!("No stack frames recognised.");
}
outln!(" hint: {PIPE_HINT}");
}
/// The always-printed counts line.
///
/// `frames_omitted` and `unparsed_lines` are MEASUREMENTS, not progress, so
/// they belong here rather than in the prose `--quiet` drops. Without them a
/// capped trace looks complete and an input in which nothing was recognised
/// looks like an empty trace, which is exactly the overclaim this payload is
/// built to refuse. They print only when non-zero: zero omissions is the
/// ordinary case and a permanent column of zeroes would bury the run where the
/// number is not zero.
fn counts_line(counts: &fallow_types::trace_error::ErrorTraceCounts) -> String {
use std::fmt::Write as _;
let mut line = format!(
" frames {} | resolved {} | ambiguous {} | not found {} | not attempted {}",
counts.frames, counts.resolved, counts.ambiguous, counts.not_found, counts.not_attempted
);
if counts.frames_omitted > 0 {
let _ = write!(line, " | frames omitted {}", counts.frames_omitted);
}
if counts.unparsed_lines > 0 {
let _ = write!(line, " | unparsed lines {}", counts.unparsed_lines);
}
line
}