dexin-content 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 得心实验室 (Dexin Labs — https://gitee.com/cuizhn)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,112 @@
1
+ # dexin-content
2
+
3
+ 得心实验室的**结构化内容编译与运行时 package**。
4
+
5
+ 负责与具体业务领域无关的通用内容管线:
6
+
7
+ - **Compile**:将带 frontmatter 的 Markdown 编译成中性 Content AST,再经宿主注入的 `DomainParser` 产出稳定的 PositiveArtifact
8
+ - **Store**:Artifact 与索引(ContentIndex)的 FS/Memory 抽象
9
+ - **Query**:按 id / URL path / collection / 条件过滤对 Artifact 做只读查询
10
+ - **Collection**:集合声明、发现、批量编译与增量变化检测
11
+ - **Diff**:Canonical JSON 序列化与对象级差异原语(Golden/Regress 管线使用)
12
+
13
+ ## 目录范围
14
+
15
+ dexin-content 是**通用层**:
16
+ - `core/` 编译核心(types / compiler / markdown / frontmatter / discovery)
17
+ - 根层 `collection.ts`、`store.ts`、`query.ts`、`diff.ts` 是稳定能力
18
+ - `scripts/` 仓内脚本(不在发布包 files 内,仅供本地开发/测试;也不作为公开 API)
19
+
20
+ 宿主应用负责域扩展(DomainParser)、编排脚本与内容文件所有权,通过 `exports` 里的子路径消费本 package。
21
+
22
+ ## 安装
23
+
24
+ > 需要 Node.js ≥ 20。Package 以 TypeScript 源码形式分发(`*.ts`),宿主应通过 `tsx`、Nuxt/Vite 等支持 TS 解析的工具链使用。
25
+
26
+ ```bash
27
+ # Gitee 源(当前发布前)
28
+ npm install git+https://gitee.com/cuizhn/dexin-content.git
29
+
30
+ # 发布到 npm 后
31
+ npm install dexin-content
32
+ ```
33
+
34
+ ## 公开 API 边界
35
+
36
+ 唯一公共 API 边界是 `package.json` 中 `exports` 字段。**只使用下列路径**:
37
+
38
+ | 子路径 | 暴露内容 |
39
+ |---|---|
40
+ | `.` | 门面:核心类型 + `DomainParserRegistry` / `compile` + `ArtifactStore` / `ContentQuery` + `defineCollection / resolveCollections / discover / compileCollections` |
41
+ | `./core/types` | 类型:`Inline` / `DocumentBlock` / `DocumentContent` / `DocumentIdentity` / `PositiveArtifact` / `Artifact` / `Meta` / `ParseError` / `DomainParser` / `Schema` / `ParseContext` |
42
+ | `./core/compiler` | `compile`、`CompileInput`、`CompileResult`、`DomainParserRegistry` |
43
+ | `./core/discovery` | `buildDocumentIdentity` / `normaliseRel` / `readSourceFile` / `sanitizeSourceText` / `mkLineEndingError` |
44
+ | `./core/frontmatter` | `splitFrontmatter` / `parseFrontmatter` / `validateSchema` / `projectMeta` |
45
+ | `./store` | `ArtifactStore` 接口 + `createFsArtifactStore` / `createMemoryArtifactStore` + `ContentIndex` / `IndexEntry` |
46
+ | `./collection` | `defineCollection` / `resolveCollections` / `compileCollections` / `recompileChanged` / `createLocalSource` / `createMemorySource` / `SourceAdapter` / `ResolvedCollection` / `CollectionDefinition` |
47
+ | `./query` | `ContentQuery` (byId / byPath / list / collection) + `QueryOptions` |
48
+ | `./diff` | `toCanonicalJSON` / `sortKeysDeep` / `stripUnderscoreKeysGolden` / `underscorePrefixedPaths` / `firstDiff` / `shortStr` |
49
+
50
+ 任何未列入上表的内部路径均不保证稳定。
51
+
52
+ ## 快速示例
53
+
54
+ ```ts
55
+ import {
56
+ defineCollection,
57
+ resolveCollections,
58
+ createLocalSource,
59
+ compileCollections,
60
+ } from 'dexin-content/collection'
61
+ import { createFsArtifactStore } from 'dexin-content/store'
62
+ import { ContentQuery } from 'dexin-content/query'
63
+ import { DomainParserRegistry } from 'dexin-content/core/compiler'
64
+ import type {
65
+ DomainParser,
66
+ DocumentContent,
67
+ ParseContext,
68
+ } from 'dexin-content/core/types'
69
+
70
+ // 1) 注入你的域 parser;parser 决定中性 AST 如何映射到目标 Artifact 内容结构
71
+ const documentParser: DomainParser = {
72
+ domain: 'document',
73
+ parse(content: DocumentContent, _ctx: ParseContext) {
74
+ return { version: 1, blocks: content.blocks as unknown[] }
75
+ },
76
+ }
77
+ const registry = new DomainParserRegistry()
78
+ registry.register(documentParser)
79
+
80
+ // 2) 声明集合 + 编译
81
+ const collections = resolveCollections(
82
+ [defineCollection({ name: 'legal', source: 'legal', domain: 'document' })],
83
+ '/path/to/content/root',
84
+ )
85
+ const source = createLocalSource('/path/to/content/root')
86
+ const store = createFsArtifactStore('/path/to/output/dexin-content')
87
+ await compileCollections(source, collections, registry, store)
88
+
89
+ // 3) 运行时只读查询(不解析任何 Markdown)
90
+ const query = new ContentQuery(store)
91
+ const entry = await query.byPath('/legal/privacy')
92
+ ```
93
+
94
+ ## 开发
95
+
96
+ ```bash
97
+ # 依赖
98
+ npm install
99
+
100
+ # 类型检查(唯一静态门禁)
101
+ npm run typecheck
102
+
103
+ # 仓内自检测(需先 build 输出 output/dexin-content/)
104
+ npm run runtime:check
105
+
106
+ # 预发布 tarball 内容检查
107
+ npm run pack
108
+ ```
109
+
110
+ ## License
111
+
112
+ MIT © 得心实验室。详见 [LICENSE](./LICENSE)。
package/collection.ts ADDED
@@ -0,0 +1,289 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/collection.ts
3
+ //
4
+ // Collection declaration + source adaptation + discovery +
5
+ // batch compile orchestration.
6
+ //
7
+ // The batch compileCollections() wraps the formal per-file
8
+ // core/compiler.ts compile() — it discovers files, builds
9
+ // CompileInput per file, calls compile, and writes PositiveArtifact
10
+ // docs + ContentIndex to the store.
11
+ //
12
+ // grep-zero: no business vocabulary appears anywhere in this
13
+ // module — including error messages.
14
+ // ─────────────────────────────────────────────────────────────
15
+
16
+ import { readdir, readFile } from 'node:fs/promises'
17
+ import { join } from 'node:path'
18
+ import { createHash } from 'node:crypto'
19
+ import type {
20
+ PositiveArtifact,
21
+ DomainName
22
+ } from './core/types'
23
+ import type { DomainParserRegistry, CompileInput } from './core/compiler'
24
+ import { compile } from './core/compiler'
25
+ import { buildDocumentIdentity, sanitizeSourceText } from './core/discovery'
26
+ import { splitFrontmatter } from './core/frontmatter'
27
+ import type { ArtifactStore, ContentIndex, IndexEntry } from './store'
28
+
29
+ // ── Collection types ──
30
+
31
+ export interface CollectionDefinition {
32
+ /** Collection name (appears in identity.collection + IndexEntry.collection). */
33
+ name: string
34
+ /** Directory under content root; .md files collected recursively. */
35
+ source: string
36
+ /** Domain tag; defaults to 'document' when omitted. */
37
+ domain?: DomainName | string
38
+ /** Frontmatter schema (reuses core Schema; validator-only, no meta projection). */
39
+ schema?: { required?: string[]; types?: Record<string, 'string' | 'number' | 'boolean'> }
40
+ }
41
+
42
+ export interface ResolvedCollection extends CollectionDefinition {
43
+ domain: string
44
+ sourceRoot: string
45
+ }
46
+
47
+ /** Declaration passthrough (declarative API semantic); resolution in resolveCollections. */
48
+ export function defineCollection (def: CollectionDefinition): CollectionDefinition {
49
+ return def
50
+ }
51
+
52
+ /** Resolve definitions: fill domain default + bind absolute sourceRoot. */
53
+ export function resolveCollections (
54
+ defs: CollectionDefinition[],
55
+ contentRoot: string
56
+ ): ResolvedCollection[] {
57
+ return defs.map(d => ({
58
+ ...d,
59
+ domain: d.domain ?? 'document',
60
+ sourceRoot: join(contentRoot, d.source)
61
+ }))
62
+ }
63
+
64
+ // ── SourceAdapter ──
65
+
66
+ export interface SourceAdapter {
67
+ list(): Promise<string[]>
68
+ read(relPath: string): Promise<string>
69
+ }
70
+
71
+ /**
72
+ * Local filesystem source. Reads .md files recursively from root.
73
+ * CRLF protection (R-d): read() throws LINE_ENDING_CONTAMINATION
74
+ * on CRLF so the batch path enjoys the same guard as the synchronous
75
+ * single-file read path.
76
+ *
77
+ * Sanitisation (BOM strip + CRLF guard) is delegated to the shared
78
+ * `sanitizeSourceText` pure helper in core/discovery — byte-identical
79
+ * to the synchronous readSourceFile() path.
80
+ */
81
+ export function createLocalSource (root: string): SourceAdapter {
82
+ async function walk (dir: string, base: string): Promise<string[]> {
83
+ const entries = await readdir(dir, { withFileTypes: true })
84
+ const out: string[] = []
85
+ for (const e of entries) {
86
+ const rel = base ? `${base}/${e.name}` : e.name
87
+ if (e.isDirectory()) out.push(...await walk(join(dir, e.name), rel))
88
+ else if (e.isFile() && e.name.endsWith('.md')) out.push(rel)
89
+ }
90
+ return out.sort()
91
+ }
92
+ return {
93
+ list: () => walk(root, ''),
94
+ read: async (rel) => {
95
+ const raw = await readFile(join(root, rel), 'utf8')
96
+ return sanitizeSourceText(raw, rel)
97
+ }
98
+ }
99
+ }
100
+
101
+ /** Virtual in-memory source (test use). */
102
+ export function createMemorySource (files: Record<string, string>): SourceAdapter {
103
+ return {
104
+ list: async () => Object.keys(files).sort(),
105
+ read: async (rel) => {
106
+ if (!(rel in files)) throw new Error(`[MemorySource] Not found: ${rel}`)
107
+ return files[rel]
108
+ }
109
+ }
110
+ }
111
+
112
+ // ── Discovery ──
113
+
114
+ export interface ContentFile {
115
+ id: string
116
+ path: string
117
+ collection: string
118
+ file: string
119
+ /**
120
+ * Raw source (YAML frontmatter + Markdown body), already BOM/CRLF sanitised.
121
+ * Cached at discovery phase so compileCollections() avoids a second IO read
122
+ * per file; the split into frontmatter/body happens only when needed below.
123
+ */
124
+ raw: string
125
+ }
126
+
127
+ /**
128
+ * Scan all collections via source. Files not belonging to any
129
+ * collection → throw (fail-fast, no silent drop).
130
+ *
131
+ * Identity derivation: document path convention via
132
+ * buildDocumentIdentity (id = rel minus .md minus trailing /index,
133
+ * path = '/' + id, file = rel, collection = name). Non-document
134
+ * domains are not supported in the batch path; hosts requiring
135
+ * custom identity shapes must construct CompileInput per file
136
+ * themselves — a thrown error makes the gap explicit.
137
+ */
138
+ export async function discover (
139
+ source: SourceAdapter,
140
+ collections: ResolvedCollection[]
141
+ ): Promise<ContentFile[]> {
142
+ const byDir = new Map(collections.map(c => [c.source, c]))
143
+ const all = await source.list()
144
+ const files: ContentFile[] = []
145
+ for (const rel of all) {
146
+ const top = rel.split('/')[0]
147
+ const col = byDir.get(top)
148
+ if (!col) {
149
+ throw new Error(
150
+ `[discover] File '${rel}' belongs to no collection (top dir '${top}' undefined).`
151
+ )
152
+ }
153
+ if (col.domain !== 'document') {
154
+ throw new Error(
155
+ `[discover] Domain '${col.domain}' discovery not yet implemented in batch path; ` +
156
+ `only 'document' domain is supported here. Non-document domains require ` +
157
+ `explicit identity construction.`
158
+ )
159
+ }
160
+ const identity = buildDocumentIdentity(rel, col.name)
161
+ const raw = await source.read(rel)
162
+ files.push({
163
+ id: identity.id,
164
+ path: identity.path,
165
+ collection: col.name,
166
+ file: identity.file,
167
+ raw
168
+ })
169
+ }
170
+ return files
171
+ }
172
+
173
+ // ── Batch compile orchestration ──
174
+
175
+ function checksum (s: string): string {
176
+ return createHash('md5').update(s, 'utf-8').digest('hex').slice(0, 12)
177
+ }
178
+
179
+ /**
180
+ * Batch compile: discover → per-file compile → write docs + index to store.
181
+ *
182
+ * For each ContentFile:
183
+ * 1. Build CompileInput (id = file.id, identity = buildDocumentIdentity,
184
+ * source = raw, schema = col.schema)
185
+ * 2. Call formal compile(input, registry)
186
+ * 3. On positive → collect PositiveArtifact + IndexEntry
187
+ * On error → collect error message (fail-fast: throw after all files processed)
188
+ *
189
+ * Store receives only positive artifacts. compile failures are aggregated
190
+ * and thrown as a single Error.
191
+ */
192
+ export async function compileCollections (
193
+ source: SourceAdapter,
194
+ collections: ResolvedCollection[],
195
+ registry: DomainParserRegistry,
196
+ store: ArtifactStore
197
+ ): Promise<{ docs: PositiveArtifact[]; index: ContentIndex }> {
198
+ const files = await discover(source, collections)
199
+ const docs: PositiveArtifact[] = []
200
+ const entries: IndexEntry[] = []
201
+ const errors: string[] = []
202
+
203
+ for (const f of files) {
204
+ const col = collections.find(c => c.name === f.collection)!
205
+ // Reuse discovery-cached source; no second per-file IO read.
206
+ // `body` (post-frontmatter) is derived once solely for the
207
+ // change-detection checksum — compile() takes the full raw
208
+ // source per its stable CompileInput contract.
209
+ const { body } = splitFrontmatter(f.raw)
210
+ const input: CompileInput = {
211
+ id: f.id,
212
+ domain: col.domain,
213
+ identity: buildDocumentIdentity(f.file, col.name),
214
+ source: f.raw,
215
+ file: f.file,
216
+ schema: col.schema
217
+ }
218
+ const result = compile(input, registry)
219
+ if (result.kind !== 'positive') {
220
+ errors.push(` ✗ ${f.file}: ${result.error?.message ?? 'unknown'}`)
221
+ continue
222
+ }
223
+ const artifact = result.artifact as PositiveArtifact
224
+ docs.push(artifact)
225
+ entries.push({
226
+ id: f.id,
227
+ path: f.path,
228
+ collection: f.collection,
229
+ domain: col.domain,
230
+ file: f.file,
231
+ meta: result.meta,
232
+ checksum: checksum(body)
233
+ })
234
+ }
235
+
236
+ if (errors.length > 0) {
237
+ throw new Error(`Compile failed (${errors.length} error(s)):\n${errors.join('\n')}`)
238
+ }
239
+
240
+ const index: ContentIndex = {
241
+ generator: 'dexin-content',
242
+ builtAt: new Date().toISOString(),
243
+ entries
244
+ }
245
+
246
+ await store.writeIndex(index)
247
+ for (const doc of docs) {
248
+ await store.writeDoc(doc)
249
+ }
250
+
251
+ return { docs, index }
252
+ }
253
+
254
+ // ── Incremental recompile ──
255
+
256
+ /**
257
+ * Detect changed/removed docs by checksum diff against the previous index.
258
+ *
259
+ * Wraps compileCollections (which writes index + all docs to store), then
260
+ * reports which ids changed (checksum differs from prev) or were removed
261
+ * (present in prev, absent in next). Used by Dev flows (memory store)
262
+ * to drive incremental re-render signals. Writing all docs is acceptable
263
+ * for ephemeral in-memory Dev stores. Watcher / preview server
264
+ * integrations are out of scope.
265
+ */
266
+ export async function recompileChanged (
267
+ source: SourceAdapter,
268
+ collections: ResolvedCollection[],
269
+ registry: DomainParserRegistry,
270
+ store: ArtifactStore
271
+ ): Promise<{ changed: string[]; removed: string[] }> {
272
+ const prev = await store.readIndex()
273
+ const prevMap = new Map((prev?.entries ?? []).map(e => [e.id, e]))
274
+
275
+ const { index } = await compileCollections(source, collections, registry, store)
276
+
277
+ const nextIds = new Set(index.entries.map(e => e.id))
278
+ const changed: string[] = []
279
+ const removed: string[] = []
280
+
281
+ for (const e of index.entries) {
282
+ if (prevMap.get(e.id)?.checksum !== e.checksum) changed.push(e.id)
283
+ }
284
+ for (const id of prevMap.keys()) {
285
+ if (!nextIds.has(id)) removed.push(id)
286
+ }
287
+
288
+ return { changed, removed }
289
+ }
@@ -0,0 +1,174 @@
1
+ // ─────────────────────────────────────────────────────────────
2
+ // dexin-content/core/compiler.ts
3
+ //
4
+ // Pipeline:
5
+ // read source → split frontmatter → project meta (SCHEMA-FREE) →
6
+ // validate schema (if any) → parse Markdown to DocumentAST →
7
+ // dispatch to domain parser → assemble PositiveArtifact (5 fields).
8
+ //
9
+ // The compiler does NOT hard-code domain parsers internally. Instead they
10
+ // are injected via `DomainParserRegistry` by the host runner, satisfying
11
+ // the dependency inversion principle.
12
+ // ─────────────────────────────────────────────────────────────
13
+
14
+ import type {
15
+ DomainParser,
16
+ DocumentContent,
17
+ Artifact,
18
+ ParseContext,
19
+ DocumentIdentity,
20
+ Meta,
21
+ ParseError,
22
+ Schema
23
+ } from './types'
24
+ import {
25
+ parseFrontmatter,
26
+ splitFrontmatter,
27
+ validateSchema
28
+ } from './frontmatter'
29
+ import { parseDocument } from './markdown'
30
+
31
+ export type ArtifactKind = 'positive' | 'error'
32
+
33
+ export class DomainParserRegistry {
34
+ private parsers = new Map<string, DomainParser>()
35
+ register (parser: DomainParser): void {
36
+ if (this.parsers.has(parser.domain)) {
37
+ throw new Error(`[compiler] Domain parser already registered for '${parser.domain}'`)
38
+ }
39
+ this.parsers.set(parser.domain, parser)
40
+ }
41
+ get (domain: string): DomainParser | undefined {
42
+ return this.parsers.get(domain)
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Inputs required by `compile`. This is deliberately a plain object so the
48
+ * host runner can mix-and-match per-collection identity derivation rules.
49
+ */
50
+ export interface CompileInput {
51
+ /** Stable document id — used in the output Artifact.id field */
52
+ id: string
53
+ /** The domain tag to compile under. Determines parser lookup. */
54
+ domain: string
55
+ /** Identity shape computed by host/discovery layer (document path convention). */
56
+ identity: DocumentIdentity
57
+ /** Raw Markdown source WITH frontmatter. */
58
+ source: string
59
+ /** File path used in error messages (relative, short form). */
60
+ file: string
61
+ /** Optional collection schema for frontmatter validation. */
62
+ schema?: Schema
63
+ }
64
+
65
+ export interface CompileResult {
66
+ kind: ArtifactKind
67
+ artifact: Artifact
68
+ /** Parsed DocumentAST; undefined if parsing failed before this stage. */
69
+ docAST?: DocumentContent
70
+ /** Parsed meta (SCHEMA-FREE scalar projection). Always present. */
71
+ meta: Meta
72
+ error?: Error & ParseError
73
+ }
74
+
75
+ /**
76
+ * Run the compile pipeline once. Fail-fast errors are returned wrapped as
77
+ * CompileResult with kind = 'error' — no throw out of this function, so the
78
+ * runner can log + diff against L5 descriptor.
79
+ */
80
+ export function compile (
81
+ input: CompileInput,
82
+ registry: DomainParserRegistry
83
+ ): CompileResult {
84
+ let meta: Meta = {}
85
+ try {
86
+ // 1. Frontmatter split + parse + SCHEMA-FREE scalar projection.
87
+ const { frontmatter, body } = splitFrontmatter(input.source)
88
+ meta = parseFrontmatter(frontmatter, input.file)
89
+
90
+ // 2. Schema validation (schema is validator-only — doesn't change meta).
91
+ validateSchema(meta, input.schema, input.file)
92
+
93
+ // 3. Markdown → DocumentAST (neutral).
94
+ // Note: the GENERIC layer does NOT throw for md h5/h6 — it produces a
95
+ // structural HeadingBlock with synthetic level=4 and attaches the
96
+ // original md depth to the explicit HeadingBlock.mdDepth optional
97
+ // field. Fail-fast for out-of-range heading depths is the ACTIVE
98
+ // DOMAIN PARSER's responsibility.
99
+ const docAST = parseDocument(body, input.file)
100
+
101
+ // 4. Domain parser dispatch.
102
+ const parser = registry.get(input.domain)
103
+ if (!parser) {
104
+ throw mkError(
105
+ `COMPILER_NO_DOMAIN_PARSER`,
106
+ `[compiler] No domain parser registered for '${input.domain}' — file: ${input.file}`,
107
+ input.file
108
+ )
109
+ }
110
+ const ctx: ParseContext = {
111
+ file: input.file,
112
+ schema: input.schema,
113
+ meta
114
+ }
115
+ const content = parser.parse(docAST, ctx)
116
+
117
+ return {
118
+ kind: 'positive',
119
+ meta,
120
+ docAST,
121
+ artifact: {
122
+ id: input.id,
123
+ domain: input.domain,
124
+ identity: input.identity,
125
+ meta,
126
+ content: { version: 1, blocks: content.blocks }
127
+ }
128
+ }
129
+ } catch (e) {
130
+ const err = normaliseError(e, input.file)
131
+ return {
132
+ kind: 'error',
133
+ meta,
134
+ error: err,
135
+ artifact: {
136
+ id: input.id,
137
+ domain: input.domain,
138
+ identity: input.identity,
139
+ meta,
140
+ kind: 'COMPILE_ERROR',
141
+ error: { code: err.code, message: err.message }
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ // ── Error helpers ────────────────────────────────────────
148
+
149
+ function mkError (code: string, message: string, file: string): ParseError & Error {
150
+ const e = new Error(message) as ParseError & Error
151
+ e.code = code
152
+ e.file = file
153
+ return e
154
+ }
155
+
156
+ function normaliseError (e: unknown, file: string): ParseError & Error {
157
+ if (e && typeof e === 'object') {
158
+ const obj = e as Record<string, unknown>
159
+ if ('code' in obj && 'message' in obj) {
160
+ // already a ParseError — ensure .file set
161
+ const err = e as ParseError & Error
162
+ if (!err.file) err.file = file
163
+ return err
164
+ }
165
+ if (e instanceof Error) {
166
+ const err = e as ParseError & Error
167
+ const obj = e as unknown as Record<string, unknown>
168
+ err.code = (obj.code as string) ?? 'UNKNOWN_ERROR'
169
+ err.file = file
170
+ return err
171
+ }
172
+ }
173
+ return mkError('UNKNOWN_ERROR', String(e), file)
174
+ }