locter 3.0.0 โ†’ 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.
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
@@ -129,74 +169,107 @@ 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.
178
+ The `read` method loads a file/module in an asynchronous fashion.
137
179
  Either a string or the output of the locate/locateSync method can be passed as argument.
138
180
 
139
181
  ```typescript
140
- import { load, locate } from 'locter';
182
+ import { read, locate } from 'locter';
141
183
 
142
184
  (async () => {
143
185
  const file = await locate(
144
186
  'files/example.{js,.ts}'
145
187
  );
146
188
 
147
- let content = await load(file);
189
+ let content = await read(file);
148
190
  console.log(content);
149
191
  // ...
150
192
 
151
- content = await load('...');
193
+ content = await read('...');
152
194
  console.log(content);
153
195
  // ...
154
196
  })
155
197
  ```
156
198
 
157
- There is also a synchronous method called `loadSync` to load files.
199
+ There is also a synchronous method called `readSync`.
158
200
 
159
201
  ```typescript
160
- import { loadSync, locateSync } from 'locter';
202
+ import { readSync, locateSync } from 'locter';
161
203
 
162
- (async () => {
163
- const file = await locateSync(
164
- 'files/example.{js,.ts}'
165
- );
204
+ const file = locateSync(
205
+ 'files/example.{js,.ts}'
206
+ );
166
207
 
167
- let content = await loadSync(file);
168
- console.log(content);
169
- // ...
208
+ const content = readSync(file);
209
+ ```
170
210
 
171
- content = await loadSync('...');
172
- console.log(content);
173
- // ...
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';
224
+
225
+ (async () => {
226
+ const pkg = await read('package.json');
227
+
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. 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
+ ```
183
256
 
184
257
  **Reading a `package.json` field**
185
258
 
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.):
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.):
187
260
 
188
261
  ```typescript
189
- import { loadPackageField } from 'locter';
262
+ import { readPackageField } from 'locter';
190
263
 
191
264
  (async () => {
192
265
  const cwd = process.cwd();
193
266
 
194
- const value = await loadPackageField<{ entry: string }>('myapp', { cwd });
267
+ const value = await readPackageField<{ entry: string }>('myapp', { cwd });
195
268
  // โ†’ the value of the `myapp` field, or undefined if package.json
196
269
  // or the field is absent.
197
270
 
198
271
  // Walk parent directories until a package.json is found:
199
- const parentValue = await loadPackageField('myapp', {
272
+ const parentValue = await readPackageField('myapp', {
200
273
  cwd,
201
274
  walkUp: true,
202
275
  stopAt: '/repo', // optional ceiling, inclusive
@@ -204,33 +277,201 @@ import { loadPackageField } from 'locter';
204
277
  })
205
278
  ```
206
279
 
207
- Throws `LocterLoadError` if the resolved `package.json` is malformed. A sync variant `loadPackageFieldSync` is also available.
280
+ Throws `LocterLoadError` if the resolved `package.json` is malformed. A sync variant `readPackageFieldSync` is also available.
281
+
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:
287
+
288
+ ```typescript
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
+ ```
208
334
 
209
- To register loader for other file types, the function `registerLoader` can be used.
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**
346
+
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**.
210
351
 
211
352
  ```typescript
212
- import { registerLoader } from 'locter';
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:
213
370
 
214
- registerLoader(['.ext'], {
215
- execute(input: string) {
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).
216
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
+ },
217
395
  },
218
- 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.
219
413
 
414
+ ```typescript
415
+ import { TextFileReader, TextFileWriter, registerFormat } from 'locter';
416
+ import { parse, stringify } from 'smol-toml';
417
+
418
+ class TomlReader extends TextFileReader {
419
+ parse(content: string) {
420
+ return parse(content);
220
421
  }
221
- })
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
+ });
435
+ ```
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
222
458
  ```
223
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
+
224
464
  ### Errors
225
465
 
226
- `load` / `loadSync` (and every built-in loader) throw a typed subclass of
466
+ `read` / `write` (and every built-in format) throw a typed subclass of
227
467
  `LocterError`, so callers can distinguish failure modes without substring
228
468
  matching on `.message`:
229
469
 
230
470
  | Class | When it's thrown |
231
471
  |--------------------------------|---------------------------------------------------------|
232
472
  | `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. |
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 |
234
475
  | `LocterUnknownExtensionError` | No rule matched the file's extension |
235
476
  | `LocterError` | Base class; matches all of the above |
236
477
 
@@ -238,10 +479,10 @@ Each error exposes the offending `path` and preserves the underlying error
238
479
  on `cause`:
239
480
 
240
481
  ```typescript
241
- import { load, LocterNotFoundError, LocterLoadError } from 'locter';
482
+ import { read, LocterNotFoundError, LocterLoadError } from 'locter';
242
483
 
243
484
  try {
244
- await load('config.json');
485
+ await read('config.json');
245
486
  } catch (err) {
246
487
  if (err instanceof LocterNotFoundError) {
247
488
  console.error(`Config file not found: ${err.path}`);
@@ -253,56 +494,30 @@ try {
253
494
  }
254
495
  ```
255
496
 
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
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).
261
500
 
262
- The 3.0 release reshapes a few public surfaces. The list below is exhaustive โ€” no other public APIs changed.
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.
263
502
 
264
- ```diff
265
- - import { load, locate } from 'locter';
266
- + import { load, locate, LocterNotFoundError, LocterLoadError } from 'locter';
267
-
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.
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.
296
504
 
297
505
  ### Runtime environments
298
506
 
299
- Locter ships with two runtime detection helpers:
507
+ Locter ships with runtime detection helpers:
300
508
 
301
509
  - `isJestRuntimeEnvironment()` โ€” true when running under Jest. Built-in: the
302
- `ModuleLoader` falls back to `require()` under Jest to avoid a known
510
+ `ModuleReader` falls back to `require()` under Jest to avoid a known
303
511
  segmentation fault ([nodejs/node#35889](https://github.com/nodejs/node/issues/35889)).
304
512
  - `isVitestRuntimeEnvironment()` โ€” true when running under Vitest (checks
305
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.
306
521
 
307
522
  #### Using locter with Vitest
308
523
 
@@ -313,18 +528,27 @@ code statically imports a module and locter dynamically loads the same module
313
528
  (or a transitive dependency of it). This breaks class identity for libraries
314
529
  that rely on it (e.g. TypeORM entity metadata).
315
530
 
316
- Use `setModuleLoader` to inject an `import` call from user space so Vitest can
531
+ Use `setModuleReader` to inject an `import` call from user space so Vitest can
317
532
  rewrite it:
318
533
 
319
534
  ```typescript
320
535
  // vitest setup file
321
- import { setModuleLoader } from 'locter';
536
+ import { setModuleReader } from 'locter';
322
537
 
323
- setModuleLoader({
538
+ setModuleReader({
324
539
  load: (id) => import(id),
325
540
  });
326
541
  ```
327
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
+
328
552
  Alternatively, the same effect can be achieved by inlining locter in
329
553
  `vitest.config.ts`:
330
554
 
@@ -340,6 +564,137 @@ export default defineConfig({
340
564
  });
341
565
  ```
342
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
+
343
698
 
344
699
  ## License
345
700