@arcforge/err 2.0.98
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -0
- package/src/err.ts +132 -0
- package/src/index.ts +12 -0
- package/src/map.ts +1044 -0
- package/src/render.ts +102 -0
- package/src/sink.ts +37 -0
- package/src/stack.ts +119 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { AxonErrorSeverity, AxonErrorSource } from "./map"
|
|
2
|
+
import type { AxonStackFrame } from "./stack"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rust-style call-site rendering — pure formatting over plain data. Every
|
|
6
|
+
* field here already lives on AxonError (map.ts's identity fields + the
|
|
7
|
+
* frames/snippets stack.ts captured at construction time) — nothing is
|
|
8
|
+
* parsed back out of a string, no disk access happens here. A future TUI
|
|
9
|
+
* component renders the exact same AxonErrorLike shape with its own layout;
|
|
10
|
+
* this module is one legitimate renderer of it, not the source of truth.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type AxonErrorLike = {
|
|
14
|
+
code: string
|
|
15
|
+
title: string
|
|
16
|
+
description: string
|
|
17
|
+
message: string
|
|
18
|
+
severity: AxonErrorSeverity
|
|
19
|
+
source: AxonErrorSource
|
|
20
|
+
context: Record<string, unknown> | undefined
|
|
21
|
+
frames: AxonStackFrame[]
|
|
22
|
+
cause?: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** One frame, Rust-style: file:line:col, then its captured source context with a caret. */
|
|
26
|
+
export function renderFrame(frame: AxonStackFrame): string {
|
|
27
|
+
if (!frame.fileName || frame.lineNumber === null) {
|
|
28
|
+
return frame.functionName ? `at ${frame.functionName}` : "at <unknown>"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const location = `${frame.fileName}:${frame.lineNumber}:${frame.columnNumber ?? 0}`
|
|
32
|
+
const header = frame.functionName ? `at ${frame.functionName} (${location})` : `at ${location}`
|
|
33
|
+
|
|
34
|
+
const snippet = frame.source ? renderSnippet(frame.source, frame.lineNumber, frame.columnNumber) : null
|
|
35
|
+
return snippet ? `${header}\n${snippet}` : header
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const RULE_WIDTH = 80
|
|
39
|
+
|
|
40
|
+
/** The full renderable report: headline + description, a rule, then every frame and the cause chain. Blank line before and after the whole block. */
|
|
41
|
+
export function renderError(error: AxonErrorLike): string {
|
|
42
|
+
const lines = [`Axon Error: ${error.title}`, error.description]
|
|
43
|
+
|
|
44
|
+
if (error.message && error.message !== error.title) {
|
|
45
|
+
lines.push("", error.message)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (error.context && Object.keys(error.context).length > 0) {
|
|
49
|
+
lines.push("", "Context:", indent(renderContext(error.context)))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
lines.push("─".repeat(RULE_WIDTH), ...error.frames.map(renderFrame))
|
|
53
|
+
|
|
54
|
+
if (error.cause !== undefined) {
|
|
55
|
+
lines.push("", "Caused by:", indent(causeMessage(error.cause)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return ["", lines.join("\n"), ""].join("\n")
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function renderContext(context: Record<string, unknown>): string {
|
|
62
|
+
return Object.entries(context)
|
|
63
|
+
.map(([key, value]) => `${key}: ${typeof value === "string" ? value : safeStringify(value)}`)
|
|
64
|
+
.join("\n")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** JSON.stringify throws on circular structures — context is caller-supplied and not guaranteed serializable, so rendering it must never itself throw. */
|
|
68
|
+
function safeStringify(value: unknown): string {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.stringify(value)
|
|
71
|
+
} catch {
|
|
72
|
+
return String(value)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function causeMessage(cause: unknown): string {
|
|
77
|
+
return cause instanceof Error ? (cause.stack ?? cause.message) : String(cause)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function indent(text: string): string {
|
|
81
|
+
return text.split("\n").map(line => ` ${line}`).join("\n")
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Gutter-numbered source lines with a caret under the reported column — all from already-captured data, no disk read. */
|
|
85
|
+
function renderSnippet(source: AxonStackFrame["source"], lineNumber: number, columnNumber: number | null): string | null {
|
|
86
|
+
if (!source || source.length === 0) return null
|
|
87
|
+
|
|
88
|
+
const gutterWidth = String(source[source.length - 1]!.lineNumber).length
|
|
89
|
+
|
|
90
|
+
const rendered: string[] = []
|
|
91
|
+
for (const line of source) {
|
|
92
|
+
const num = String(line.lineNumber).padStart(gutterWidth, " ")
|
|
93
|
+
const marker = line.lineNumber === lineNumber ? ">" : " "
|
|
94
|
+
rendered.push(` ${marker} ${num} | ${line.text}`)
|
|
95
|
+
if (line.lineNumber === lineNumber && columnNumber !== null) {
|
|
96
|
+
const caretPad = " ".repeat(gutterWidth + 6 + Math.max(0, columnNumber - 1))
|
|
97
|
+
rendered.push(`${caretPad}^`)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return rendered.join("\n")
|
|
102
|
+
}
|
package/src/sink.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks"
|
|
2
|
+
import type { AxonError } from "./err"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Error attribution is a SCOPE, not a global. A process may host several
|
|
6
|
+
* Axon() runtimes at once, each with its own session log — a module-global
|
|
7
|
+
* sink (the old design) meant every error landed in whichever session
|
|
8
|
+
* registered last, silently misattributing instance A's failures to
|
|
9
|
+
* instance B's durable record.
|
|
10
|
+
*
|
|
11
|
+
* Instead, each runtime establishes its sink over its own well-defined
|
|
12
|
+
* entry points (Axon() construction, each kernel wake, reload) via
|
|
13
|
+
* AsyncLocalStorage: errScope.run(sink, fn). Every err() constructed
|
|
14
|
+
* anywhere downstream of that call — through awaits, promise chains,
|
|
15
|
+
* nested calls — reaches THAT runtime's session and no other.
|
|
16
|
+
*
|
|
17
|
+
* An err() constructed outside any scope (CLI tooling, tests, a callback
|
|
18
|
+
* scheduled outside a runtime's flow) reaches no sink: the error still
|
|
19
|
+
* throws and propagates to its catcher, which owns visibility at host
|
|
20
|
+
* level. Losing telemetry there is visible and fixable; misattribution
|
|
21
|
+
* would be a lie in the durable record — this fails in the right direction.
|
|
22
|
+
*/
|
|
23
|
+
export type AxonErrorSink = (error: AxonError) => void
|
|
24
|
+
|
|
25
|
+
const storage = new AsyncLocalStorage<AxonErrorSink>()
|
|
26
|
+
|
|
27
|
+
export const errScope = {
|
|
28
|
+
/** Run fn with every err() constructed downstream delivered to sink. Scopes nest — the innermost wins. */
|
|
29
|
+
run<T>(sink: AxonErrorSink, fn: () => T): T {
|
|
30
|
+
return storage.run(sink, fn)
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** err()'s delivery call — the current scope's sink, or nothing (see module doc). */
|
|
35
|
+
export function emitError(error: AxonError): void {
|
|
36
|
+
storage.getStore()?.(error)
|
|
37
|
+
}
|
package/src/stack.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stack capture — one structured frame per call site, not an opaque string.
|
|
5
|
+
* Bun/V8 already resolve real .ts file+line+column with no source-map step
|
|
6
|
+
* (confirmed: running a .ts file directly reports its own source
|
|
7
|
+
* coordinates).
|
|
8
|
+
*
|
|
9
|
+
* The source snippet is captured HERE, at construction time, not read from
|
|
10
|
+
* disk later at render time. A TUI (or any renderer) replaying an AxonError
|
|
11
|
+
* from the session log may be on a different machine, after the file
|
|
12
|
+
* changed, or looking at a deployed bundle with no source on disk at all —
|
|
13
|
+
* the snippet has to already be data on the frame, or it's gone forever.
|
|
14
|
+
* render() only ever formats what's already here.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// AxonSourceLine / AxonStackFrame are the wire contract — they live in
|
|
18
|
+
// @arcforge/types and are re-exported here so err's internals and existing
|
|
19
|
+
// importers resolve them from the same place.
|
|
20
|
+
export type { AxonSourceLine, AxonStackFrame } from "@arcforge/types"
|
|
21
|
+
import type { AxonSourceLine, AxonStackFrame } from "@arcforge/types"
|
|
22
|
+
|
|
23
|
+
const FRAME_PATTERN_NAMED = /at\s+(.*?)\s+\((.*?):(\d+):(\d+)\)/
|
|
24
|
+
const FRAME_PATTERN_BARE = /at\s+(.*?):(\d+):(\d+)/
|
|
25
|
+
|
|
26
|
+
/** Noise no caller ever wants rendered — framework internals, not their code. */
|
|
27
|
+
const IGNORED_PATH_SEGMENTS = ["node_modules", "native:", "internal", "bun:"]
|
|
28
|
+
|
|
29
|
+
const CONTEXT_LINES = 2
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Capture the stack at the current call site, skipping `skipFrames` of it
|
|
33
|
+
* (use 1 to drop captureStack's own frame, 2 to also drop err()'s).
|
|
34
|
+
*/
|
|
35
|
+
export function captureStack(skipFrames: number = 1): AxonStackFrame[] {
|
|
36
|
+
const raw = new Error().stack
|
|
37
|
+
if (!raw) return []
|
|
38
|
+
|
|
39
|
+
const lines = raw.split("\n").slice(1 + skipFrames) // drop the "Error" header line + requested frames
|
|
40
|
+
return lines
|
|
41
|
+
.map(parseLine)
|
|
42
|
+
.filter(isRealFrame)
|
|
43
|
+
.map(withSource)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse an ALREADY-CAPTURED stack string (an unknown catch value's own
|
|
48
|
+
* `.stack`, not ours) into structured frames — same real-frame filtering
|
|
49
|
+
* and source-snippet capture as captureStack(), just over foreign text
|
|
50
|
+
* instead of `new Error().stack`. This is how a wrapped cause's own throw
|
|
51
|
+
* site becomes renderable: err()'s own frames point at the catch
|
|
52
|
+
* boundary that called err(), never at the user code that actually threw.
|
|
53
|
+
*/
|
|
54
|
+
export function parseStack(raw: string): AxonStackFrame[] {
|
|
55
|
+
return raw
|
|
56
|
+
.split("\n")
|
|
57
|
+
.slice(1) // drop the "Error: message" header line
|
|
58
|
+
.map(parseLine)
|
|
59
|
+
.filter(isRealFrame)
|
|
60
|
+
.map(withSource)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The first non-framework frame in a foreign stack — the user's own throw site, best-effort. Null when the stack has no such frame (e.g. a bare string throw has no stack at all). */
|
|
64
|
+
export function firstRealFrame(raw: string | undefined): AxonStackFrame | null {
|
|
65
|
+
if (!raw) return null
|
|
66
|
+
return parseStack(raw)[0] ?? null
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function parseLine(line: string): Omit<AxonStackFrame, "source"> {
|
|
70
|
+
const named = line.match(FRAME_PATTERN_NAMED)
|
|
71
|
+
if (named) {
|
|
72
|
+
const [, fn, file, ln, col] = named
|
|
73
|
+
return { functionName: fn ?? null, fileName: file ?? null, lineNumber: numberOr(ln), columnNumber: numberOr(col) }
|
|
74
|
+
}
|
|
75
|
+
const bare = line.match(FRAME_PATTERN_BARE)
|
|
76
|
+
if (bare) {
|
|
77
|
+
const [, file, ln, col] = bare
|
|
78
|
+
return { functionName: null, fileName: file ?? null, lineNumber: numberOr(ln), columnNumber: numberOr(col) }
|
|
79
|
+
}
|
|
80
|
+
return { functionName: null, fileName: null, lineNumber: null, columnNumber: null }
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function numberOr(value: string | undefined): number | null {
|
|
84
|
+
if (value === undefined) return null
|
|
85
|
+
const n = parseInt(value, 10)
|
|
86
|
+
return Number.isNaN(n) ? null : n
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isRealFrame(frame: Omit<AxonStackFrame, "source">): boolean {
|
|
90
|
+
if (!frame.fileName) return false
|
|
91
|
+
return !IGNORED_PATH_SEGMENTS.some(segment => frame.fileName!.includes(segment))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function withSource(frame: Omit<AxonStackFrame, "source">): AxonStackFrame {
|
|
95
|
+
return { ...frame, source: readSourceWindow(frame.fileName, frame.lineNumber) }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `CONTEXT_LINES` of real source ± the reported line, captured as plain data — no disk access after this point. */
|
|
99
|
+
function readSourceWindow(fileName: string | null, lineNumber: number | null): AxonSourceLine[] | null {
|
|
100
|
+
if (!fileName || lineNumber === null) return null
|
|
101
|
+
|
|
102
|
+
let text: string
|
|
103
|
+
try {
|
|
104
|
+
text = readFileSync(fileName, "utf-8")
|
|
105
|
+
} catch {
|
|
106
|
+
return null // source not reachable (deployed bundle, deleted file) — the header alone is still useful
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const lines = text.split("\n")
|
|
110
|
+
const targetIdx = lineNumber - 1
|
|
111
|
+
if (targetIdx < 0 || targetIdx >= lines.length) return null
|
|
112
|
+
|
|
113
|
+
const from = Math.max(0, targetIdx - CONTEXT_LINES)
|
|
114
|
+
const to = Math.min(lines.length - 1, targetIdx + CONTEXT_LINES)
|
|
115
|
+
|
|
116
|
+
const window: AxonSourceLine[] = []
|
|
117
|
+
for (let i = from; i <= to; i++) window.push({ lineNumber: i + 1, text: lines[i] ?? "" })
|
|
118
|
+
return window
|
|
119
|
+
}
|