@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/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
+ }