locter 2.2.1 โ†’ 4.0.0-beta.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.
Files changed (39) hide show
  1. package/README.MD +581 -44
  2. package/dist/index.d.mts +563 -0
  3. package/dist/index.mjs +1117 -671
  4. package/dist/index.mjs.map +1 -1
  5. package/package.json +36 -40
  6. package/dist/index.cjs +0 -743
  7. package/dist/index.cjs.map +0 -1
  8. package/dist/index.d.ts +0 -3
  9. package/dist/loader/built-in/conf/index.d.ts +0 -1
  10. package/dist/loader/built-in/conf/module.d.ts +0 -6
  11. package/dist/loader/built-in/index.d.ts +0 -3
  12. package/dist/loader/built-in/json/index.d.ts +0 -1
  13. package/dist/loader/built-in/json/module.d.ts +0 -5
  14. package/dist/loader/built-in/module/index.d.ts +0 -3
  15. package/dist/loader/built-in/module/module.d.ts +0 -15
  16. package/dist/loader/built-in/module/type.d.ts +0 -8
  17. package/dist/loader/built-in/module/utils.d.ts +0 -9
  18. package/dist/loader/built-in/yaml/index.d.ts +0 -1
  19. package/dist/loader/built-in/yaml/module.d.ts +0 -5
  20. package/dist/loader/constants.d.ts +0 -6
  21. package/dist/loader/helpers.d.ts +0 -6
  22. package/dist/loader/index.d.ts +0 -3
  23. package/dist/loader/module.d.ts +0 -18
  24. package/dist/loader/singleton.d.ts +0 -2
  25. package/dist/loader/type.d.ts +0 -8
  26. package/dist/locator/async.d.ts +0 -3
  27. package/dist/locator/index.d.ts +0 -4
  28. package/dist/locator/sync.d.ts +0 -3
  29. package/dist/locator/types.d.ts +0 -15
  30. package/dist/locator/utils.d.ts +0 -7
  31. package/dist/utils/error.d.ts +0 -1
  32. package/dist/utils/file-name.d.ts +0 -2
  33. package/dist/utils/file-path.d.ts +0 -1
  34. package/dist/utils/has-property.d.ts +0 -2
  35. package/dist/utils/index.d.ts +0 -8
  36. package/dist/utils/object.d.ts +0 -2
  37. package/dist/utils/runtime.d.ts +0 -2
  38. package/dist/utils/to-array.d.ts +0 -1
  39. package/dist/utils/typescript.d.ts +0 -1
package/README.MD CHANGED
@@ -1,19 +1,59 @@
1
- # Locter ๐Ÿ”ฅ
2
-
3
- [![npm version](https://badge.fury.io/js/locter.svg)](https://badge.fury.io/js/locter)
4
- [![CI](https://github.com/tada5hi/locter/actions/workflows/main.yml/badge.svg)](https://github.com/tada5hi/locter/actions/workflows/main.yml)
5
- [![codecov](https://codecov.io/gh/Tada5hi/locter/branch/master/graph/badge.svg?token=4KNSG8L13V)](https://codecov.io/gh/Tada5hi/locter)
6
- [![Known Vulnerabilities](https://snyk.io/test/github/Tada5hi/locter/badge.svg?targetFile=package.json)](https://snyk.io/test/github/Tada5hi/locter?targetFile=package.json)
7
- [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white)](https://conventionalcommits.org)
8
-
9
- Locter is a library to locate and load a file/modules regarding specific criteria.
1
+ <p align="center">
2
+ <img src="./assets/logo.svg" alt="locter" width="130" />
3
+ </p>
4
+
5
+ <h1 align="center">locter</h1>
6
+
7
+ <p align="center">
8
+ <b>Locate, read &amp; write files โ€” one API.</b><br>
9
+ Glob-based file discovery plus a pluggable format layer for JSON, YAML,
10
+ <code>.conf</code> and TS/ESM/CJS modules โ€” every operation sync <i>and</i> async.
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://www.npmjs.com/package/locter"><img src="https://badge.fury.io/js/locter.svg" alt="npm version" /></a>
15
+ <a href="https://github.com/tada5hi/locter/actions/workflows/main.yml"><img src="https://github.com/tada5hi/locter/actions/workflows/main.yml/badge.svg" alt="CI" /></a>
16
+ <a href="https://codecov.io/gh/Tada5hi/locter"><img src="https://codecov.io/gh/Tada5hi/locter/branch/master/graph/badge.svg?token=4KNSG8L13V" alt="codecov" /></a>
17
+ <a href="https://snyk.io/test/github/Tada5hi/locter?targetFile=package.json"><img src="https://snyk.io/test/github/Tada5hi/locter/badge.svg?targetFile=package.json" alt="Known Vulnerabilities" /></a>
18
+ <a href="https://conventionalcommits.org"><img src="https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196?logo=conventionalcommits&logoColor=white" alt="Conventional Commits" /></a>
19
+ </p>
20
+
21
+ <p align="center">
22
+ <a href="#installation"><b>Installation</b></a>
23
+ ยท
24
+ <a href="#usage">Usage</a>
25
+ ยท
26
+ <a href="#formats">Formats</a>
27
+ ยท
28
+ <a href="#errors">Errors</a>
29
+ ยท
30
+ <a href="#migration">Migration</a>
31
+ </p>
32
+
33
+ ---
34
+
35
+ ## Features
36
+
37
+ - ๐Ÿ” **Locate** โ€” `locate`, `locateMany`, `locateUp`: fast-glob discovery returning structured `LocatorInfo` records.
38
+ - ๐Ÿ“– **Read** โ€” one `read()` for JSON, YAML, `.conf`, and JS/TS modules in any runtime (powered by `jiti`); every result normalized to a module record.
39
+ - โœ๏ธ **Write** โ€” `write()` values back without pulling serializers into your app: comment-preserving YAML, indent-preserving JSON, rc9-style `.conf`.
40
+ - ๐Ÿ” **Sync twins** โ€” every operation ships as `fn` / `fnSync`, derived from one shared implementation.
41
+ - ๐Ÿงฉ **Extensible** โ€” register custom formats with independent reader/writer slots; override built-ins per extension.
42
+ - ๐Ÿšจ **Typed errors** โ€” `LocterNotFoundError`, `LocterLoadError`, `LocterWriteError`, โ€ฆ each carrying `path` and `cause`.
10
43
 
11
44
  **Table of Contents**
12
45
 
13
46
  - [Installation](#installation)
14
47
  - [Usage](#usage)
15
48
  - [Locator](#locator)
16
- - [Loader](#loader)
49
+ - [Reader](#reader)
50
+ - [Writer](#writer)
51
+ - [Formats](#formats)
52
+ - [Errors](#errors)
53
+ - [Runtime environments](#runtime-environments)
54
+ - [Migration](#migration)
55
+ - [Migrating from 3.x](#migrating-from-3x)
56
+ - [Migrating from 2.x](#migrating-from-2x)
17
57
  - [License](#license)
18
58
 
19
59
  ## Installation
@@ -50,8 +90,8 @@ import { locateMany } from 'locter';
50
90
  console.log(files);
51
91
  /*
52
92
  [
53
- { path: 'files', name: 'example', extension: '.js'},
54
- { path: 'files', name: 'example', extension: '.ts'}
93
+ { directory: '/cwd/files', name: 'example', extension: '.js', path: '/cwd/files/example.js' },
94
+ { directory: '/cwd/files', name: 'example', extension: '.ts', path: '/cwd/files/example.ts' }
55
95
  ]
56
96
  */
57
97
 
@@ -62,9 +102,9 @@ import { locateMany } from 'locter';
62
102
  console.log(files);
63
103
  /*
64
104
  [
65
- { path: 'files', name: 'example', extension: '.js'},
66
- { path: 'files', name: 'example', extension: '.ts'},
67
- { path: 'files', name: 'example-long', extension: '.ts'},
105
+ { directory: '/cwd/files', name: 'example', extension: '.js', path: '/cwd/files/example.js' },
106
+ { directory: '/cwd/files', name: 'example', extension: '.ts', path: '/cwd/files/example.ts' },
107
+ { directory: '/cwd/files', name: 'example-long', extension: '.ts', path: '/cwd/files/example-long.ts' }
68
108
  ]
69
109
  */
70
110
  })
@@ -86,78 +126,575 @@ import { locate } from 'locter';
86
126
 
87
127
  console.log(file);
88
128
  /*
89
- { path: 'files', name: 'example', extension: '.js'}
129
+ { directory: '/cwd/files', name: 'example', extension: '.js', path: '/cwd/files/example.js' }
90
130
  */
91
131
  })
92
132
  ```
93
133
 
94
134
  A synchronous variant is also available: `locateSync`
95
135
 
96
- ### Loader
136
+ **Walking up**
137
+
138
+ `locateUp` walks from a starting directory toward the filesystem root and returns the first match (useful for discovering config files at the repo root from any sub-directory):
139
+
140
+ ```typescript
141
+ import { locateUp } from 'locter';
142
+
143
+ (async () => {
144
+ const info = await locateUp('trapi.config.{ts,mts,cts,mjs,cjs,js,json}', {
145
+ cwd: process.cwd(),
146
+ });
147
+
148
+ console.log(info);
149
+ /*
150
+ { directory: '/repo', name: 'trapi.config', extension: '.ts', path: '/repo/trapi.config.ts' }
151
+ or `undefined` if nothing matched
152
+ */
153
+ })
154
+ ```
155
+
156
+ Pass `stopAt: '/repo'` to cap the walk at a known ceiling โ€” inclusive, so the ceiling directory itself is still searched.
157
+
158
+ A synchronous variant `locateUpSync` is also available.
159
+
160
+ **Options**
161
+
162
+ `locate` / `locateMany` (and their sync variants) accept an options object:
163
+
164
+ | Option | Type | Default | Notes |
165
+ |-------------------|-------------------------|-----------------|--------------------------------------------------------|
166
+ | `cwd` | `string \| string[]` | `process.cwd()` | Working directory (or directories) the pattern is resolved against |
167
+ | `ignore` | `string \| string[]` | `[]` | Patterns to exclude |
168
+ | `onlyFiles` | `boolean` | `true` | Match files only |
169
+ | `onlyDirectories` | `boolean` | `false` | Match directories only |
170
+ | `dot` | `boolean` | `false` | Match dotfiles (e.g. `.env`, `.npmrc`) for wildcard patterns |
97
171
 
98
- The `load` method can be used to load a file/module in an asynchronous fashion.
172
+ If both flags are set, `onlyDirectories: true` takes precedence over `onlyFiles`; when neither restricts the match (`onlyFiles: false`), files **and** directories are returned.
173
+
174
+ `locateUp` / `locateUpSync` take the same options except `cwd` must be a single `string`, and additionally accept `stopAt?: string` (inclusive ceiling for the walk).
175
+
176
+ ### Reader
177
+
178
+ The `read` method loads a file/module in an asynchronous fashion.
99
179
  Either a string or the output of the locate/locateSync method can be passed as argument.
100
180
 
101
181
  ```typescript
102
- import { load, locate } from 'locter';
182
+ import { read, locate } from 'locter';
103
183
 
104
184
  (async () => {
105
185
  const file = await locate(
106
186
  'files/example.{js,.ts}'
107
187
  );
108
188
 
109
- let content = await load(file);
189
+ let content = await read(file);
110
190
  console.log(content);
111
191
  // ...
112
192
 
113
- content = await load('...');
193
+ content = await read('...');
114
194
  console.log(content);
115
195
  // ...
116
196
  })
117
197
  ```
118
198
 
119
- There is also a synchronous method called `loadSync` to load files.
199
+ There is also a synchronous method called `readSync`.
120
200
 
121
201
  ```typescript
122
- import { loadSync, locateSync } from 'locter';
202
+ import { readSync, locateSync } from 'locter';
203
+
204
+ const file = locateSync(
205
+ 'files/example.{js,.ts}'
206
+ );
207
+
208
+ const content = readSync(file);
209
+ ```
210
+
211
+ Bare module specifiers (no file extension) are routed to the module reader, so
212
+ `read('yaml')` behaves like `import('yaml')` โ€” note that reading a module
213
+ **executes** it.
214
+
215
+ **Module records**
216
+
217
+ Every `read` / `readSync` result is a **module record** โ€” a frozen,
218
+ null-prototype object shaped like an ES module namespace: `default` always
219
+ holds the loaded value, and when that value is an object, its top-level keys
220
+ are re-exposed as named exports.
221
+
222
+ ```typescript
223
+ import { read } from 'locter';
123
224
 
124
225
  (async () => {
125
- const file = await locateSync(
126
- 'files/example.{js,.ts}'
127
- );
226
+ const pkg = await read('package.json');
128
227
 
129
- let content = await loadSync(file);
130
- console.log(content);
131
- // ...
228
+ console.log(pkg.default); // the full parsed JSON value
229
+ console.log(pkg.version); // top-level keys are named exports
230
+ })
231
+ ```
132
232
 
133
- content = await loadSync('...');
134
- console.log(content);
135
- // ...
233
+ For JavaScript/TypeScript files the module's own namespace passes through
234
+ unchanged; the output of every other reader (built-in JSON/YAML/Conf and
235
+ user-registered ones) is always wrapped โ€” even if the parsed data happens to
236
+ contain an `__esModule` key. The record is read-only: to mutate the loaded
237
+ data, work with `.default`.
238
+
239
+ Records additionally carry a private, non-forgeable brand: `isModuleRecord`
240
+ tells records produced by `read()` apart from arbitrary data, and
241
+ [`write`](#writer) uses the brand to unwrap `.default` automatically on
242
+ write-back.
243
+
244
+ To pick an export by predicate, the `getModuleExport` helper is available:
245
+
246
+ ```typescript
247
+ import { getModuleExport, read } from 'locter';
248
+
249
+ (async () => {
250
+ const record = await read('files/example.ts');
251
+
252
+ const found = getModuleExport(record, (key) => key === 'myExport');
253
+ // โ†’ { key: 'myExport', value: ... }, or undefined if no key matches
254
+ })
255
+ ```
256
+
257
+ **Reading a `package.json` field**
258
+
259
+ `readPackageField` reads a top-level field from the nearest `package.json` โ€” handy for the common `pkg.<your-config-key>` config-loading fallback (vite's `pkg.vite`, eslint's `pkg.eslintConfig`, etc.):
260
+
261
+ ```typescript
262
+ import { readPackageField } from 'locter';
263
+
264
+ (async () => {
265
+ const cwd = process.cwd();
266
+
267
+ const value = await readPackageField<{ entry: string }>('myapp', { cwd });
268
+ // โ†’ the value of the `myapp` field, or undefined if package.json
269
+ // or the field is absent.
270
+
271
+ // Walk parent directories until a package.json is found:
272
+ const parentValue = await readPackageField('myapp', {
273
+ cwd,
274
+ walkUp: true,
275
+ stopAt: '/repo', // optional ceiling, inclusive
276
+ });
136
277
  })
137
278
  ```
138
279
 
139
- Two loaders are predefined from scratch and already registered:
140
- - **ConfLoader**: This loader allows to load `.conf` files.
141
- - **JSONLoader**: This loader allows to load `.json` files.
142
- - **YAMLLoader**: This loader allows to load `.yml` files.
143
- - **ModuleLoader**: This loader allows to load modules with
144
- `.js`, `.mjs`, `.mts`, `.cjs`, `.cts`, `.ts` file extensions independent of the environment (cjs or esm).
280
+ Throws `LocterLoadError` if the resolved `package.json` is malformed. A sync variant `readPackageFieldSync` is also available.
145
281
 
146
- To register loader for other file types, the function `registerLoader` can be used.
282
+ ### Writer
283
+
284
+ The `write` method serializes a value to a file, dispatched by extension like
285
+ `read` โ€” no need to pull `yaml` or a `.conf` serializer into the consuming
286
+ application:
147
287
 
148
288
  ```typescript
149
- import { registerLoader } from 'locter';
289
+ import { read, write } from 'locter';
290
+
291
+ (async () => {
292
+ const config = await read('config.yml');
293
+ config.default.port = 8080;
294
+
295
+ // records produced by read() are unwrapped automatically โ€”
296
+ // the file receives the plain value, not the record wrapper
297
+ await write('config.yml', config);
298
+
299
+ // plain values work just as well
300
+ await write('generated/settings.json', { port: 8080 });
301
+ })
302
+ ```
303
+
304
+ A synchronous variant is also available: `writeSync`.
305
+
306
+ Write semantics:
307
+
308
+ - **Missing parent directories are created** (`mkdir -p`).
309
+ - Output always ends with a single trailing newline.
310
+ - Values that are records produced by `read()` (detected via the private
311
+ brand โ€” an `__esModule` key in plain data does **not** count) are unwrapped
312
+ to their `.default` value before serialization.
313
+ - Module formats (`.js`, `.ts`, โ€ฆ) are **read-only**: `write('config.ts', โ€ฆ)`
314
+ throws `LocterWriteError`, as does writing to a bare module specifier.
315
+
316
+ Per-format behavior:
317
+
318
+ - **JSON** โ€” 4-space indent by default; configurable per instance:
319
+ `new JSONWriter({ indent })` accepts a number of spaces, a literal indent
320
+ string, or `'auto'` (detect and keep the indentation of the existing file).
321
+ - **YAML** โ€” *comment-preserving* write-back: when the target file exists, the
322
+ value is grafted into the parsed document, so comments and anchors attached
323
+ to surviving keys survive. Arrays and type-changed nodes are replaced
324
+ wholesale. A corrupt existing file throws instead of being overwritten.
325
+
326
+ ```yaml
327
+ # port used by the dev server
328
+ port: 3000
329
+ ```
330
+
331
+ ```typescript
332
+ await write('config.yml', { port: 8080 });
333
+ ```
334
+
335
+ ```yaml
336
+ # port used by the dev server
337
+ port: 8080
338
+ ```
339
+
340
+ - **Conf** โ€” the inverse of the reader: nested objects become dot-separated
341
+ `key=value` lines, arrays repeated `key[]=` lines. Round-trips are
342
+ structural, not textual: comments and line order are dropped, and a string
343
+ that parses as another type (`'123'`) reads back as that type (`123`).
344
+
345
+ **Writing a `package.json` field**
150
346
 
151
- registerLoader(['.ext'], {
152
- execute(input: string) {
347
+ `writePackageField` is the write-side companion of `readPackageField` (same
348
+ `cwd` / `walkUp` / `stopAt` semantics): it sets a top-level field of the
349
+ nearest `package.json` and writes it back **preserving the file's existing
350
+ indentation**.
153
351
 
352
+ ```typescript
353
+ import { writePackageField } from 'locter';
354
+
355
+ (async () => {
356
+ await writePackageField('myapp', { entry: 'src' }, { cwd: process.cwd() });
357
+
358
+ // passing undefined removes the field
359
+ await writePackageField('myapp', undefined);
360
+ })
361
+ ```
362
+
363
+ Throws `LocterNotFoundError` when no `package.json` could be located. A sync
364
+ variant `writePackageFieldSync` is also available.
365
+
366
+ ### Formats
367
+
368
+ A format couples an extension set with a reader and an optional writer. The
369
+ built-in formats are pre-registered:
370
+
371
+ - **conf** (`.conf`): `ConfReader` + `ConfWriter`
372
+ - **json** (`.json`): `JSONReader` + `JSONWriter`
373
+ - **yaml** (`.yml`, `.yaml`): `YAMLReader` + `YAMLWriter`
374
+ - **module** (`.js`, `.mjs`, `.mts`, `.cjs`, `.cts`, `.ts`): `ModuleReader`
375
+ (read-only) โ€” loads modules independent of the environment (cjs or esm).
376
+
377
+ To register a format for other file types, use `registerFormat`. Rules are
378
+ matched in registration order, **before** the built-ins โ€” registering an
379
+ extension a built-in claims (e.g. `.json`) overrides it. The reader and writer
380
+ slots are independent: a reader-only rule overrides how `.json` is read
381
+ without shadowing the built-in JSON writer (and vice versa).
382
+
383
+ ```typescript
384
+ import { registerFormat } from 'locter';
385
+
386
+ registerFormat({
387
+ test: ['.ext'],
388
+ reader: {
389
+ async read(input: string) {
390
+ // ...
391
+ },
392
+ readSync(input: string) {
393
+ // ...
394
+ },
154
395
  },
155
- executeSync(input: string) {
396
+ writer: {
397
+ async write(path: string, value: unknown) {
398
+ // ...
399
+ },
400
+ writeSync(path: string, value: unknown) {
401
+ // ...
402
+ },
403
+ },
404
+ });
405
+ ```
406
+
407
+ For text-based formats, extending the abstract `TextFileReader` /
408
+ `TextFileWriter` base classes is simpler than implementing the `IReader` /
409
+ `IWriter` ports by hand: the bases own UTF-8 I/O, typed error wrapping, and
410
+ derive the sync and async surface from a single implementation โ€” only
411
+ `parse(content)` respectively `stringify(value)` must be provided. The
412
+ built-in JSON/YAML/Conf formats are implemented this way.
413
+
414
+ ```typescript
415
+ import { TextFileReader, TextFileWriter, registerFormat } from 'locter';
416
+ import { parse, stringify } from 'smol-toml';
156
417
 
418
+ class TomlReader extends TextFileReader {
419
+ parse(content: string) {
420
+ return parse(content);
157
421
  }
158
- })
422
+ }
423
+
424
+ class TomlWriter extends TextFileWriter {
425
+ stringify(value: unknown) {
426
+ return stringify(value);
427
+ }
428
+ }
429
+
430
+ registerFormat({
431
+ test: ['.toml'],
432
+ reader: () => new TomlReader(),
433
+ writer: () => new TomlWriter(),
434
+ });
159
435
  ```
160
436
 
437
+ Instead of instances, the slots accept factory functions (as above) โ€” a
438
+ factory is invoked lazily on the first matching input and the first
439
+ successfully constructed instance is cached (a factory that throws is retried
440
+ on the next match).
441
+
442
+ Every rule has a stable id (auto-generated, or set explicitly). Registering an
443
+ existing id replaces that rule in place; built-in ids (`module`, `conf`,
444
+ `json`, `yaml`) are reserved. The registry can be inspected and unwound:
445
+
446
+ ```typescript
447
+ import { registerFormat, unregisterFormat, useFormatRegistry } from 'locter';
448
+
449
+ const registration = registerFormat({ test: ['.json5'], reader: json5Reader });
450
+ unregisterFormat(registration.id); // remove it again
451
+
452
+ registerFormat({ id: 'json5', test: ['.json5'], reader: otherReader }); // replace by id
453
+
454
+ const registry = useFormatRegistry(); // the process-global FormatRegistry
455
+ registry.entries(); // [{ id, test, builtIn }, ...] in match order
456
+ registry.has('json'); // true (built-in)
457
+ registry.reset(); // drop all user rules + cached instances
458
+ ```
459
+
460
+ The global registry belongs to the **application**. Libraries that need custom
461
+ formats should create their own isolated instance instead of mutating the
462
+ singleton: `new FormatRegistry({ rules: [...] })`.
463
+
464
+ ### Errors
465
+
466
+ `read` / `write` (and every built-in format) throw a typed subclass of
467
+ `LocterError`, so callers can distinguish failure modes without substring
468
+ matching on `.message`:
469
+
470
+ | Class | When it's thrown |
471
+ |--------------------------------|---------------------------------------------------------|
472
+ | `LocterNotFoundError` | File or module does not exist (`ENOENT`, `MODULE_NOT_FOUND`, `ERR_MODULE_NOT_FOUND`) |
473
+ | `LocterLoadError` | A reader threw โ€” parse error, runtime/eval error, etc. |
474
+ | `LocterWriteError` | A write failed โ€” serialization error, filesystem error, read-only format, corrupt existing YAML target |
475
+ | `LocterUnknownExtensionError` | No rule matched the file's extension |
476
+ | `LocterError` | Base class; matches all of the above |
477
+
478
+ Each error exposes the offending `path` and preserves the underlying error
479
+ on `cause`:
480
+
481
+ ```typescript
482
+ import { read, LocterNotFoundError, LocterLoadError } from 'locter';
483
+
484
+ try {
485
+ await read('config.json');
486
+ } catch (err) {
487
+ if (err instanceof LocterNotFoundError) {
488
+ console.error(`Config file not found: ${err.path}`);
489
+ } else if (err instanceof LocterLoadError) {
490
+ console.error(`Failed to parse config: ${err.cause}`);
491
+ } else {
492
+ throw err;
493
+ }
494
+ }
495
+ ```
496
+
497
+ Note that on the write side `ENOENT` stays a `LocterWriteError` โ€” a missing
498
+ parent directory is a write failure, not a lookup miss (and `write` creates
499
+ missing parents anyway).
500
+
501
+ `LocterError` extends [`BaseError`](https://github.com/Tada5hi/ebec) from `@ebec/core`, so each instance also carries a `code` (auto-derived from the class name unless overridden โ€” e.g. `'ENOENT'` for `LocterNotFoundError`s thrown by the JSON reader) and a `toJSON()` for structured logging.
502
+
503
+ Each error class also publishes its `Symbol.for(...)` marker (`LOCTER_ERROR_MARKER`, `LOCTER_NOT_FOUND_ERROR_MARKER`, โ€ฆ), which makes `instanceof` work across realms โ€” useful when consumers end up with duplicate copies of locter in their dependency tree.
504
+
505
+ ### Runtime environments
506
+
507
+ Locter ships with runtime detection helpers:
508
+
509
+ - `isJestRuntimeEnvironment()` โ€” true when running under Jest. Built-in: the
510
+ `ModuleReader` falls back to `require()` under Jest to avoid a known
511
+ segmentation fault ([nodejs/node#35889](https://github.com/nodejs/node/issues/35889)).
512
+ - `isVitestRuntimeEnvironment()` โ€” true when running under Vitest (checks
513
+ `process.env.VITEST === 'true'`).
514
+ - `isTsNodeRuntimeEnvironment()` โ€” true when a `ts-node` register instance is
515
+ active. Built-in: the `ModuleReader`'s async path falls back to its sync
516
+ loading under ts-node.
517
+ - `isTsxRuntimeEnvironment()` โ€” true when the process was started through
518
+ [tsx](https://tsx.is) (`tsx script.ts`, `node --import tsx`, or a resolved
519
+ `.../node_modules/tsx/...` loader path). Useful for callers that need to
520
+ know whether TypeScript sources can be executed directly.
521
+
522
+ #### Using locter with Vitest
523
+
524
+ Locter's built-in `await import(id)` runs inside `node_modules/locter` and
525
+ therefore bypasses Vitest's `vite-node` module graph by default. For most use
526
+ cases this is fine, but it produces **duplicate module instances** when test
527
+ code statically imports a module and locter dynamically loads the same module
528
+ (or a transitive dependency of it). This breaks class identity for libraries
529
+ that rely on it (e.g. TypeORM entity metadata).
530
+
531
+ Use `setModuleReader` to inject an `import` call from user space so Vitest can
532
+ rewrite it:
533
+
534
+ ```typescript
535
+ // vitest setup file
536
+ import { setModuleReader } from 'locter';
537
+
538
+ setModuleReader({
539
+ load: (id) => import(id),
540
+ });
541
+ ```
542
+
543
+ `setModuleReader` returns a restore function that re-applies the previous
544
+ configuration โ€” useful for scoped overrides:
545
+
546
+ ```typescript
547
+ const restore = setModuleReader({ load: (id) => import(id) });
548
+ // ...
549
+ restore();
550
+ ```
551
+
552
+ Alternatively, the same effect can be achieved by inlining locter in
553
+ `vitest.config.ts`:
554
+
555
+ ```typescript
556
+ export default defineConfig({
557
+ test: {
558
+ server: {
559
+ deps: {
560
+ inline: [/locter/],
561
+ },
562
+ },
563
+ },
564
+ });
565
+ ```
566
+
567
+ ## Migration
568
+
569
+ ### Migrating from 3.x
570
+
571
+ The 4.0 release renames the loader subsystem around the **format** concept
572
+ (read + write), reworks registration and dispatch, and normalizes results.
573
+ The list below is exhaustive โ€” no other public APIs changed.
574
+
575
+ **Renames** (clean break, no deprecated aliases):
576
+
577
+ | 3.x | 4.x |
578
+ |--------------------------------------|--------------------------------------------|
579
+ | `load` / `loadSync` | `read` / `readSync` |
580
+ | `registerLoader` / `unregisterLoader`| `registerFormat` / `unregisterFormat` |
581
+ | `LoaderManager` | `FormatRegistry` |
582
+ | `useLoader` | `useFormatRegistry` |
583
+ | `Loader` type (`execute`/`executeSync`) | `IReader` (`read`/`readSync`) + `IWriter` (`write`/`writeSync`) |
584
+ | `JSONLoader` / `YAMLLoader` / `ConfLoader` | `JSONReader`+`JSONWriter` / `YAMLReader`+`YAMLWriter` / `ConfReader`+`ConfWriter` |
585
+ | `ModuleLoader` | `ModuleReader` |
586
+ | `setModuleLoader` | `setModuleReader` |
587
+ | `loadPackageField` / `loadPackageFieldSync` | `readPackageField` / `readPackageFieldSync` |
588
+
589
+ **Behavioral changes:**
590
+
591
+ ```diff
592
+ // 1. User rules now override built-ins. Registering an extension a
593
+ // built-in claims (e.g. `.json`) previously had NO effect โ€” the
594
+ // built-in rule always matched first. It now takes precedence.
595
+ registerFormat({ test: ['.json'], reader: myJson5Reader }); // 4.x: overrides the built-in
596
+
597
+ // 2. Plugin-string loaders are removed, and rules use the object form
598
+ // with independent reader/writer slots. Import the implementation
599
+ // yourself and register it (optionally lazily, via a factory).
600
+ - registerLoader({ test: ['.toml'], loader: 'toml' }); // resolved to @locter/toml, sync require()
601
+ + import { TomlReader, TomlWriter } from '@locter/toml';
602
+ + registerFormat({ test: ['.toml'], reader: () => new TomlReader(), writer: () => new TomlWriter() });
603
+
604
+ // 3. The registry no longer implements the loader interface: execute() /
605
+ // executeSync() become read() / readSync() (accepting LocatorInfo |
606
+ // string), and findLoader() / resolve() are replaced by
607
+ // findReader() / findWriter() / builtInReader() / builtInWriter().
608
+ - const manager = new LoaderManager();
609
+ - await manager.execute('file.json');
610
+ + const registry = new FormatRegistry();
611
+ + await registry.read('file.json'); // mirrors the global read() helper
612
+ + registry.findReader('file.json'); // IReader | undefined
613
+ + registry.builtInReader('module'); // statically typed as ModuleReader
614
+
615
+ // 4. Every read() / readSync() result is now a module record (see
616
+ // "Module records" above). Module files already resolved this way โ€”
617
+ // json/yaml/conf and custom readers used to return the bare parsed
618
+ // value.
619
+ const pkg = await read('package.json');
620
+ pkg.version; // top-level keys still work
621
+ - pkg.default; // undefined
622
+ + pkg.default; // the full parsed value
623
+ + // The record is frozen and null-prototype: code that mutates the
624
+ + // result, relies on Object.prototype, or enumerates/serializes it
625
+ + // (now includes `default`) must use pkg.default instead.
626
+
627
+ // 5. ModuleReader.read() / readSync() return the raw module value;
628
+ // normalization happens exactly once, at the registry boundary. Only
629
+ // relevant when invoking the reader class directly.
630
+ - await new ModuleLoader().execute('file.ts'); // normalized record
631
+ + await new ModuleReader().read('file.ts'); // raw module value
632
+ + await registry.read('file.ts'); // normalized record
633
+ ```
634
+
635
+ **Removed from the public surface** (internal plumbing that leaked through the old `export *` barrels; the package now exports a curated, snapshot-tested list):
636
+
637
+ - Locator internals: `buildLocatorPatterns`, `buildLocatorOptions`, `pathToLocatorInfo`, `buildFilePathWithoutExtension` (deleted โ€” it had no callers). `buildFilePath` and `isLocatorInfo` stay public.
638
+ - Record internals: `isESModule`, `toModuleRecord`, `createModuleRecord`. Use `read()` (which normalizes) and the public `getModuleExport` / `isModuleRecord`.
639
+ - Generic helpers: `isObject`, `isSafeObjectKey`, `hasOwnProperty`, `hasStringProperty`, `toArray`, `isFilePath`, `getFileNameExtension`, `removeFileNameExtension`, `isTypeScriptError`. The runtime detection helpers (`isJestRuntimeEnvironment`, `isVitestRuntimeEnvironment`, `isTsNodeRuntimeEnvironment`, `isTsxRuntimeEnvironment`) stay public.
640
+
641
+ **Locator option precedence** โ€” `{ onlyFiles: true, onlyDirectories: true }` used to resolve to *neither* restriction (everything matched); `onlyDirectories: true` now wins. `{ onlyFiles: false }` used to mean directories-only; it now means "no restriction" (files and directories).
642
+
643
+ **New in 4.0:**
644
+
645
+ - `write` / `writeSync` with per-format writers (`JSONWriter`, `YAMLWriter`,
646
+ `ConfWriter`), including comment-preserving YAML write-back โ€” see
647
+ [Writer](#writer).
648
+ - `writePackageField` / `writePackageFieldSync`.
649
+ - `LocterWriteError` + `wrapWriteError`.
650
+ - `isModuleRecord` โ€” brand-based detection of records produced by `read()`.
651
+ - The ports are exported as `IReader` / `IWriter`, alongside `Rule`,
652
+ `ReaderFactory`, `WriterFactory` โ€” so custom formats can be typed against
653
+ the package.
654
+ - The extensions handled by the module reader are exported as
655
+ `MODULE_FILE_EXTENSIONS`.
656
+ - `readPackageField` / `readPackageFieldSync` read `package.json` via the
657
+ built-in JSON reader directly: user-registered `.json` rules no longer
658
+ intercept the read, and synthetic record keys (`default`, `__esModule`) no
659
+ longer resolve as package fields.
660
+
661
+ ### Migrating from 2.x
662
+
663
+ The 3.0 release reshapes a few public surfaces. The list below is exhaustive โ€” no other public APIs changed. (Symbols are shown under their 3.x names; see [Migrating from 3.x](#migrating-from-3x) for the current ones.)
664
+
665
+ ```diff
666
+ - import { load, locate } from 'locter';
667
+ + import { load, locate, LocterNotFoundError, LocterLoadError } from 'locter';
668
+
669
+ // 1. LocatorOptions.path โ†’ cwd
670
+ - await locate('config.json', { path: process.cwd() });
671
+ + await locate('config.json', { cwd: process.cwd() });
672
+
673
+ // 2. LocatorInfo.path now holds the FULL file path (it was the
674
+ // containing directory in 2.x). buildFilePath(info) still works in
675
+ // both versions but is now a near-identity for LocatorInfo inputs.
676
+ - const filePath = buildFilePath(info); // 2.x: composed from .path + .name + .extension
677
+ + const filePath = info.path; // 3.x: just the path field
678
+ + const dir = info.directory; // 3.x: containing directory (the old `info.path`)
679
+
680
+ // 3. load() / loadSync() (and every built-in loader) now throw
681
+ // LocterError subclasses instead of generic Error / ebec BaseError.
682
+ try {
683
+ await load('config.json');
684
+ - } catch (err) {
685
+ - if (err.message.includes('ENOENT')) { /* โ€ฆ */ }
686
+ + } catch (err) {
687
+ + if (err instanceof LocterNotFoundError) { /* โ€ฆ */ }
688
+ + else if (err instanceof LocterLoadError) { /* parse error: err.cause */ }
689
+ + else throw err;
690
+ }
691
+ ```
692
+
693
+ Additionally:
694
+
695
+ - The `ebec` runtime dependency is replaced by `@ebec/core`. If you used to import `BaseError` from `ebec`, switch to `@ebec/core`.
696
+ - The package is now ESM-only (no `require('locter')`). This change shipped in the 2.xโ†’3.x toolchain modernization commit; mentioned here for completeness.
697
+
161
698
 
162
699
  ## License
163
700