@starpak/error 0.0.0-stage → 0.1.0

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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Spak Error - error definitions
3
+ *
4
+ * Every code must answer: 哪里错 → 为什么 → 怎么改. Keep `why` and `fix`
5
+ * actionable; `ui` is what the Spak OS error panel renders.
6
+ */
7
+
8
+ import { CODES } from './codes'
9
+
10
+ export type Severity = 'info' | 'warning' | 'error' | 'fatal'
11
+ export type Category =
12
+ | 'language' | 'build' | 'mod' | 'runtime'
13
+ | 'permission' | 'fs' | 'network' | 'env' | 'fatal'
14
+
15
+ export interface ErrorUI {
16
+ icon?: string
17
+ color?: string
18
+ /** One-click actions shown by the OS panel. */
19
+ actions?: string[]
20
+ }
21
+
22
+ export interface ErrorDefinition {
23
+ code: string
24
+ title: string
25
+ severity: Severity
26
+ category: Category
27
+ why: string
28
+ fix: string[]
29
+ docs?: string
30
+ ui?: ErrorUI
31
+ related?: string[]
32
+ }
33
+
34
+ const D = (
35
+ code: string,
36
+ title: string,
37
+ severity: Severity,
38
+ category: Category,
39
+ why: string,
40
+ fix: string[],
41
+ extra: Partial<ErrorDefinition> = {},
42
+ ): ErrorDefinition => ({ code, title, severity, category, why, fix, ...extra })
43
+
44
+ const LIST: ErrorDefinition[] = [
45
+ // ---------------- 1xxx language ----------------
46
+ D(CODES.SYNTAX, '语法错误', 'error', 'language', '源码不符合 Spak 语法,解析器在报错位置无法继续。',
47
+ ['看报错列出的行与列,重点检查该位置前一个 token', '用 `spm run <file>` 复现,最小化到能触发错误的最短代码']),
48
+ D(CODES.UNEXPECTED_TOKEN, '意外的 token', 'error', 'language', '在只能出现特定 token 的位置出现了别的符号,常见于漏写 `end`、多写逗号。',
49
+ ['检查该块是否缺少 `end`', '检查上一行是否多写 `)`、`,` 或 `then`'], { related: [CODES.SYNTAX] }),
50
+ D(CODES.UNTERMINATED_STRING, '字符串没有闭合', 'error', 'language', '字符串缺少结束引号,或跨行字符串没有使用 `"""`。',
51
+ ['补齐引号', '多行文本请用 `"""..."""`']),
52
+ D(CODES.TYPE_MISMATCH, '类型不匹配', 'error', 'language', '注解声明的类型与实际值的类型不一致。',
53
+ ['看两侧类型,确认是值写错还是注解写错', '需要字符串时用 `tostring(x)` 或 `f"{x}"`']),
54
+ D(CODES.POSSIBLY_NIL, '可能为 nil', 'error', 'language', '值来自可空类型,使用前没有收窄。',
55
+ ['用 `if x then` 收窄', '或用 `x ?? 默认值` 提供兜底']),
56
+ D(CODES.UNKNOWN_TYPE, '未知类型名', 'error', 'language', '注解引用了不存在的类型或没写 `type` 别名。',
57
+ ['检查拼写与大小写', '先 `type X = {...}` 声明再使用'], { related: [CODES.TYPE_ALIAS_CYCLE] }),
58
+ D(CODES.ARGUMENT_MISMATCH, '参数数量或类型不符', 'error', 'language', '调用处传入的实参与函数声明的形参对不上。',
59
+ ['核对函数签名', '可变参数用 `...` 接收']),
60
+ D(CODES.RETURN_MISMATCH, '返回值与注解不符', 'error', 'language', '函数声明了返回类型,但实际返回了别的类型。',
61
+ ['检查所有 `return` 分支', '没有返回值时把返回类型改成 `nil` 或不写']),
62
+ D(CODES.DUPLICATE_DECLARATION, '重复声明', 'error', 'language', '同一作用域里同名绑定被声明了两次。',
63
+ ['改掉其中一个名字', '确实要覆盖就先赋值而不是重新声明']),
64
+ D(CODES.TYPE_ALIAS_CYCLE, '类型别名循环', 'error', 'language', '类型别名直接或间接引用了自己,无法展开。',
65
+ ['打断循环:至少一处使用具体类型(如 `[T]` 或 `{k: v}`)']),
66
+ D(CODES.MISSING_FIELD, '字段不存在', 'error', 'language', '访问了记录类型上不存在的字段。',
67
+ ['检查字段拼写', '先给对象补上该字段,或把类型放宽为 `any`']),
68
+ D(CODES.ZERO_BASED_INDEX, '1 基索引误用', 'error', 'language', 'Spak 的序列下标从 1 开始,`list[0]` 取不到元素。',
69
+ ['把 `list[0]` 改成 `list[1]`', '遍历优先用 `for x in list` 而不是手写下标']),
70
+
71
+ // ---------------- 2xxx build ----------------
72
+ D(CODES.BUILD_FAILED, '构建失败', 'error', 'build', '打包器(esbuild/SEA/容器封装)在某个阶段返回了错误。',
73
+ ['看构建日志里的第一条错误,后面通常是连锁反应', '`spm build` 加 `--target` 明确目标']),
74
+ D(CODES.ENTRY_NOT_FOUND, '入口文件不存在', 'error', 'build', '清单里的 `entry`(或默认 `index.ts`)在目录里找不到。',
75
+ ['检查 `mod.json` / `spak.app.json` 的 `entry` 路径', '确认文件大小写与扩展名一致']),
76
+ D(CODES.UNKNOWN_TARGET, '未知构建目标', 'error', 'build', '`--target` 传了当前构建器不支持的名字。',
77
+ ['可用目标:node / server / app / web / gal', '`spm build --help` 查看当次构建器支持列表']),
78
+ D(CODES.PAK_MAGIC_INVALID, '不是有效的 SPAK 包', 'error', 'build', '文件头不是 `SPAK`,可能不是包或下载被截断。',
79
+ ['确认文件来源', '重新下载;必要时用 `parsePak` 校验']),
80
+ D(CODES.PAK_VERSION_UNSUPPORTED, '包版本不支持', 'error', 'build', '容器版本高于当前运行时能读的版本。',
81
+ ['升级 Spak 运行时', '或让打包方使用 v2 格式']),
82
+ D(CODES.PAK_MANIFEST_INVALID, '包清单损坏', 'error', 'build', '容器里的 manifest 不是合法 JSON 或缺少必填字段。',
83
+ ['重新打包', '检查打包脚本是否写入了空清单']),
84
+ D(CODES.PAK_TRUNCATED, '包被截断', 'error', 'build', '文件长度小于 manifest 声明的长度,传输不完整。',
85
+ ['重新下载', '校验文件大小与哈希']),
86
+ D(CODES.OUTPUT_NOT_WRITABLE, '输出目录不可写', 'error', 'build', '目标目录不存在或当前用户没有写权限。',
87
+ ['换到有写权限的目录', '检查挂载点是否只读']),
88
+ D(CODES.DISK_FULL, '磁盘空间不足', 'error', 'build', '写入产物时文件系统返回 ENOSPC。',
89
+ ['清理 `dist/`、缓存与旧版本产物', '换更大的磁盘或挂载点']),
90
+
91
+ // ---------------- 3xxx mods / apps ----------------
92
+ D(CODES.MANIFEST_INVALID, '清单字段非法', 'error', 'mod', '`mod.json` / `spak.app.json` 缺字段或类型不对。',
93
+ ['按文档补齐 id / name / version / entry', '错误信息会指出具体字段名']),
94
+ D(CODES.DEPENDENCY_MISSING, '依赖缺失', 'error', 'mod', '`requires` 里声明的模组没有被安装或 id 拼错。',
95
+ ['`spm mod add <id>` 安装依赖', '检查依赖 id 是否与对方 `mod.json` 一致']),
96
+ D(CODES.DEPENDENCY_CYCLE, '循环依赖', 'error', 'mod', '模组之间互相依赖,无法确定装载顺序。',
97
+ ['把公共部分抽成第三个模组', '打断其中一条 `requires`']),
98
+ D(CODES.DUPLICATE_MOD_ID, '模组 id 重复', 'error', 'mod', '两个目录声明了同一个 id。',
99
+ ['改掉其中一个 id', '删掉旧版本残留目录']),
100
+ D(CODES.ENTRY_UNSUPPORTED, '入口类型不支持', 'error', 'mod', '入口扩展名不在支持列表内。',
101
+ ['支持 .spk / .js / .mjs / .cjs', '把入口改成 `index.spk` 或编译为 `.mjs`']),
102
+ D(CODES.SPK_COMPILE_FAILED, '模组 .spk 编译失败', 'error', 'mod', '模组源码没通过类型检查或代码生成。',
103
+ ['按诊断修源码', '先用 `spm run <file>` 单独跑通']),
104
+ D(CODES.VERSION_MISMATCH, '版本不兼容', 'error', 'mod', '模组要求的 SDK / OS 版本与当前宿主不符。',
105
+ ['升级宿主或降低模组要求', '检查 `sdk` / `os.min` 字段']),
106
+
107
+ // ---------------- 4xxx runtime ----------------
108
+ D(CODES.UNCAUGHT_EXCEPTION, '未捕获异常', 'fatal', 'runtime', '异常逃出了所有 try/catch,进程即将退出。',
109
+ ['按堆栈定位第一行业务代码', '在该层加 `try/catch` 并给出可读错误']),
110
+ D(CODES.UNHANDLED_REJECTION, '未处理的 Promise 拒绝', 'fatal', 'runtime', '异步调用失败但没有任何 catch。',
111
+ ['给 Promise 加 `.catch()` 或用 `try/await`', '加全局 `process.on("unhandledRejection")` 兜底上报']),
112
+ D(CODES.PORT_IN_USE, '端口已被占用', 'error', 'runtime', '另一个进程已经监听该端口。',
113
+ ['换端口(`spak.config.yml` 的 `server.port`)', '或结束占用进程:`lsof -i :<port>`']),
114
+ D(CODES.LISTEN_FAILED, '监听失败', 'error', 'runtime', '地址不可用、权限不足或端口被系统保留。',
115
+ ['改用 `0.0.0.0` 或 `127.0.0.1`', '小于 1024 的端口需要额外权限']),
116
+ D(CODES.ROUTE_DUPLICATE, '路由重复注册', 'warning', 'runtime', '同一 method + path 被注册两次,后注册的会覆盖前者。',
117
+ ['合并处理函数', '给不同模组的路由加前缀']),
118
+ D(CODES.HANDLER_THREW, '请求处理函数抛错', 'error', 'runtime', '路由 handler 内部异常,已返回 500。',
119
+ ['看服务日志里的原始堆栈', '把可能失败的 IO 放进 try/catch']),
120
+ D(CODES.BODY_TOO_LARGE, '请求体过大', 'warning', 'runtime', '请求体超过 1 MB 上限,已被拒绝(413)。',
121
+ ['改传文件而不是塞进 JSON', '或调大宿主的上限配置']),
122
+
123
+ // ---------------- 5xxx permission ----------------
124
+ D(CODES.PERMISSION_UNDECLARED, '权限未声明', 'error', 'permission', '调用了清单里没有声明的能力。',
125
+ ['在 `permissions` 里显式声明(fs / network / childProcess)', '最小权限原则:只开需要的']),
126
+ D(CODES.FS_DENIED, '文件访问被拒绝', 'error', 'permission', '模组/应用没有 fs 权限,或只读却尝试写入。',
127
+ ['申请 `fs: read` 或 `fs: write`', '把要读的文件放进允许的目录']),
128
+ D(CODES.NETWORK_DENIED, '网络访问被拒绝', 'error', 'permission', '清单没有开启网络,或只允许 localhost。',
129
+ ['声明 `network: localhost` 或 `all`', '避免在启动阶段发起外网请求']),
130
+ D(CODES.CHILD_PROCESS_DENIED, '子进程被禁止', 'error', 'permission', '清单的 `childProcess` 为 false。',
131
+ ['改用内置能力实现', '确实需要就声明 `childProcess: true` 并说明原因']),
132
+ D(CODES.DANGEROUS_API, '危险 API 调用', 'fatal', 'permission', '调用了沙箱明确禁止的接口(eval / 动态执行 / 原生模块)。',
133
+ ['换成安全替代实现', '如确需使用,走宿主提供的受控接口']),
134
+
135
+ // ---------------- 6xxx filesystem ----------------
136
+ D(CODES.FS_NOT_FOUND, '文件或目录不存在', 'error', 'fs', '路径拼错、文件被删,或工作目录不是预期的那个。',
137
+ ['打印 `process.cwd()` 与绝对路径确认', '用 `fs.existsSync` 先判断再读']),
138
+ D(CODES.FS_DENIED_BY_OS, '权限不足(EACCES/EPERM)', 'error', 'fs', '当前用户对目标路径没有读/写/执行权限。',
139
+ ['检查文件 mode 与属主', '换有权限的目录,或提升权限后再试']),
140
+ D(CODES.FS_OPERATION_NOT_PERMITTED, '操作不被允许(EPERM)', 'error', 'fs', '文件系统或安全策略禁止该操作(如 chmod/chown)。',
141
+ ['避免对挂载盘做权限变更', '改用复制到本地盘再操作']),
142
+ D(CODES.FS_NO_SPACE, '磁盘已满(ENOSPC)', 'fatal', 'fs', '写入时空间耗尽,进程可能已产生半个文件。',
143
+ ['清理临时目录与旧产物', '检查是否写进了内存盘']),
144
+ D(CODES.FS_TOO_MANY_OPEN_FILES, '打开文件过多(EMFILE)', 'error', 'fs', '文件句柄泄漏,或超过 `ulimit -n`。',
145
+ ['确保每个 open 都有 close', '批量处理时分批并复用句柄']),
146
+ D(CODES.FS_IS_DIRECTORY, '目标是目录(EISDIR)', 'error', 'fs', '把目录当成文件读取或覆盖。',
147
+ ['先 `stat` 判断类型', '目录操作用 `readdir` 而不是 `readFile`']),
148
+ D(CODES.FS_RENAME_UNSUPPORTED, '重命名/原子写失败(FUSE)', 'error', 'fs', '在 Android FUSE 挂载盘上,重命名会残留 `.l2s.tmp_*` 并报 ENOENT。',
149
+ ['把 git 仓库/.git 或构建产物放到本地盘', '避免在挂载盘上做原子写与符号链接']),
150
+
151
+ // ---------------- 7xxx network ----------------
152
+ D(CODES.NET_CONNECTION_REFUSED, '连接被拒绝', 'error', 'network', '目标端口没有服务在监听,或防火墙直接拒绝。',
153
+ ['确认服务已启动并监听该端口', '检查地址是否写成了内网/回环']),
154
+ D(CODES.NET_CONNECTION_RESET, '连接被重置', 'error', 'network', '对端或中间设备主动断开(常见于被墙/代理超时)。',
155
+ ['重试并退避', '换镜像/代理;检查 TLS 版本']),
156
+ D(CODES.NET_TIMEOUT, '请求超时', 'error', 'network', '在超时时间内没有收到完整响应。',
157
+ ['调大超时并加分类重试', '先测同域名的小请求定位是网络还是服务慢']),
158
+ D(CODES.NET_DNS, '域名解析失败', 'error', 'network', '域名不存在或 DNS 不可用。',
159
+ ['`nslookup` 验证域名', '换 DNS 或临时用 IP']),
160
+ D(CODES.NET_TLS, 'TLS 握手失败', 'error', 'network', '证书链、协议版本或系统时间不正确。',
161
+ ['检查系统时间', '更新 CA 证书;企业代理需额外信任根证书']),
162
+ D(CODES.NET_PROXY_AUTH, '代理认证失败', 'error', 'network', '代理要求凭据,或凭据已过期。',
163
+ ['更新代理用户名/密码或 token', '确认 `HTTP(S)_PROXY` 环境变量正确']),
164
+ D(CODES.NET_HTTP_STATUS, 'HTTP 状态码异常', 'error', 'network', '对端返回 4xx/5xx。',
165
+ ['先看响应体里的错误信息', '4xx 查请求参数,5xx 看对端日志']),
166
+
167
+ // ---------------- 8xxx environment ----------------
168
+ D(CODES.NODE_VERSION, 'Node 版本不满足要求', 'error', 'env', '当前运行时低于所需版本(构建目标为 node24)。',
169
+ ['升级 Node 到 24+', '或把构建目标降到你实际运行的版本']),
170
+ D(CODES.NATIVE_MODULE, '原生模块加载失败', 'error', 'env', 'ABI 不匹配、缺少系统库,或挂载盘无法 mmap。',
171
+ ['用对应 Node 版本重新编译', '装缺失的 .so;避免从 FUSE 盘加载 .node']),
172
+ D(CODES.PLATFORM_UNSUPPORTED, '平台不支持', 'error', 'env', '目标平台不在当前构建器支持列表内。',
173
+ ['查看 `docs/spak-os.md` 的支持矩阵', '用交叉构建或换平台']),
174
+ D(CODES.ENCODING, '编码/终端问题', 'warning', 'env', '输出包含终端无法显示的字符,或文件不是 UTF-8。',
175
+ ['统一用 UTF-8', '`LANG=C.UTF-8` 后再运行']),
176
+ D(CODES.CLOCK_SKEW, '系统时间偏移', 'warning', 'env', '本机时间与服务器差距过大,导致证书/签名校验失败。',
177
+ ['开启自动对时', '检查时区设置']),
178
+ D(CODES.MODULE_NOT_FOUND, '模块找不到(MODULE_NOT_FOUND)', 'error', 'env', 'import/require 的包没有安装,或路径解析到了工作区之外。',
179
+ ['`pnpm install` 补齐依赖', '检查包名拼写与 `exports` 映射', '相对路径要用 `./` 开头']),
180
+
181
+ // ---------------- 9xxx fatal ----------------
182
+ D(CODES.OUT_OF_MEMORY, '内存耗尽', 'fatal', 'fatal', '堆内存超过 V8 上限,进程被终止。',
183
+ ['定位大对象/无限累积的数组', '临时用 `--max-old-space-size=4096` 顶住,再修根因']),
184
+ D(CODES.STACK_OVERFLOW, '调用栈溢出', 'fatal', 'fatal', '无限递归或循环引用触发深度遍历。',
185
+ ['找递归出口', '深树结构改用显式栈遍历']),
186
+ D(CODES.NATIVE_CRASH, '原生崩溃/段错误', 'fatal', 'fatal', '原生插件或运行时自身崩溃,JS 层 catch 不到。',
187
+ ['用最小复现定位是哪个 addon', '禁用可疑原生模块后重试']),
188
+ D(CODES.CORRUPT_PACKAGE, '包数据损坏', 'fatal', 'fatal', '`.app` / `.pak` 内容与清单不一致或校验失败。',
189
+ ['重新下载并核对哈希', '不要手工改包内文件']),
190
+ D(CODES.HANDLE_REVOKED, '句柄中途失效', 'fatal', 'fatal', '文件/网络句柄被外部关闭(权限撤销、设备拔出)。',
191
+ ['捕获 EBADF 并重建句柄', '不要把句柄长期跨作用域缓存']),
192
+ D(CODES.UNKNOWN, '未识别的错误', 'error', 'fatal', '错误不匹配任何已知模式。',
193
+ ['保留完整堆栈与复现步骤', '附上 `spm -v` 与系统信息再反馈']),
194
+ ]
195
+
196
+ export const DEFINITIONS: Record<string, ErrorDefinition> = Object.fromEntries(
197
+ LIST.map(definition => [definition.code, definition]),
198
+ )
199
+
200
+ export function getDefinition(code: string): ErrorDefinition | undefined {
201
+ return DEFINITIONS[code]
202
+ }
203
+
204
+ export function allDefinitions(): ErrorDefinition[] {
205
+ return Object.values(DEFINITIONS)
206
+ }
207
+
208
+ /** Codes that only warn; used by `format` to pick colors and exit codes. */
209
+ export function isFatal(code: string): boolean {
210
+ const definition = getDefinition(code)
211
+ return definition?.severity === 'fatal'
212
+ }
package/src/error.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Spak Error - error class and normalization
3
+ *
4
+ * `SpakError` always carries a code, its definition, and the original cause.
5
+ * `toInfo()` is the single shape every renderer consumes.
6
+ */
7
+
8
+ import { CODES } from './codes'
9
+ import { DEFINITIONS, getDefinition, type Category, type ErrorDefinition, type ErrorUI, type Severity } from './definitions'
10
+
11
+ export interface ErrorContext {
12
+ [key: string]: unknown
13
+ }
14
+
15
+ export interface ErrorInfo {
16
+ code: string
17
+ title: string
18
+ severity: Severity
19
+ category: Category
20
+ why: string
21
+ fix: string[]
22
+ docs?: string
23
+ ui?: ErrorUI
24
+ /** Human message for this occurrence (falls back to the definition title). */
25
+ message: string
26
+ /** Where it happened: file:line:col, module id, route, etc. */
27
+ where?: string
28
+ context?: ErrorContext
29
+ cause?: Error
30
+ }
31
+
32
+ export interface SpakErrorOptions {
33
+ cause?: unknown
34
+ where?: string
35
+ context?: ErrorContext
36
+ /** Occurrence-specific message; the definition title is used when omitted. */
37
+ message?: string
38
+ }
39
+
40
+ const FALLBACK = DEFINITIONS[CODES.UNKNOWN]
41
+
42
+ function asError(cause: unknown): Error | undefined {
43
+ if (!cause) return undefined
44
+ if (cause instanceof Error) return cause
45
+ return new Error(String(cause))
46
+ }
47
+
48
+ export class SpakError extends Error {
49
+ readonly code: string
50
+ readonly definition: ErrorDefinition
51
+ readonly where?: string
52
+ readonly context?: ErrorContext
53
+ readonly cause?: Error
54
+
55
+ constructor(code: string, options: SpakErrorOptions = {}) {
56
+ const definition = getDefinition(code) ?? FALLBACK
57
+ super(options.message ?? definition.title)
58
+ this.name = 'SpakError'
59
+ this.code = definition.code
60
+ this.definition = definition
61
+ this.where = options.where
62
+ this.context = options.context
63
+ this.cause = asError(options.cause)
64
+ }
65
+
66
+ get severity(): Severity {
67
+ return this.definition.severity
68
+ }
69
+
70
+ get fix(): string[] {
71
+ return this.definition.fix
72
+ }
73
+
74
+ toInfo(): ErrorInfo {
75
+ const definition = this.definition
76
+ return {
77
+ code: definition.code,
78
+ title: definition.title,
79
+ severity: definition.severity,
80
+ category: definition.category,
81
+ why: definition.why,
82
+ fix: definition.fix,
83
+ docs: definition.docs,
84
+ ui: definition.ui,
85
+ message: this.message,
86
+ where: this.where,
87
+ context: this.context,
88
+ cause: this.cause,
89
+ }
90
+ }
91
+ }
92
+
93
+ export function isSpakError(value: unknown): value is SpakError {
94
+ return value instanceof SpakError
95
+ }
96
+
97
+ /** Build an `ErrorInfo` for a definition without throwing. */
98
+ export function describe(code: string, options: { where?: string, context?: ErrorContext, message?: string } = {}): ErrorInfo {
99
+ return new SpakError(code, options).toInfo()
100
+ }
101
+
102
+ /** Language diagnostics → Spak errors. Keeps the original position. */
103
+ export function fromDiagnostic(diagnostic: {
104
+ code: string
105
+ message: string
106
+ loc?: { line: number, column: number }
107
+ }): SpakError {
108
+ const mapping: Record<string, string> = {
109
+ E001: CODES.TYPE_MISMATCH,
110
+ E002: CODES.POSSIBLY_NIL,
111
+ E003: CODES.UNKNOWN_TYPE,
112
+ E004: CODES.ARGUMENT_MISMATCH,
113
+ E005: CODES.RETURN_MISMATCH,
114
+ E006: CODES.TYPE_ALIAS_CYCLE,
115
+ E007: CODES.MISSING_FIELD,
116
+ W001: CODES.UNKNOWN,
117
+ W002: CODES.UNKNOWN,
118
+ W003: CODES.UNKNOWN,
119
+ }
120
+ const code = mapping[diagnostic.code] ?? CODES.UNKNOWN
121
+ const where = diagnostic.loc ? `line ${diagnostic.loc.line}:${diagnostic.loc.column}` : undefined
122
+ return new SpakError(code, { message: diagnostic.message, where, context: { diagnostic: diagnostic.code } })
123
+ }
package/src/format.ts ADDED
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Spak Error - renderers
3
+ *
4
+ * One `ErrorInfo` → four surfaces: terminal text, JSON (CI/logs), HTML
5
+ * (Spak OS panel) and Markdown (docs/issues).
6
+ */
7
+
8
+ import type { ErrorInfo } from './error'
9
+
10
+ export type FormatKind = 'text' | 'json' | 'html' | 'markdown'
11
+
12
+ export interface FormatOptions {
13
+ /** ANSI colors in `text`; defaults to TTY detection. */
14
+ color?: boolean
15
+ /** Prefix for the location line, e.g. a file path. */
16
+ file?: string
17
+ }
18
+
19
+ const ANSI = {
20
+ reset: '\u001b[0m',
21
+ bold: '\u001b[1m',
22
+ dim: '\u001b[2m',
23
+ red: '\u001b[31m',
24
+ yellow: '\u001b[33m',
25
+ cyan: '\u001b[36m',
26
+ magenta: '\u001b[35m',
27
+ }
28
+
29
+ const SEVERITY_COLOR: Record<string, string> = {
30
+ info: ANSI.cyan,
31
+ warning: ANSI.yellow,
32
+ error: ANSI.red,
33
+ fatal: ANSI.magenta,
34
+ }
35
+
36
+ const SEVERITY_CSS: Record<string, string> = {
37
+ info: '#2f81f7',
38
+ warning: '#d29922',
39
+ error: '#f85149',
40
+ fatal: '#a371f7',
41
+ }
42
+
43
+ function paint(text: string, color: string, enabled: boolean): string {
44
+ return enabled ? `${color}${text}${ANSI.reset}` : text
45
+ }
46
+
47
+ function escapeHtml(text: string): string {
48
+ return text
49
+ .replace(/&/g, '&amp;')
50
+ .replace(/</g, '&lt;')
51
+ .replace(/>/g, '&gt;')
52
+ .replace(/"/g, '&quot;')
53
+ }
54
+
55
+ function location(info: ErrorInfo, file?: string): string | undefined {
56
+ if (info.where) return file ? `${file}:${info.where}` : info.where
57
+ if (file) return file
58
+ return undefined
59
+ }
60
+
61
+ export function format(info: ErrorInfo, kind: FormatKind = 'text', options: FormatOptions = {}): string {
62
+ switch (kind) {
63
+ case 'json':
64
+ return formatJson(info)
65
+ case 'html':
66
+ return formatHtml(info)
67
+ case 'markdown':
68
+ return formatMarkdown(info)
69
+ default:
70
+ return formatText(info, options)
71
+ }
72
+ }
73
+
74
+ function formatText(info: ErrorInfo, options: FormatOptions): string {
75
+ const color = options.color ?? Boolean(process.stdout.isTTY)
76
+ const tone = SEVERITY_COLOR[info.severity] ?? ANSI.red
77
+ const label = paint(info.severity, tone, color)
78
+ const lines: string[] = []
79
+
80
+ lines.push(`${label}${paint(`[${info.code}]`, ANSI.dim, color)} ${paint(info.title, ANSI.bold, color)}`)
81
+ if (info.message && info.message !== info.title) {
82
+ lines.push(` ${info.message}`)
83
+ }
84
+
85
+ const where = location(info, options.file)
86
+ if (where) lines.push(` ${paint('-->', ANSI.cyan, color)} ${where}`)
87
+
88
+ lines.push(` ${paint('为什么', ANSI.dim, color)}:${info.why}`)
89
+ if (info.fix.length) {
90
+ lines.push(` ${paint('怎么改', ANSI.dim, color)}:`)
91
+ info.fix.forEach((step, index) => lines.push(` ${index + 1}. ${step}`))
92
+ }
93
+ if (info.docs) lines.push(` ${paint('文档', ANSI.dim, color)}:${info.docs}`)
94
+
95
+ if (info.cause?.stack) {
96
+ const first = info.cause.stack.split('\n').slice(0, 3).join('\n ')
97
+ lines.push(` ${paint('原始错误', ANSI.dim, color)}:\n ${first}`)
98
+ }
99
+
100
+ return lines.join('\n')
101
+ }
102
+
103
+ function formatJson(info: ErrorInfo): string {
104
+ return JSON.stringify({
105
+ code: info.code,
106
+ title: info.title,
107
+ severity: info.severity,
108
+ category: info.category,
109
+ message: info.message,
110
+ where: info.where,
111
+ why: info.why,
112
+ fix: info.fix,
113
+ docs: info.docs,
114
+ ui: info.ui,
115
+ context: info.context,
116
+ cause: info.cause
117
+ ? { name: info.cause.name, message: info.cause.message, stack: info.cause.stack }
118
+ : undefined,
119
+ }, null, 2)
120
+ }
121
+
122
+ function formatHtml(info: ErrorInfo): string {
123
+ const color = info.ui?.color ?? SEVERITY_CSS[info.severity] ?? SEVERITY_CSS.error
124
+ const actions = (info.ui?.actions ?? []).map(action =>
125
+ ` <button type="button" class="spak-error-action">${escapeHtml(action)}</button>`).join('\n')
126
+ const steps = info.fix.map(step => ` <li>${escapeHtml(step)}</li>`).join('\n')
127
+
128
+ return `<section class="spak-error" data-code="${escapeHtml(info.code)}" data-severity="${escapeHtml(info.severity)}" style="--spak-error-color:${color}">
129
+ <header>
130
+ <span class="spak-error-icon">${escapeHtml(info.ui?.icon ?? '!')}</span>
131
+ <h2>${escapeHtml(info.title)}</h2>
132
+ <code>${escapeHtml(info.code)}</code>
133
+ </header>
134
+ <p class="spak-error-message">${escapeHtml(info.message)}</p>
135
+ ${info.where ? `<p class="spak-error-where">${escapeHtml(info.where)}</p>` : ''}
136
+ <p class="spak-error-why"><strong>为什么:</strong>${escapeHtml(info.why)}</p>
137
+ <div class="spak-error-fix">
138
+ <strong>怎么改:</strong>
139
+ <ol>
140
+ ${steps}
141
+ </ol>
142
+ </div>
143
+ ${actions ? ` <footer>\n${actions}\n </footer>` : ''}
144
+ </section>`
145
+ }
146
+
147
+ function formatMarkdown(info: ErrorInfo): string {
148
+ const lines: string[] = []
149
+ lines.push(`### ${info.code} · ${info.title}`)
150
+ lines.push('')
151
+ lines.push(`**严重程度**:${info.severity} **分类**:${info.category}`)
152
+ if (info.where) lines.push(`**位置**:\`${info.where}\``)
153
+ lines.push('')
154
+ if (info.message && info.message !== info.title) {
155
+ lines.push(info.message)
156
+ lines.push('')
157
+ }
158
+ lines.push(`**为什么**:${info.why}`)
159
+ lines.push('')
160
+ lines.push('**怎么改**:')
161
+ info.fix.forEach((step, index) => lines.push(`${index + 1}. ${step}`))
162
+ if (info.docs) {
163
+ lines.push('')
164
+ lines.push(`**文档**:${info.docs}`)
165
+ }
166
+ if (info.cause) {
167
+ lines.push('')
168
+ lines.push('<details><summary>原始错误</summary>')
169
+ lines.push('')
170
+ lines.push('```')
171
+ lines.push(`${info.cause.name}: ${info.cause.message}`)
172
+ if (info.cause.stack) lines.push(info.cause.stack)
173
+ lines.push('```')
174
+ lines.push('')
175
+ lines.push('</details>')
176
+ }
177
+ return lines.join('\n')
178
+ }
179
+
180
+ /** Exit code convention: fatal/error → 1, warning/info → 0. */
181
+ export function exitCodeFor(info: ErrorInfo): number {
182
+ return info.severity === 'fatal' || info.severity === 'error' ? 1 : 0
183
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Spak Error - Public API
3
+ *
4
+ * Every error has a stable code, an actionable explanation and four renderers.
5
+ */
6
+
7
+ export { CODES, codeNumber, isKnownCode } from './codes'
8
+ export type { ErrorCode } from './codes'
9
+
10
+ export { DEFINITIONS, allDefinitions, getDefinition, isFatal } from './definitions'
11
+ export type { Category, ErrorDefinition, ErrorUI, Severity } from './definitions'
12
+
13
+ export { SpakError, describe, fromDiagnostic, isSpakError } from './error'
14
+ export type { ErrorContext, ErrorInfo, SpakErrorOptions } from './error'
15
+
16
+ export { ERRNO_MAP, fromNative } from './native'
17
+ export type { NativeOptions } from './native'
18
+
19
+ export { exitCodeFor, format } from './format'
20
+ export type { FormatKind, FormatOptions } from './format'
package/src/native.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Spak Error - native error mapping
3
+ *
4
+ * Turns `errno` codes and well-known crash messages into Spak errors while
5
+ * keeping the original error as `cause`.
6
+ */
7
+
8
+ import { CODES } from './codes'
9
+ import { SpakError, isSpakError } from './error'
10
+
11
+ /** POSIX/Node errno → Spak code. */
12
+ export const ERRNO_MAP: Record<string, string> = {
13
+ ENOENT: CODES.FS_NOT_FOUND,
14
+ ENOTDIR: CODES.FS_NOT_FOUND,
15
+ EACCES: CODES.FS_DENIED_BY_OS,
16
+ EPERM: CODES.FS_OPERATION_NOT_PERMITTED,
17
+ ENOSPC: CODES.FS_NO_SPACE,
18
+ EMFILE: CODES.FS_TOO_MANY_OPEN_FILES,
19
+ ENFILE: CODES.FS_TOO_MANY_OPEN_FILES,
20
+ EISDIR: CODES.FS_IS_DIRECTORY,
21
+ EROFS: CODES.FS_DENIED_BY_OS,
22
+ EBADF: CODES.HANDLE_REVOKED,
23
+
24
+ EADDRINUSE: CODES.PORT_IN_USE,
25
+ EADDRNOTAVAIL: CODES.LISTEN_FAILED,
26
+ EACCES_LISTEN: CODES.LISTEN_FAILED,
27
+
28
+ ECONNREFUSED: CODES.NET_CONNECTION_REFUSED,
29
+ ECONNRESET: CODES.NET_CONNECTION_RESET,
30
+ ECONNABORTED: CODES.NET_CONNECTION_RESET,
31
+ EPIPE: CODES.NET_CONNECTION_RESET,
32
+ ETIMEDOUT: CODES.NET_TIMEOUT,
33
+ ESOCKETTIMEDOUT: CODES.NET_TIMEOUT,
34
+ ENOTFOUND: CODES.NET_DNS,
35
+ EAI_AGAIN: CODES.NET_DNS,
36
+
37
+ ERR_TLS_CERT_ALTNAME_INVALID: CODES.NET_TLS,
38
+ ERR_SSL_WRONG_VERSION_NUMBER: CODES.NET_TLS,
39
+ DEPTH_ZERO_SELF_SIGNED_CERT: CODES.NET_TLS,
40
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: CODES.NET_TLS,
41
+ SELF_SIGNED_CERT_IN_CHAIN: CODES.NET_TLS,
42
+
43
+ MODULE_NOT_FOUND: CODES.MODULE_NOT_FOUND,
44
+ ERR_MODULE_NOT_FOUND: CODES.MODULE_NOT_FOUND,
45
+ ERR_REQUIRE_ESM: CODES.MODULE_NOT_FOUND,
46
+ ERR_PACKAGE_PATH_NOT_EXPORTED: CODES.MODULE_NOT_FOUND,
47
+ ERR_INVALID_ARG_TYPE: CODES.BUILD_FAILED,
48
+ }
49
+
50
+ /** Message patterns for failures that have no errno (crashes, engine errors). */
51
+ const MESSAGE_MAP: Array<{ pattern: RegExp, code: string }> = [
52
+ { pattern: /JavaScript heap out of memory/i, code: CODES.OUT_OF_MEMORY },
53
+ { pattern: /Maximum call stack size exceeded/i, code: CODES.STACK_OVERFLOW },
54
+ { pattern: /Segmentation fault|core dumped|double free|invalid pointer|bus error/i, code: CODES.NATIVE_CRASH },
55
+ { pattern: /Unexpected token .* in JSON|is not valid JSON|JSON at position/i, code: CODES.PAK_MANIFEST_INVALID },
56
+ { pattern: /\.l2s\.|FUSE|rename .*ENOENT/i, code: CODES.FS_RENAME_UNSUPPORTED },
57
+ { pattern: /socket hang up/i, code: CODES.NET_CONNECTION_RESET },
58
+ { pattern: /self-signed certificate|certificate has expired|unable to verify/i, code: CODES.NET_TLS },
59
+ { pattern: /basic auth|proxy authentication/i, code: CODES.NET_PROXY_AUTH },
60
+ { pattern: /Cannot find module/i, code: CODES.MODULE_NOT_FOUND },
61
+ { pattern: /EACCES.*listen|permission denied.*:?\d{2,5}/i, code: CODES.LISTEN_FAILED },
62
+ ]
63
+
64
+ export interface NativeOptions {
65
+ where?: string
66
+ context?: Record<string, unknown>
67
+ }
68
+
69
+ /**
70
+ * Normalise any thrown value into a `SpakError`.
71
+ * Unknown shapes still produce an error (SPAK-9999) with the original cause.
72
+ */
73
+ export function fromNative(error: unknown, options: NativeOptions = {}): SpakError {
74
+ if (isSpakError(error)) return error
75
+
76
+ const cause = error instanceof Error ? error : new Error(String(error))
77
+ const errno = typeof (error as { code?: unknown })?.code === 'string'
78
+ ? String((error as { code?: string }).code)
79
+ : undefined
80
+ const message = cause.message ?? ''
81
+ const context: Record<string, unknown> = { ...options.context }
82
+ if (errno) context.errno = errno
83
+
84
+ if (errno) {
85
+ const direct = ERRNO_MAP[errno]
86
+ if (direct) {
87
+ // Listen failures share EACCES with file access; the syscall tells them apart.
88
+ const syscall = String((error as { syscall?: unknown })?.syscall ?? '')
89
+ if (errno === 'EACCES' && syscall === 'listen') {
90
+ return new SpakError(CODES.LISTEN_FAILED, { cause, message, where: options.where, context })
91
+ }
92
+ if (errno === 'ENOENT' && /\.l2s\.|tmp_obj_|\.git\/objects/.test(message)) {
93
+ return new SpakError(CODES.FS_RENAME_UNSUPPORTED, { cause, message, where: options.where, context })
94
+ }
95
+ return new SpakError(direct, { cause, message, where: options.where, context })
96
+ }
97
+ }
98
+
99
+ for (const entry of MESSAGE_MAP) {
100
+ if (entry.pattern.test(message)) {
101
+ return new SpakError(entry.code, { cause, message, where: options.where, context })
102
+ }
103
+ }
104
+
105
+ return new SpakError(CODES.UNKNOWN, { cause, message, where: options.where, context })
106
+ }
107
+
108
+ export { ERRNO_MAP as ERRNO_TO_SPAK }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.