locter 3.0.0 โ†’ 4.0.0-beta.1

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/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`); `readAsModule()` for the uniform module-record shape.
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
@@ -129,74 +169,109 @@ A synchronous variant `locateUpSync` is also available.
129
169
  | `onlyDirectories` | `boolean` | `false` | Match directories only |
130
170
  | `dot` | `boolean` | `false` | Match dotfiles (e.g. `.env`, `.npmrc`) for wildcard patterns |
131
171
 
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
+
132
174
  `locateUp` / `locateUpSync` take the same options except `cwd` must be a single `string`, and additionally accept `stopAt?: string` (inclusive ceiling for the walk).
133
175
 
134
- ### Loader
176
+ ### Reader
135
177
 
136
- The `load` method can be used to load a file/module in an asynchronous fashion.
137
- Either a string or the output of the locate/locateSync method can be passed as argument.
178
+ Two verbs, two shapes:
179
+
180
+ - **`read`** โ€” *raw*: the plain parsed value for data formats (JSON/YAML/Conf
181
+ and custom readers) โ€” mutable and round-trip-symmetric with `write`. For
182
+ modules it returns the normalized module record: a module *is* a record,
183
+ and the normalization only irons out the CJS/ESM interop divergence so
184
+ `read` and `readSync` agree on one shape.
185
+ - **`readAsModule`** โ€” everything as a *module record*: the uniform, frozen
186
+ record shape regardless of format โ€” for export inspection, interop, and
187
+ uniform handling of mixed-extension config.
188
+
189
+ Either a string or the output of the locate/locateSync method can be passed
190
+ as argument; both verbs have `*Sync` twins.
138
191
 
139
192
  ```typescript
140
- import { load, locate } from 'locter';
193
+ import { locate, read, readSync } from 'locter';
141
194
 
142
195
  (async () => {
143
- const file = await locate(
144
- 'files/example.{js,.ts}'
145
- );
196
+ const config = await read('config.yml');
197
+ // โ†’ the plain parsed value: { port: 3000, ... } โ€” mutable,
198
+ // round-trip-symmetric with write()
146
199
 
147
- let content = await load(file);
148
- console.log(content);
149
- // ...
200
+ const settings = await read('trapi.config.ts');
201
+ // โ†’ the module as a normalized record; unwrap explicitly:
202
+ // settings.default
150
203
 
151
- content = await load('...');
152
- console.log(content);
153
- // ...
204
+ const file = await locate('files/example.{js,.ts}');
205
+ const content = readSync(file); // sync twin
154
206
  })
155
207
  ```
156
208
 
157
- There is also a synchronous method called `loadSync` to load files.
209
+ Bare module specifiers (no file extension) are routed to the module reader, so
210
+ `read('yaml')` behaves like `import('yaml')` โ€” note that reading a module
211
+ **executes** it.
212
+
213
+ **Module records โ€” `readAsModule`**
214
+
215
+ Every `readAsModule` / `readAsModuleSync` result is a **module record** โ€” a
216
+ frozen, null-prototype object shaped like an ES module namespace: `default`
217
+ always holds the loaded value, and when that value is an object, its
218
+ top-level keys are re-exposed as named exports. For modules, `read` and
219
+ `readAsModule` agree; for data formats, `read` returns what
220
+ `readAsModule(...).default` holds.
158
221
 
159
222
  ```typescript
160
- import { loadSync, locateSync } from 'locter';
223
+ import { readAsModule } from 'locter';
161
224
 
162
225
  (async () => {
163
- const file = await locateSync(
164
- 'files/example.{js,.ts}'
165
- );
226
+ const pkg = await readAsModule('package.json');
166
227
 
167
- let content = await loadSync(file);
168
- console.log(content);
169
- // ...
170
-
171
- content = await loadSync('...');
172
- console.log(content);
173
- // ...
228
+ console.log(pkg.default); // the full parsed JSON value
229
+ console.log(pkg.version); // top-level keys are named exports
174
230
  })
175
231
  ```
176
232
 
177
- Two loaders are predefined from scratch and already registered:
178
- - **ConfLoader**: This loader allows to load `.conf` files.
179
- - **JSONLoader**: This loader allows to load `.json` files.
180
- - **YAMLLoader**: This loader allows to load `.yml` files.
181
- - **ModuleLoader**: This loader allows to load modules with
182
- `.js`, `.mjs`, `.mts`, `.cjs`, `.cts`, `.ts` file extensions independent of the environment (cjs or esm).
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 (`read` of such data likewise returns it
237
+ untouched โ€” a literal `__esModule` key never turns data into a module).
238
+ The record is read-only: to mutate the loaded data, work with `.default`
239
+ (for data records that is the same object `read` returns).
240
+
241
+ Records additionally carry a private, non-forgeable brand: `isModuleRecord`
242
+ tells records produced by `readAsModule()` apart from arbitrary data, and
243
+ [`write`](#writer) uses the brand to unwrap `.default` automatically on
244
+ write-back.
245
+
246
+ To pick an export by predicate, the `getModuleExport` helper is available:
247
+
248
+ ```typescript
249
+ import { getModuleExport, readAsModule } from 'locter';
250
+
251
+ (async () => {
252
+ const record = await readAsModule('files/example.ts');
253
+
254
+ const found = getModuleExport(record, (key) => key === 'myExport');
255
+ // โ†’ { key: 'myExport', value: ... }, or undefined if no key matches
256
+ })
257
+ ```
183
258
 
184
259
  **Reading a `package.json` field**
185
260
 
186
- `loadPackageField` 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.):
261
+ `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.):
187
262
 
188
263
  ```typescript
189
- import { loadPackageField } from 'locter';
264
+ import { readPackageField } from 'locter';
190
265
 
191
266
  (async () => {
192
267
  const cwd = process.cwd();
193
268
 
194
- const value = await loadPackageField<{ entry: string }>('myapp', { cwd });
269
+ const value = await readPackageField<{ entry: string }>('myapp', { cwd });
195
270
  // โ†’ the value of the `myapp` field, or undefined if package.json
196
271
  // or the field is absent.
197
272
 
198
273
  // Walk parent directories until a package.json is found:
199
- const parentValue = await loadPackageField('myapp', {
274
+ const parentValue = await readPackageField('myapp', {
200
275
  cwd,
201
276
  walkUp: true,
202
277
  stopAt: '/repo', // optional ceiling, inclusive
@@ -204,33 +279,205 @@ import { loadPackageField } from 'locter';
204
279
  })
205
280
  ```
206
281
 
207
- Throws `LocterLoadError` if the resolved `package.json` is malformed. A sync variant `loadPackageFieldSync` is also available.
282
+ Throws `LocterLoadError` if the resolved `package.json` is malformed. A sync variant `readPackageFieldSync` is also available.
208
283
 
209
- To register loader for other file types, the function `registerLoader` can be used.
284
+ ### Writer
285
+
286
+ The `write` method serializes a value to a file, dispatched by extension like
287
+ `read` โ€” no need to pull `yaml` or a `.conf` serializer into the consuming
288
+ application:
210
289
 
211
290
  ```typescript
212
- import { registerLoader } from 'locter';
291
+ import { read, readAsModule, write } from 'locter';
292
+
293
+ (async () => {
294
+ // read โ†’ modify โ†’ write, fully symmetric
295
+ const config = await read('config.yml');
296
+ config.port = 8080;
297
+ await write('config.yml', config);
298
+
299
+ // records produced by readAsModule() are unwrapped automatically โ€”
300
+ // the file receives the plain value, not the record wrapper
301
+ const record = await readAsModule('config.yml');
302
+ record.default.port = 8080;
303
+ await write('config.yml', record);
304
+
305
+ // plain values work just as well
306
+ await write('generated/settings.json', { port: 8080 });
307
+ })
308
+ ```
309
+
310
+ A synchronous variant is also available: `writeSync`.
311
+
312
+ Write semantics:
313
+
314
+ - **Missing parent directories are created** (`mkdir -p`).
315
+ - Output always ends with a single trailing newline.
316
+ - Values that are records produced by `readAsModule()` (detected via the private
317
+ brand โ€” an `__esModule` key in plain data does **not** count) are unwrapped
318
+ to their `.default` value before serialization.
319
+ - Module formats (`.js`, `.ts`, โ€ฆ) are **read-only**: `write('config.ts', โ€ฆ)`
320
+ throws `LocterWriteError`, as does writing to a bare module specifier.
321
+
322
+ Per-format behavior:
323
+
324
+ - **JSON** โ€” 4-space indent by default; configurable per instance:
325
+ `new JSONWriter({ indent })` accepts a number of spaces, a literal indent
326
+ string, or `'auto'` (detect and keep the indentation of the existing file).
327
+ - **YAML** โ€” *comment-preserving* write-back: when the target file exists, the
328
+ value is grafted into the parsed document, so comments and anchors attached
329
+ to surviving keys survive. Arrays and type-changed nodes are replaced
330
+ wholesale. A corrupt existing file throws instead of being overwritten.
331
+
332
+ ```yaml
333
+ # port used by the dev server
334
+ port: 3000
335
+ ```
336
+
337
+ ```typescript
338
+ await write('config.yml', { port: 8080 });
339
+ ```
340
+
341
+ ```yaml
342
+ # port used by the dev server
343
+ port: 8080
344
+ ```
345
+
346
+ - **Conf** โ€” the inverse of the reader: nested objects become dot-separated
347
+ `key=value` lines, arrays repeated `key[]=` lines. Round-trips are
348
+ structural, not textual: comments and line order are dropped, and a string
349
+ that parses as another type (`'123'`) reads back as that type (`123`).
350
+
351
+ **Writing a `package.json` field**
352
+
353
+ `writePackageField` is the write-side companion of `readPackageField` (same
354
+ `cwd` / `walkUp` / `stopAt` semantics): it sets a top-level field of the
355
+ nearest `package.json` and writes it back **preserving the file's existing
356
+ indentation**.
357
+
358
+ ```typescript
359
+ import { writePackageField } from 'locter';
360
+
361
+ (async () => {
362
+ await writePackageField('myapp', { entry: 'src' }, { cwd: process.cwd() });
363
+
364
+ // passing undefined removes the field
365
+ await writePackageField('myapp', undefined);
366
+ })
367
+ ```
368
+
369
+ Throws `LocterNotFoundError` when no `package.json` could be located. A sync
370
+ variant `writePackageFieldSync` is also available.
371
+
372
+ ### Formats
373
+
374
+ A format couples an extension set with a reader and an optional writer. The
375
+ built-in formats are pre-registered:
376
+
377
+ - **conf** (`.conf`): `ConfReader` + `ConfWriter`
378
+ - **json** (`.json`): `JSONReader` + `JSONWriter`
379
+ - **yaml** (`.yml`, `.yaml`): `YAMLReader` + `YAMLWriter`
380
+ - **module** (`.js`, `.mjs`, `.mts`, `.cjs`, `.cts`, `.ts`): `ModuleReader`
381
+ (read-only) โ€” loads modules independent of the environment (cjs or esm).
213
382
 
214
- registerLoader(['.ext'], {
215
- execute(input: string) {
383
+ To register a format for other file types, use `registerFormat`. Rules are
384
+ matched in registration order, **before** the built-ins โ€” registering an
385
+ extension a built-in claims (e.g. `.json`) overrides it. The reader and writer
386
+ slots are independent: a reader-only rule overrides how `.json` is read
387
+ without shadowing the built-in JSON writer (and vice versa).
216
388
 
389
+ ```typescript
390
+ import { registerFormat } from 'locter';
391
+
392
+ registerFormat({
393
+ test: ['.ext'],
394
+ reader: {
395
+ async read(input: string) {
396
+ // ...
397
+ },
398
+ readSync(input: string) {
399
+ // ...
400
+ },
217
401
  },
218
- executeSync(input: string) {
402
+ writer: {
403
+ async write(path: string, value: unknown) {
404
+ // ...
405
+ },
406
+ writeSync(path: string, value: unknown) {
407
+ // ...
408
+ },
409
+ },
410
+ });
411
+ ```
412
+
413
+ For text-based formats, extending the abstract `TextFileReader` /
414
+ `TextFileWriter` base classes is simpler than implementing the `IReader` /
415
+ `IWriter` ports by hand: the bases own UTF-8 I/O, typed error wrapping, and
416
+ derive the sync and async surface from a single implementation โ€” only
417
+ `parse(content)` respectively `stringify(value)` must be provided. The
418
+ built-in JSON/YAML/Conf formats are implemented this way.
219
419
 
420
+ ```typescript
421
+ import { TextFileReader, TextFileWriter, registerFormat } from 'locter';
422
+ import { parse, stringify } from 'smol-toml';
423
+
424
+ class TomlReader extends TextFileReader {
425
+ parse(content: string) {
426
+ return parse(content);
220
427
  }
221
- })
428
+ }
429
+
430
+ class TomlWriter extends TextFileWriter {
431
+ stringify(value: unknown) {
432
+ return stringify(value);
433
+ }
434
+ }
435
+
436
+ registerFormat({
437
+ test: ['.toml'],
438
+ reader: () => new TomlReader(),
439
+ writer: () => new TomlWriter(),
440
+ });
222
441
  ```
223
442
 
443
+ Instead of instances, the slots accept factory functions (as above) โ€” a
444
+ factory is invoked lazily on the first matching input and the first
445
+ successfully constructed instance is cached (a factory that throws is retried
446
+ on the next match).
447
+
448
+ Every rule has a stable id (auto-generated, or set explicitly). Registering an
449
+ existing id replaces that rule in place; built-in ids (`module`, `conf`,
450
+ `json`, `yaml`) are reserved. The registry can be inspected and unwound:
451
+
452
+ ```typescript
453
+ import { registerFormat, unregisterFormat, useFormatRegistry } from 'locter';
454
+
455
+ const registration = registerFormat({ test: ['.json5'], reader: json5Reader });
456
+ unregisterFormat(registration.id); // remove it again
457
+
458
+ registerFormat({ id: 'json5', test: ['.json5'], reader: otherReader }); // replace by id
459
+
460
+ const registry = useFormatRegistry(); // the process-global FormatRegistry
461
+ registry.entries(); // [{ id, test, builtIn }, ...] in match order
462
+ registry.has('json'); // true (built-in)
463
+ registry.reset(); // drop all user rules + cached instances
464
+ ```
465
+
466
+ The global registry belongs to the **application**. Libraries that need custom
467
+ formats should create their own isolated instance instead of mutating the
468
+ singleton: `new FormatRegistry({ rules: [...] })`.
469
+
224
470
  ### Errors
225
471
 
226
- `load` / `loadSync` (and every built-in loader) throw a typed subclass of
472
+ `read` / `write` (and every built-in format) throw a typed subclass of
227
473
  `LocterError`, so callers can distinguish failure modes without substring
228
474
  matching on `.message`:
229
475
 
230
476
  | Class | When it's thrown |
231
477
  |--------------------------------|---------------------------------------------------------|
232
478
  | `LocterNotFoundError` | File or module does not exist (`ENOENT`, `MODULE_NOT_FOUND`, `ERR_MODULE_NOT_FOUND`) |
233
- | `LocterLoadError` | A loader threw โ€” parse error, runtime/eval error, etc. |
479
+ | `LocterLoadError` | A reader threw โ€” parse error, runtime/eval error, etc. |
480
+ | `LocterWriteError` | A write failed โ€” serialization error, filesystem error, read-only format, corrupt existing YAML target |
234
481
  | `LocterUnknownExtensionError` | No rule matched the file's extension |
235
482
  | `LocterError` | Base class; matches all of the above |
236
483
 
@@ -238,10 +485,10 @@ Each error exposes the offending `path` and preserves the underlying error
238
485
  on `cause`:
239
486
 
240
487
  ```typescript
241
- import { load, LocterNotFoundError, LocterLoadError } from 'locter';
488
+ import { read, LocterNotFoundError, LocterLoadError } from 'locter';
242
489
 
243
490
  try {
244
- await load('config.json');
491
+ await read('config.json');
245
492
  } catch (err) {
246
493
  if (err instanceof LocterNotFoundError) {
247
494
  console.error(`Config file not found: ${err.path}`);
@@ -253,56 +500,30 @@ try {
253
500
  }
254
501
  ```
255
502
 
256
- `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 loader) and a `toJSON()` for structured logging.
257
-
258
- 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.
259
-
260
- ### Migrating from 2.x
261
-
262
- The 3.0 release reshapes a few public surfaces. The list below is exhaustive โ€” no other public APIs changed.
503
+ Note that on the write side `ENOENT` stays a `LocterWriteError` โ€” a missing
504
+ parent directory is a write failure, not a lookup miss (and `write` creates
505
+ missing parents anyway).
263
506
 
264
- ```diff
265
- - import { load, locate } from 'locter';
266
- + import { load, locate, LocterNotFoundError, LocterLoadError } from 'locter';
507
+ `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.
267
508
 
268
- // 1. LocatorOptions.path โ†’ cwd
269
- - await locate('config.json', { path: process.cwd() });
270
- + await locate('config.json', { cwd: process.cwd() });
271
-
272
- // 2. LocatorInfo.path now holds the FULL file path (it was the
273
- // containing directory in 2.x). buildFilePath(info) still works in
274
- // both versions but is now a near-identity for LocatorInfo inputs.
275
- - const filePath = buildFilePath(info); // 2.x: composed from .path + .name + .extension
276
- + const filePath = info.path; // 3.x: just the path field
277
- + const dir = info.directory; // 3.x: containing directory (the old `info.path`)
278
-
279
- // 3. load() / loadSync() (and every built-in loader) now throw
280
- // LocterError subclasses instead of generic Error / ebec BaseError.
281
- try {
282
- await load('config.json');
283
- - } catch (err) {
284
- - if (err.message.includes('ENOENT')) { /* โ€ฆ */ }
285
- + } catch (err) {
286
- + if (err instanceof LocterNotFoundError) { /* โ€ฆ */ }
287
- + else if (err instanceof LocterLoadError) { /* parse error: err.cause */ }
288
- + else throw err;
289
- }
290
- ```
291
-
292
- Additionally:
293
-
294
- - The `ebec` runtime dependency is replaced by `@ebec/core`. If you used to import `BaseError` from `ebec`, switch to `@ebec/core`.
295
- - 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.
509
+ 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.
296
510
 
297
511
  ### Runtime environments
298
512
 
299
- Locter ships with two runtime detection helpers:
513
+ Locter ships with runtime detection helpers:
300
514
 
301
515
  - `isJestRuntimeEnvironment()` โ€” true when running under Jest. Built-in: the
302
- `ModuleLoader` falls back to `require()` under Jest to avoid a known
516
+ `ModuleReader` falls back to `require()` under Jest to avoid a known
303
517
  segmentation fault ([nodejs/node#35889](https://github.com/nodejs/node/issues/35889)).
304
518
  - `isVitestRuntimeEnvironment()` โ€” true when running under Vitest (checks
305
519
  `process.env.VITEST === 'true'`).
520
+ - `isTsNodeRuntimeEnvironment()` โ€” true when a `ts-node` register instance is
521
+ active. Built-in: the `ModuleReader`'s async path falls back to its sync
522
+ loading under ts-node.
523
+ - `isTsxRuntimeEnvironment()` โ€” true when the process was started through
524
+ [tsx](https://tsx.is) (`tsx script.ts`, `node --import tsx`, or a resolved
525
+ `.../node_modules/tsx/...` loader path). Useful for callers that need to
526
+ know whether TypeScript sources can be executed directly.
306
527
 
307
528
  #### Using locter with Vitest
308
529
 
@@ -313,18 +534,27 @@ code statically imports a module and locter dynamically loads the same module
313
534
  (or a transitive dependency of it). This breaks class identity for libraries
314
535
  that rely on it (e.g. TypeORM entity metadata).
315
536
 
316
- Use `setModuleLoader` to inject an `import` call from user space so Vitest can
537
+ Use `setModuleReader` to inject an `import` call from user space so Vitest can
317
538
  rewrite it:
318
539
 
319
540
  ```typescript
320
541
  // vitest setup file
321
- import { setModuleLoader } from 'locter';
542
+ import { setModuleReader } from 'locter';
322
543
 
323
- setModuleLoader({
544
+ setModuleReader({
324
545
  load: (id) => import(id),
325
546
  });
326
547
  ```
327
548
 
549
+ `setModuleReader` returns a restore function that re-applies the previous
550
+ configuration โ€” useful for scoped overrides:
551
+
552
+ ```typescript
553
+ const restore = setModuleReader({ load: (id) => import(id) });
554
+ // ...
555
+ restore();
556
+ ```
557
+
328
558
  Alternatively, the same effect can be achieved by inlining locter in
329
559
  `vitest.config.ts`:
330
560
 
@@ -340,6 +570,140 @@ export default defineConfig({
340
570
  });
341
571
  ```
342
572
 
573
+ ## Migration
574
+
575
+ ### Migrating from 3.x
576
+
577
+ The 4.0 release renames the loader subsystem around the **format** concept
578
+ (read + write), reworks registration and dispatch, and normalizes results.
579
+ The list below is exhaustive โ€” no other public APIs changed.
580
+
581
+ **Renames** (clean break, no deprecated aliases):
582
+
583
+ | 3.x | 4.x |
584
+ |--------------------------------------|--------------------------------------------|
585
+ | `load` / `loadSync` | `read` / `readSync` (raw: plain data values / module records) or `readAsModule` / `readAsModuleSync` (everything as a module record) |
586
+ | `registerLoader` / `unregisterLoader`| `registerFormat` / `unregisterFormat` |
587
+ | `LoaderManager` | `FormatRegistry` |
588
+ | `useLoader` | `useFormatRegistry` |
589
+ | `Loader` type (`execute`/`executeSync`) | `IReader` (`read`/`readSync`) + `IWriter` (`write`/`writeSync`) |
590
+ | `JSONLoader` / `YAMLLoader` / `ConfLoader` | `JSONReader`+`JSONWriter` / `YAMLReader`+`YAMLWriter` / `ConfReader`+`ConfWriter` |
591
+ | `ModuleLoader` | `ModuleReader` |
592
+ | `setModuleLoader` | `setModuleReader` |
593
+ | `loadPackageField` / `loadPackageFieldSync` | `readPackageField` / `readPackageFieldSync` |
594
+
595
+ **Behavioral changes:**
596
+
597
+ ```diff
598
+ // 1. User rules now override built-ins. Registering an extension a
599
+ // built-in claims (e.g. `.json`) previously had NO effect โ€” the
600
+ // built-in rule always matched first. It now takes precedence.
601
+ registerFormat({ test: ['.json'], reader: myJson5Reader }); // 4.x: overrides the built-in
602
+
603
+ // 2. Plugin-string loaders are removed, and rules use the object form
604
+ // with independent reader/writer slots. Import the implementation
605
+ // yourself and register it (optionally lazily, via a factory).
606
+ - registerLoader({ test: ['.toml'], loader: 'toml' }); // resolved to @locter/toml, sync require()
607
+ + import { TomlReader, TomlWriter } from '@locter/toml';
608
+ + registerFormat({ test: ['.toml'], reader: () => new TomlReader(), writer: () => new TomlWriter() });
609
+
610
+ // 3. The registry no longer implements the loader interface: execute() /
611
+ // executeSync() become read() / readSync() (accepting LocatorInfo |
612
+ // string), and findLoader() / resolve() are replaced by
613
+ // findReader() / findWriter() / builtInReader() / builtInWriter().
614
+ - const manager = new LoaderManager();
615
+ - await manager.execute('file.json');
616
+ + const registry = new FormatRegistry();
617
+ + await registry.read('file.json'); // mirrors the global read() helper
618
+ + registry.findReader('file.json'); // IReader | undefined
619
+ + registry.builtInReader('module'); // statically typed as ModuleReader
620
+
621
+ // 4. Two verbs replace 3.x load(): read() is RAW (the plain parsed
622
+ // value for data โ€” 3.x data loads map to read() nearly 1:1 โ€” and
623
+ // the normalized record for modules), while readAsModule() returns
624
+ // a frozen, null-prototype module record for EVERY format (see
625
+ // "Module records" above).
626
+ const value = await read('package.json');
627
+ value.version; // the plain parsed JSON
628
+
629
+ const pkg = await readAsModule('package.json');
630
+ pkg.version; // top-level keys as named exports
631
+ + pkg.default; // the full parsed value
632
+ + // The record is frozen and null-prototype: code that mutates the
633
+ + // result, relies on Object.prototype, or enumerates/serializes it
634
+ + // must use pkg.default (or read()) instead.
635
+
636
+ // 5. ModuleReader.read() / readSync() return the raw module value;
637
+ // normalization happens exactly once, at the registry boundary. Only
638
+ // relevant when invoking the reader class directly.
639
+ - await new ModuleLoader().execute('file.ts'); // normalized record
640
+ + await new ModuleReader().read('file.ts'); // raw module value
641
+ + await registry.read('file.ts'); // normalized record
642
+ ```
643
+
644
+ **Removed from the public surface** (internal plumbing that leaked through the old `export *` barrels; the package now exports a curated, snapshot-tested list):
645
+
646
+ - Locator internals: `buildLocatorPatterns`, `buildLocatorOptions`, `pathToLocatorInfo`, `buildFilePathWithoutExtension` (deleted โ€” it had no callers). `buildFilePath` and `isLocatorInfo` stay public.
647
+ - Record internals: `isESModule`, `toModuleRecord`, `createModuleRecord`. Use `readAsModule()` (which normalizes) / `read()` (raw) and the public `getModuleExport` / `isModuleRecord`.
648
+ - Generic helpers: `isObject`, `isSafeObjectKey`, `hasOwnProperty`, `hasStringProperty`, `toArray`, `isFilePath`, `getFileNameExtension`, `removeFileNameExtension`, `isTypeScriptError`. The runtime detection helpers (`isJestRuntimeEnvironment`, `isVitestRuntimeEnvironment`, `isTsNodeRuntimeEnvironment`, `isTsxRuntimeEnvironment`) stay public.
649
+
650
+ **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).
651
+
652
+ **New in 4.0:**
653
+
654
+ - `write` / `writeSync` with per-format writers (`JSONWriter`, `YAMLWriter`,
655
+ `ConfWriter`), including comment-preserving YAML write-back โ€” see
656
+ [Writer](#writer).
657
+ - `writePackageField` / `writePackageFieldSync`.
658
+ - `LocterWriteError` + `wrapWriteError`.
659
+ - `isModuleRecord` โ€” brand-based detection of records produced by `read()`.
660
+ - The ports are exported as `IReader` / `IWriter`, alongside `Rule`,
661
+ `ReaderFactory`, `WriterFactory` โ€” so custom formats can be typed against
662
+ the package.
663
+ - The extensions handled by the module reader are exported as
664
+ `MODULE_FILE_EXTENSIONS`.
665
+ - `readPackageField` / `readPackageFieldSync` read `package.json` via the
666
+ built-in JSON reader directly: user-registered `.json` rules no longer
667
+ intercept the read, and synthetic record keys (`default`, `__esModule`) no
668
+ longer resolve as package fields.
669
+
670
+ ### Migrating from 2.x
671
+
672
+ 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.)
673
+
674
+ ```diff
675
+ - import { load, locate } from 'locter';
676
+ + import { load, locate, LocterNotFoundError, LocterLoadError } from 'locter';
677
+
678
+ // 1. LocatorOptions.path โ†’ cwd
679
+ - await locate('config.json', { path: process.cwd() });
680
+ + await locate('config.json', { cwd: process.cwd() });
681
+
682
+ // 2. LocatorInfo.path now holds the FULL file path (it was the
683
+ // containing directory in 2.x). buildFilePath(info) still works in
684
+ // both versions but is now a near-identity for LocatorInfo inputs.
685
+ - const filePath = buildFilePath(info); // 2.x: composed from .path + .name + .extension
686
+ + const filePath = info.path; // 3.x: just the path field
687
+ + const dir = info.directory; // 3.x: containing directory (the old `info.path`)
688
+
689
+ // 3. load() / loadSync() (and every built-in loader) now throw
690
+ // LocterError subclasses instead of generic Error / ebec BaseError.
691
+ try {
692
+ await load('config.json');
693
+ - } catch (err) {
694
+ - if (err.message.includes('ENOENT')) { /* โ€ฆ */ }
695
+ + } catch (err) {
696
+ + if (err instanceof LocterNotFoundError) { /* โ€ฆ */ }
697
+ + else if (err instanceof LocterLoadError) { /* parse error: err.cause */ }
698
+ + else throw err;
699
+ }
700
+ ```
701
+
702
+ Additionally:
703
+
704
+ - The `ebec` runtime dependency is replaced by `@ebec/core`. If you used to import `BaseError` from `ebec`, switch to `@ebec/core`.
705
+ - 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.
706
+
343
707
 
344
708
  ## License
345
709