Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 45 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
- 🎨 **Beautiful UI** – High-visibility terminal output powered by `boxen` and `chalk`.
- 🎭 **Color Themes** – 5 built-in themes (`default`, `minimal`, `high-contrast`, `dracula`, `monokai`).
- 🔍 **Verbose Mode** – Extra diagnostic details with `--verbose` for debugging custom patterns.
- 📍 **Stack Trace Parsing** – Extract file/line/column from stack traces, with source snippets via `--context`.
- 🤖 **CI/CD Ready** – Export raw data via `--json` for automated error reporting.

---
Expand Down Expand Up @@ -92,6 +93,15 @@ errlens analyze "is not a function" --theme dracula

# Show verbose diagnostic info
errlens analyze "Cannot read properties of undefined" --verbose

# Show the source location and context from a stack trace
errlens analyze "TypeError: arr is not a function
at handleData (src/index.ts:12:5)
at main (src/index.ts:20:3)" --context

# Print stack-trace locations to JSON
errlens analyze "TypeError: arr is not a function
at handleData (src/index.ts:12:5)" --json
```

---
Expand All @@ -114,6 +124,7 @@ errlens --help # Show help
| `--json` | Output raw JSON (no colors, no boxes) — perfect for CI/CD pipelines |
| `--theme <name>` | Pick a color theme (see the Color Themes section below) |
| `--verbose` | Show extra diagnostic info for each matched error |
| `--context` | Show a source snippet around the error location parsed from the stack trace |
| `--lang <code>` | Output language (e.g. `hi`, `es`, `fr`) — available on `run` and `analyze` |

### 1️⃣ Automatic Monitoring (The "Pro" Way)
Expand Down Expand Up @@ -158,7 +169,8 @@ Example response from `run`:
{
"code": 0,
"count": 0,
"matches": []
"matches": [],
"locations": []
}
```

Expand All @@ -180,10 +192,42 @@ Example response from `analyze <errorString>` (match found):
],
"example": "const name = user?.name || 'Guest';"
}
],
"locations": [
{
"function": "readFoo",
"file": "/home/user/app/src/index.js",
"line": 12,
"column": 5
}
]
}
```

`locations` is an array of stack frames parsed from the error. When no stack trace is present, it is an empty array.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

#### Stack Trace Context

When the error string contains a Node-style stack trace, ErrLens extracts the frame details (function name, file, line, column) automatically. Use `--context` to print a 5-line snippet around the first application frame:

```bash
errlens analyze "TypeError: arr.join is not a function
at handleData (src/index.ts:12:5)
at main (src/index.ts:20:3)" --context
```

```text
📍 LOCATION: handleData (src/index.ts:12:5)
📍 CONTEXT: src/index.ts:12
10 const raw = fetchRows();
11 const rows = raw.map(normalize);
12 > const output = rows.join(',');
13 return output;
14 }
```

Internal frames (`node:internal/...`, `at <anonymous>`) are skipped when choosing the context location. Source snippets are only read from regular files inside the current working directory: `file://` locations are decoded, but paths outside the project root, symlink escapes, non-regular files, and files over 256 KiB are rejected and no snippet is shown.

Exit codes (useful for CI):

- `run <file>` exits with the child process exit code.
Expand Down
18 changes: 12 additions & 6 deletions bin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const path = require("path");
const fs = require("fs");
const { findError } = require("../lib/matcher");
const { formatError } = require("../lib/formatter");
const { parseStackTrace } = require("../lib/stack-parser");
const { THEME_NAMES, isValidTheme, resolveTheme } = require("../lib/themes");
const { version } = require("../package.json");

Expand All @@ -23,7 +24,8 @@ program
`Color theme (${THEME_NAMES.join(", ")})`,
"default"
)
.option("--verbose", "Show extra diagnostic info for each match");
.option("--verbose", "Show extra diagnostic info for each match")
.option("--context", "Show source snippet for the error location");

// ----------------- HELPERS -----------------
function validateTheme(name) {
Expand Down Expand Up @@ -142,11 +144,13 @@ program
}

const { count, matches } = findError(errorOutput, options.lang);
const locations = parseStackTrace(errorOutput);
const context = Boolean(program.opts().context);

if (code === null) {
if (isJson) {
console.log(
JSON.stringify({ code: 1, count, matches: sanitizeMatches(matches) }, null, 2)
JSON.stringify({ code: 1, count, matches: sanitizeMatches(matches), locations }, null, 2)
);
} else {
const theme = resolveTheme(themeName);
Expand All @@ -160,7 +164,7 @@ program

if (isJson) {
console.log(
JSON.stringify({ code, count, matches: sanitizeMatches(matches) }, null, 2)
JSON.stringify({ code, count, matches: sanitizeMatches(matches), locations }, null, 2)
);
process.exit(code ?? 1);
}
Expand All @@ -176,7 +180,7 @@ program
);
matches.forEach((m) =>
console.log(
formatError(m, { theme: themeName, verbose })
formatError(m, { theme: themeName, verbose, locations, context })
)
);
} else {
Expand Down Expand Up @@ -221,11 +225,13 @@ program
validateTheme(themeName);

const { count, matches } = findError(errorString, options.lang);
const locations = parseStackTrace(errorString);
const context = Boolean(program.opts().context);
const exitCode = count > 0 ? 1 : 0;

if (isJson) {
console.log(
JSON.stringify({ code: exitCode, count, matches: sanitizeMatches(matches) }, null, 2)
JSON.stringify({ code: exitCode, count, matches: sanitizeMatches(matches), locations }, null, 2)
);
process.exit(exitCode);
}
Expand All @@ -236,7 +242,7 @@ program
chalk[theme.headerText].bold(`\n🚀 ErrLens Analysis (${count} Issue(s)):`)
);
matches.forEach((m) =>
console.log(formatError(m, { theme: themeName, verbose }))
console.log(formatError(m, { theme: themeName, verbose, locations, context }))
);
} else {
const theme = resolveTheme(themeName);
Expand Down
47 changes: 47 additions & 0 deletions lib/formatter.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,38 @@ const chalkImport = require("chalk");
const chalk = chalkImport.default || chalkImport;
const boxen = require("boxen");
const { resolveTheme } = require("./themes");
const { sourceSnippet, isInternalFrame } = require("./stack-parser");

function formatLocation(location) {
const fn = location.function ? `${location.function} (` : "";
const close = location.function ? ")" : "";
return `${fn}${location.file}:${location.line}:${location.column}${close}`;
}

function formatSnippet(snippet, theme) {
if (!snippet) return null;

const lines = snippet.lines
.map((line) => {
const gutter = line.isErrorLine
? chalk[theme.signalText].bold(`${line.number} >`)
: chalk[theme.exampleCode](`${line.number} `);
const code = line.isErrorLine
? chalk[theme.errorText](line.text)
: chalk[theme.exampleCode](line.text);
return `${gutter} ${code}`;
})
.join("\n");

return `${chalk[theme.infoLabel].bold("📍 CONTEXT:")} ${chalk[theme.exampleCode](
snippet.file
)}:${snippet.line}\n${lines}`;
}

function formatError(error, options = {}) {
const theme = resolveTheme(options.theme);
const verbose = Boolean(options.verbose);
const locations = Array.isArray(options.locations) ? options.locations : [];

const fixList = error.fixes
.map((f) => chalk[theme.fixCheck](` ✔ ${f}`))
Expand Down Expand Up @@ -45,6 +73,25 @@ ${chalk[theme.id].bold("🏷️ KEY:")} ${error.match}`;
content += matchBlock + metaBlock;
}

if (locations.length > 0) {
const appFrames = locations.filter((l) => !isInternalFrame(l.file));
const frame = appFrames[0] || locations[0];

const locationBlock = `
${chalk[theme.infoLabel].bold("📍 LOCATION:")} ${chalk[theme.exampleCode](
formatLocation(frame)
)}`;
content += locationBlock;

if (options.context) {
const snippet = sourceSnippet(frame.file, frame.line, 2);
const snippetBlock = formatSnippet(snippet, theme);
if (snippetBlock) {
content += `\n${snippetBlock}`;
}
}
}

return boxen(content, {
padding: 1,
margin: { top: 1, bottom: 1 },
Expand Down
161 changes: 161 additions & 0 deletions lib/stack-parser.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
"use strict";

const fs = require("fs");
const path = require("path");
const { fileURLToPath } = require("url");

// Matches Node-style stack trace frames:
// at Object.<anonymous> (/path/to/file.js:10:5)
// at Module._compile (node:internal/modules/cjs/loader:1256:14)
// at /path/to/file.js:10:5
// at file:///path/to/file.js:10:5
// at async Function.run (/path/to/file.js:12:3)
const FRAME_LINE_RE = /^\s*at\s+(?:async\s+)?(.+)$/;

function parseFrame(text) {
let rest = String(text).trim();
let fnName = null;

// Function frames wrap the location in parentheses: fn (loc). The location
// itself may contain parens (e.g. "C:\dir (copy)\file.js:1:1"), so match the
// opening paren that corresponds to the final closing paren instead of using
// a naive lastIndexOf(" (").
if (rest.endsWith(")")) {
let depth = 0;
for (let i = rest.length - 1; i >= 0; i--) {
const ch = rest[i];
if (ch === ")") depth++;
else if (ch === "(") {
depth--;
if (depth === 0) {
fnName = rest.slice(0, i).trim();
rest = rest.slice(i + 1, -1).trim();
break;
}
}
}
}

// Greedy match so Windows drive letters (C:\...) and node:internal paths
// are captured correctly while still isolating the trailing :line:col.
const locMatch = rest.match(/^(.+):(\d+):(\d+)$/);
if (!locMatch) return null;

return {
function: fnName || null,
file: locMatch[1],
line: Number(locMatch[2]),
column: Number(locMatch[3]),
};
}

function parseStackTrace(errorString) {
if (!errorString) return [];
if (typeof errorString !== "string") errorString = String(errorString);

const frames = [];
for (const line of errorString.split(/\r?\n/)) {
if (!FRAME_LINE_RE.test(line)) continue;
const frame = parseFrame(line.replace(FRAME_LINE_RE, "$1"));
if (frame) frames.push(frame);
}
return frames;
}

function isInternalFrame(file) {
return (
file.startsWith("node:") ||
file.startsWith("internal/") ||
file.startsWith("<") ||
file === ""
);
}

// Files larger than this are skipped to avoid reading huge sources into memory.
const MAX_SNIPPET_SIZE = 256 * 1024; // 256 KiB

// Resolve a stack-derived path (which may be attacker-controlled) and confine
// it to the current working directory. Returns a canonical absolute path or
// null when the target is not safe to read.
function resolveSafePath(filepath) {
let file = filepath;
if (file.startsWith("file://")) {
try {
file = fileURLToPath(file);
} catch {
return null;
}
}

const root = path.resolve(process.cwd());
const resolved = path.resolve(file);

// Reject paths that escape the approved source root.
if (resolved !== root && !resolved.startsWith(root + path.sep)) {
return null;
}

// Resolve symlinks and reject escapes through them.
let real;
let realRoot;
try {
real = fs.realpathSync(resolved);
realRoot = fs.realpathSync(root);
} catch {
return null;
}
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
return null;
}

// Reject non-regular files (directories, devices, sockets, etc.).
let stat;
try {
stat = fs.statSync(real);
} catch {
return null;
}
if (!stat.isFile() || stat.size > MAX_SNIPPET_SIZE) return null;

return real;
}

function sourceSnippet(filepath, line, radius = 2) {
if (!filepath || typeof line !== "number" || line < 1) return null;
if (isInternalFrame(filepath)) return null;

const file = resolveSafePath(filepath);
if (!file) return null;

let content;
try {
content = fs.readFileSync(file, "utf8");
} catch {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return null;
}

const lines = content.split(/\r?\n/);
if (line > lines.length) return null;

const start = Math.max(1, line - radius);
const end = Math.min(lines.length, line + radius);

return {
file,
line,
start,
end,
lines: lines.slice(start - 1, end).map((text, i) => ({
number: start + i,
text,
isErrorLine: start + i === line,
})),
};
}

module.exports = {
parseStackTrace,
parseFrame,
sourceSnippet,
isInternalFrame,
};
Loading
Loading