@williamthorsen/toolbelt.filesystem 0.2.1 → 0.3.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/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.3.0 — 2026-08-08
6
+
7
+ ### Features
8
+
9
+ - Migrate replaceFileExtension into filesystem package (#74)
10
+
11
+ Adds `replaceFileExtension` to the filesystem utilities. The function replaces the file extension in a file path; unlike analogous built-in functions, it supports multi-part extensions such as `.d.ts`.
12
+
13
+ - Add directory-chain ascent and lookup exports (#102)
14
+
15
+ Adds three functions for upward directory search, which walk from a starting directory to either the filesystem root or a bounded ceiling, finding named files or directories at each level along the way:
16
+
17
+ - `listDirectoryChain` returns the directories alone
18
+ - `listDirectoryChainMatches` returns every level's match
19
+ - `findDirectoryChainMatch` finds only the nearest match
20
+
21
+ All three reject a path that falls outside the range they were asked to search. `findProjectRoot` now applies that same rule to its markers.
22
+
23
+ - Add createTempTree with scope-bound disposal (#106)
24
+
25
+ Adds `createTempTree` to `@williamthorsen/toolbelt.filesystem/proposed`. The new function allows a caller to describe a directory tree as a plain object mapping paths to contents and receive a handle in return; the tree is removed when that handle goes out of scope.
26
+
27
+ - 🚨 **Breaking:** Add findPackageRoot, getSelfVersion, and findProjectRoot to toolbelt.packaging (#107)
28
+
29
+ Adds a way for any module, whether it runs from a source tree or a compiled build, to identify the package that owns it and the version that package declares.
30
+
31
+ `findProjectRoot` moves from `@williamthorsen/toolbelt.filesystem` to `@williamthorsen/toolbelt.packaging`. Callers of `loadConfigCascade` must now state where its upward search stops, rather than relying on a project root the function found for them.
32
+
33
+ ### Tooling
34
+
35
+ - Migrate Vitest configs to the nmr projects model (#73)
36
+
37
+ Packages no longer need to declare their own Vitest config. Test suites are now selected by a test file's name suffix rather than by choosing a config file: `*.app.test.ts` and `*.int.test.ts` route to the app and integration suites, and everything else runs as a unit test. Local development is now declared to require Node 24.16 or later.
38
+
39
+ - Use identical compiler settings for all packages (#105)
40
+
41
+ All packages now have identical compiler settings, using the settings from the `@williamthorsen/tsconfig` base config without modification.
42
+
5
43
  ## 0.2.1 — 2026-07-27
6
44
 
7
45
  ### Tooling
package/README.md CHANGED
@@ -2,7 +2,35 @@
2
2
 
3
3
  Filesystem utilities for TypeScript and JavaScript.
4
4
 
5
- <!-- section:release-notes --><!-- /section:release-notes -->
5
+ <!-- section:release-notes -->
6
+ ## Release notes — v0.3.0 (2026-08-08)
7
+
8
+ ### Features
9
+
10
+ - Migrate replaceFileExtension into filesystem package (#74)
11
+
12
+ Adds `replaceFileExtension` to the filesystem utilities. The function replaces the file extension in a file path; unlike analogous built-in functions, it supports multi-part extensions such as `.d.ts`.
13
+
14
+ - Add directory-chain ascent and lookup exports (#102)
15
+
16
+ Adds three functions for upward directory search, which walk from a starting directory to either the filesystem root or a bounded ceiling, finding named files or directories at each level along the way:
17
+
18
+ - `listDirectoryChain` returns the directories alone
19
+ - `listDirectoryChainMatches` returns every level's match
20
+ - `findDirectoryChainMatch` finds only the nearest match
21
+
22
+ All three reject a path that falls outside the range they were asked to search. `findProjectRoot` now applies that same rule to its markers.
23
+
24
+ - Add createTempTree with scope-bound disposal (#106)
25
+
26
+ Adds `createTempTree` to `@williamthorsen/toolbelt.filesystem/proposed`. The new function allows a caller to describe a directory tree as a plain object mapping paths to contents and receive a handle in return; the tree is removed when that handle goes out of scope.
27
+
28
+ - 🚨 **Breaking:** Add findPackageRoot, getSelfVersion, and findProjectRoot to toolbelt.packaging (#107)
29
+
30
+ Adds a way for any module, whether it runs from a source tree or a compiled build, to identify the package that owns it and the version that package declares.
31
+
32
+ `findProjectRoot` moves from `@williamthorsen/toolbelt.filesystem` to `@williamthorsen/toolbelt.packaging`. Callers of `loadConfigCascade` must now state where its upward search stops, rather than relying on a project root the function found for them.
33
+ <!-- /section:release-notes -->
6
34
 
7
35
  ## Installation
8
36
 
@@ -12,64 +40,110 @@ pnpm add @williamthorsen/toolbelt.filesystem
12
40
 
13
41
  ## Runtime requirements
14
42
 
15
- Both functions reach the filesystem through `node:` builtins, so they run under Node.js 24 or later, Bun, and Deno. They do not run in browsers, nor in edge runtimes that expose no filesystem.
43
+ `createTempTree`, `findDirectoryChainMatch`, `listDirectoryChainMatches`, and `loadConfigCascade` reach the filesystem through `node:` builtins, so they run under Node.js 24 or later, Bun, and Deno. They do not run in browsers, nor in edge runtimes that expose no filesystem. `listDirectoryChain` and `replaceFileExtension` touch no filesystem, so an edge runtime that exposes none runs them; they still import `node:path`, which a browser bundle has to supply.
16
44
 
17
45
  `loadConfigCascade` imports each config through the host runtime, so a `.ts` config is subject to whatever that runtime does with TypeScript. Node strips types rather than compiling them, which admits erasable syntax alone: an `enum`, a `namespace`, or a parameter property in a config file fails to parse. A `.mjs` or `.js` config sidesteps the question.
18
46
 
19
- ## `findProjectRoot`
47
+ ## `listDirectoryChain`
20
48
 
21
49
  ```ts
22
- findProjectRoot(startDir: string, options?: { markers?: ReadonlyArray<string> }): ProjectRoot;
50
+ listDirectoryChain(startDir: string, options?: { stopAtDir?: string }): [string, ...string[]];
23
51
  ```
24
52
 
25
- Resolves `startDir` to an absolute path, ascends from it, and returns the first directory carrying a root marker, along with the evidence that identified it:
53
+ Resolves `startDir` to an absolute path and returns it followed by each of its ancestors, nearest first. It manipulates paths as strings and reads nothing from disk.
26
54
 
27
55
  ```ts
28
- interface ProjectRoot {
29
- marker: string | null; // the marker that matched, or null when a fallback answered
30
- rootDir: string;
31
- source: 'marker' | 'package-json' | 'start-dir';
32
- }
56
+ import { listDirectoryChain } from '@williamthorsen/toolbelt.filesystem';
57
+
58
+ listDirectoryChain('/home/dev/app/src');
59
+ // ['/home/dev/app/src', '/home/dev/app', '/home/dev', '/home', '/']
60
+
61
+ listDirectoryChain('/home/dev/app/src', { stopAtDir: '/home/dev' });
62
+ // ['/home/dev/app/src', '/home/dev/app', '/home/dev']
63
+ ```
64
+
65
+ `stopAtDir` bounds the ascent inclusively and is resolved the same way `startDir` is, so a relative ceiling behaves like a relative start. One that is neither the start directory nor an ancestor of it throws, naming both, rather than being ignored and letting the ascent run past the bound. The comparison is exact, so a `stopAtDir` differing from its target only in case is off the chain even on a volume that would open it.
66
+
67
+ The result type records that the chain is never empty, which is what spares the nearest directory an undefined check:
68
+
69
+ ```ts
70
+ const [nearestDir] = listDirectoryChain(process.cwd()); // string, not string | undefined
33
71
  ```
34
72
 
35
- `DEFAULT_ROOT_MARKERS` is consulted in order, so the earliest entry wins when one directory carries several:
73
+ The ascent terminates at the filesystem root on every platform, so a Windows drive root or UNC share is as safe a starting point as a POSIX path.
74
+
75
+ ## `listDirectoryChainMatches`
76
+
77
+ ```ts
78
+ listDirectoryChainMatches(
79
+ startDir: string,
80
+ names: ReadonlyArray<string>,
81
+ options?: { stopAtDir?: string },
82
+ ): DirectoryChainMatch[];
83
+ ```
36
84
 
37
- 1. `.git`, matching either a directory (an ordinary clone) or a file (a worktree or submodule);
38
- 2. `pnpm-workspace.yaml`;
39
- 3. `pnpm-lock.yaml`;
40
- 4. `package-lock.json`;
41
- 5. `yarn.lock`;
42
- 6. `bun.lock`.
85
+ Returns, for each directory in the chain at or above `startDir`, the first of `names` that exists there:
43
86
 
44
- Passing `markers` replaces that list rather than extending it. Spread `DEFAULT_ROOT_MARKERS` to add to it:
87
+ ```ts
88
+ interface DirectoryChainMatch {
89
+ dir: string; // the chain level, which differs from the entry's own directory for a nested name
90
+ entryName: string;
91
+ entryPath: string;
92
+ }
93
+ ```
45
94
 
46
95
  ```ts
47
- import { DEFAULT_ROOT_MARKERS, findProjectRoot } from '@williamthorsen/toolbelt.filesystem';
96
+ import { listDirectoryChainMatches } from '@williamthorsen/toolbelt.filesystem';
48
97
 
49
- findProjectRoot(process.cwd(), { markers: [...DEFAULT_ROOT_MARKERS, 'deno.json'] });
98
+ listDirectoryChainMatches('/home/dev/app/src', ['.git'], { stopAtDir: '/home/dev' });
99
+ // [{ dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }]
50
100
  ```
51
101
 
52
- When no directory up to and including the filesystem root carries a marker, the result falls back in this order, reporting a `null` marker either way:
102
+ A level yields at most one match, the earliest of `names` found there, and a level holding none contributes nothing, so an empty result is an ordinary outcome rather than an error. A name matches a directory as readily as a file, which is what lets `.git` be probed without knowing whether the clone is ordinary or a worktree.
53
103
 
54
- 1. the nearest ancestor holding a `package.json`, reported as `source: 'package-json'`;
55
- 2. `startDir` itself, reported as `source: 'start-dir'`.
104
+ Each name is a path relative to the level it is probed against, so a nested location such as `.config/stack.config.mjs` works. A name that would leave its level (an absolute path, or one whose `..` segments escape it) is rejected before any level is probed, so the rejection never depends on what happens to exist on disk.
56
105
 
57
- The ascent terminates at the filesystem root on every platform, so a Windows drive root or UNC share is as safe a starting point as a POSIX path.
106
+ `options` is forwarded to `listDirectoryChain`, so `stopAtDir` bounds the ascent the same way.
107
+
108
+ Every level is probed, because every level's match is reported. Where only the nearest match matters, [`findDirectoryChainMatch`](#finddirectorychainmatch) returns it and stops there.
109
+
110
+ ## `findDirectoryChainMatch`
111
+
112
+ ```ts
113
+ findDirectoryChainMatch(
114
+ startDir: string,
115
+ names: ReadonlyArray<string>,
116
+ options?: { stopAtDir?: string },
117
+ ): DirectoryChainMatch | undefined;
118
+ ```
119
+
120
+ Returns the nearest directory at or above `startDir` holding one of `names`, or `undefined` when none does. It is `listDirectoryChainMatches` narrowed to the first hit, sharing its result shape, its options, and its name validation:
121
+
122
+ ```ts
123
+ import { findDirectoryChainMatch } from '@williamthorsen/toolbelt.filesystem';
124
+
125
+ findDirectoryChainMatch('/home/dev/app/src', ['.git']);
126
+ // { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }
127
+ ```
128
+
129
+ Probing stops at the first level that matches, so no level beyond it is touched — the reason to reach for this rather than read element zero off `listDirectoryChainMatches`, which probes to the ceiling regardless. The nullable return type is the other reason: a result that may be absent says so, where an array leaves the caller to narrow.
58
130
 
59
131
  ## `loadConfigCascade`
60
132
 
61
133
  ```ts
62
134
  loadConfigCascade<TConfig>(options: {
63
135
  fileNames: ReadonlyArray<string>;
64
- markers?: ReadonlyArray<string>;
65
136
  shouldStopAscent?: (config: TConfig) => boolean;
66
137
  startDir: string;
138
+ stopAtDir: string;
67
139
  }): Promise<ConfigCascade<TConfig>>;
68
140
  ```
69
141
 
70
- Loads every config file between `startDir` and its project root, nearest first, and reads nothing above that root.
142
+ Loads every config file between `startDir` and `stopAtDir`, nearest first, and reads nothing above that boundary.
71
143
 
72
- At each level from `startDir` up to and including the root, the first of `fileNames` that exists becomes that level's config; a level holding none contributes nothing. Each name is a path relative to the level, so a nested location such as `.config/stack.config.mjs` works. A name that would leave its level (an absolute path, or one whose `..` segments escape it) is rejected before any file is read, since following it would breach the bound the cascade exists to enforce. The project root is resolved by `findProjectRoot`, and `markers` is forwarded to it.
144
+ Discovery is [`listDirectoryChainMatches`](#listdirectorychainmatches) bounded at `stopAtDir`: the first of `fileNames` that exists at a level becomes that level's config, a level holding none contributes nothing, and a name that would leave its level is rejected before any file is read. A `stopAtDir` that is neither the start directory nor one of its ancestors throws, on the same terms `listDirectoryChain` sets out.
145
+
146
+ The boundary is required, and it is the caller's to choose. That is what keeps this function free of any notion of what marks a project: it never asks whether a directory holds a lockfile or a workspace manifest. Where the boundary should be a project root, [`findProjectRoot`](https://github.com/williamthorsen/toolbelt/tree/main/packages/packaging#findprojectroot) in `@williamthorsen/toolbelt.packaging` resolves one from markers.
73
147
 
74
148
  The matched files are then imported one at a time, and `shouldStopAscent` is consulted after each. Once it returns true, the ascent halts and no farther file is imported at all, rather than being loaded and discarded:
75
149
 
@@ -80,8 +154,7 @@ interface ConfigCascade<TConfig> {
80
154
  dir: string; // the cascade level, which differs from the file's own directory for a nested file name
81
155
  filePath: string;
82
156
  }>;
83
- projectRoot: ProjectRoot;
84
- stopReason: 'predicate' | 'project-root';
157
+ stopReason: 'predicate' | 'stop-dir';
85
158
  }
86
159
  ```
87
160
 
@@ -93,17 +166,91 @@ The predicate is the caller's whole stop policy, so any field can drive it. By c
93
166
 
94
167
  ```ts
95
168
  import { loadConfigCascade } from '@williamthorsen/toolbelt.filesystem';
169
+ import { findProjectRoot } from '@williamthorsen/toolbelt.packaging';
96
170
 
97
171
  interface StackConfig {
98
172
  rules?: Record<string, string>;
99
173
  shouldStopAscent?: boolean;
100
174
  }
101
175
 
102
- const { entries, projectRoot, stopReason } = await loadConfigCascade<StackConfig>({
176
+ const { rootDir } = findProjectRoot(process.cwd());
177
+
178
+ const { entries, stopReason } = await loadConfigCascade<StackConfig>({
103
179
  fileNames: ['stack.config.mjs', 'stack.config.js'],
104
180
  shouldStopAscent: (config) => config.shouldStopAscent === true,
105
181
  startDir: process.cwd(),
182
+ stopAtDir: rootDir,
106
183
  });
107
184
  ```
108
185
 
109
- `projectRoot` and `stopReason` are provenance for the caller to surface, so a user can see which directory bounded the cascade and what ended it.
186
+ `stopReason` is provenance for the caller to surface, so a user can see whether the predicate ended the cascade or it simply reached the boundary. Which directory bounded it is the `stopAtDir` the caller passed in.
187
+
188
+ ## `createTempTree`
189
+
190
+ Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
191
+
192
+ ```ts
193
+ createTempTree(entries: Record<string, string>): TempTree;
194
+ ```
195
+
196
+ Builds a throwaway directory tree and returns a handle that removes it when the binding leaves scope:
197
+
198
+ ```ts
199
+ import { createTempTree } from '@williamthorsen/toolbelt.filesystem/proposed';
200
+
201
+ {
202
+ using tree = createTempTree({
203
+ '.git/': '',
204
+ 'packages/app/package.json': '{ "name": "app" }',
205
+ });
206
+
207
+ tree.dir; // '/private/var/folders/.../toolbelt-a1b2c3'
208
+ tree.resolve('packages/app'); // '/private/var/folders/.../toolbelt-a1b2c3/packages/app'
209
+ }
210
+ // The tree is gone here.
211
+ ```
212
+
213
+ Each key of `entries` is a path relative to the tree root. One ending in `/` becomes a directory; any other becomes a file holding the mapped contents, with its intermediate directories created for it. A key resolving outside the root is rejected, and a call that throws leaves nothing on disk.
214
+
215
+ ```ts
216
+ interface TempTree extends Disposable {
217
+ readonly dir: string;
218
+ resolve(...segments: string[]): string;
219
+ }
220
+ ```
221
+
222
+ `dir` is realpath-resolved, because `os.tmpdir()` is a symlink on macOS and a caller comparing paths against it would otherwise see a mismatch it did not cause.
223
+
224
+ `resolve` joins `segments` against the root and throws when the result would fall outside it, so a stray `..` fails loudly rather than reaching into the enclosing directory. An absolute segment landing inside the root is returned unchanged. The containment test is lexical, so it does not follow a symlink inside the tree that points out of it.
225
+
226
+ Disposal is idempotent.
227
+
228
+ `Disposable` is declared in `lib.esnext.disposable.d.ts` alone, so consuming this export requires `ESNext.Disposable` in your `lib`.
229
+
230
+ ## `replaceFileExtension`
231
+
232
+ Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
233
+
234
+ ```ts
235
+ replaceFileExtension(filePath: string, newExtension: string, options?: { oldExtension?: string }): string;
236
+ ```
237
+
238
+ Returns `filePath` with its extension replaced. It manipulates the string alone and touches no filesystem.
239
+
240
+ ```ts
241
+ import { replaceFileExtension } from '@williamthorsen/toolbelt.filesystem/proposed';
242
+
243
+ replaceFileExtension('src/main.ts', '.js'); // 'src/main.js'
244
+ replaceFileExtension('src/main.ts', 'js'); // 'src/main.js' -- the leading period is optional
245
+ replaceFileExtension('src/main.ts', ''); // 'src/main' -- an empty replacement removes the extension
246
+ ```
247
+
248
+ The extension being replaced defaults to whatever `path.extname` reports, which is the substring from the final period in the file name. That is wrong for a multi-part extension: `path.extname('src/main.d.ts')` returns `.ts`, so the default would yield `src/main.d.js`. Declare the whole extension through `oldExtension` to replace it entire:
249
+
250
+ ```ts
251
+ replaceFileExtension('src/main.d.ts', '.js', { oldExtension: '.d.ts' }); // 'src/main.js'
252
+ ```
253
+
254
+ Which extension is meant is genuinely ambiguous, since `archive.tar.gz` could reasonably end in `.gz` or in `.tar.gz`, so the caller declares it rather than the function guessing.
255
+
256
+ Two inputs throw rather than returning a path that would quietly be wrong: a `filePath` ending in a separator, which names a directory rather than a file, and a `filePath` that does not end with a declared `oldExtension`.
@@ -0,0 +1,5 @@
1
+ export declare function createTempTree(entries: Record<string, string>): TempTree;
2
+ export interface TempTree extends Disposable {
3
+ readonly dir: string;
4
+ resolve(...segments: string[]): string;
5
+ }
@@ -0,0 +1,38 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ export function createTempTree(entries) {
5
+ const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'toolbelt-')));
6
+ try {
7
+ for (const [entry, contents] of Object.entries(entries)) {
8
+ const entryPath = resolveWithinTree(dir, [entry]);
9
+ if (entry.endsWith('/')) {
10
+ fs.mkdirSync(entryPath, { recursive: true });
11
+ }
12
+ else {
13
+ fs.mkdirSync(path.dirname(entryPath), { recursive: true });
14
+ fs.writeFileSync(entryPath, contents);
15
+ }
16
+ }
17
+ }
18
+ catch (error) {
19
+ fs.rmSync(dir, { force: true, recursive: true });
20
+ throw error;
21
+ }
22
+ return {
23
+ dir,
24
+ resolve(...segments) {
25
+ return resolveWithinTree(dir, segments);
26
+ },
27
+ [Symbol.dispose]() {
28
+ fs.rmSync(dir, { force: true, recursive: true });
29
+ },
30
+ };
31
+ }
32
+ function resolveWithinTree(dir, segments) {
33
+ const target = path.resolve(dir, ...segments);
34
+ if (target !== dir && !target.startsWith(dir + path.sep)) {
35
+ throw new Error(`Path "${target}" falls outside the temporary tree at "${dir}"`);
36
+ }
37
+ return target;
38
+ }
@@ -1 +1,2 @@
1
- export {};
1
+ export { createTempTree, type TempTree } from './createTempTree.js';
2
+ export { replaceFileExtension, type ReplaceFileExtensionOptions } from './replaceFileExtension.js';
@@ -1 +1,2 @@
1
- export {};
1
+ export { createTempTree } from "./createTempTree.js";
2
+ export { replaceFileExtension } from "./replaceFileExtension.js";
@@ -0,0 +1,4 @@
1
+ export declare function replaceFileExtension(filePath: string, newExtension: string, options?: ReplaceFileExtensionOptions): string;
2
+ export interface ReplaceFileExtensionOptions {
3
+ oldExtension?: string | undefined;
4
+ }
@@ -0,0 +1,23 @@
1
+ import path from 'node:path';
2
+ export function replaceFileExtension(filePath, newExtension, options = {}) {
3
+ if (endsWithSeparator(filePath)) {
4
+ throw new Error(`File path "${filePath}" ends with a path separator, so it names a directory`);
5
+ }
6
+ const oldExtension = toDotPrefixed(options.oldExtension ?? path.extname(filePath));
7
+ const replacement = toDotPrefixed(newExtension);
8
+ if (oldExtension === '') {
9
+ return `${filePath}${replacement}`;
10
+ }
11
+ if (!filePath.endsWith(oldExtension)) {
12
+ throw new Error(`File path "${filePath}" does not end with extension "${oldExtension}"`);
13
+ }
14
+ return filePath.slice(0, -oldExtension.length) + replacement;
15
+ }
16
+ function endsWithSeparator(filePath) {
17
+ return filePath.endsWith('/') || filePath.endsWith(path.sep);
18
+ }
19
+ function toDotPrefixed(extension) {
20
+ if (extension === '' || extension.startsWith('.'))
21
+ return extension;
22
+ return `.${extension}`;
23
+ }
@@ -0,0 +1,9 @@
1
+ import { type ListDirectoryChainOptions } from './listDirectoryChain.js';
2
+ export interface DirectoryChainMatch {
3
+ dir: string;
4
+ entryName: string;
5
+ entryPath: string;
6
+ }
7
+ export type DirectoryChainMatchOptions = ListDirectoryChainOptions;
8
+ export declare function findDirectoryChainMatch(startDir: string, names: ReadonlyArray<string>, options?: DirectoryChainMatchOptions): DirectoryChainMatch | undefined;
9
+ export declare function listDirectoryChainMatches(startDir: string, names: ReadonlyArray<string>, options?: DirectoryChainMatchOptions): DirectoryChainMatch[];
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { listDirectoryChain } from "./listDirectoryChain.js";
4
+ export function findDirectoryChainMatch(startDir, names, options = {}) {
5
+ assertLevelRelativeNames(names);
6
+ for (const dir of listDirectoryChain(startDir, options)) {
7
+ const entryName = findMatchingName(dir, names);
8
+ if (entryName !== undefined) {
9
+ return { dir, entryName, entryPath: path.join(dir, entryName) };
10
+ }
11
+ }
12
+ return undefined;
13
+ }
14
+ export function listDirectoryChainMatches(startDir, names, options = {}) {
15
+ assertLevelRelativeNames(names);
16
+ const matches = [];
17
+ for (const dir of listDirectoryChain(startDir, options)) {
18
+ const entryName = findMatchingName(dir, names);
19
+ if (entryName !== undefined) {
20
+ matches.push({ dir, entryName, entryPath: path.join(dir, entryName) });
21
+ }
22
+ }
23
+ return matches;
24
+ }
25
+ function assertLevelRelativeNames(names) {
26
+ for (const name of names) {
27
+ if (path.isAbsolute(name)) {
28
+ throw new Error(`Entry name must be relative to its directory level: ${name}`);
29
+ }
30
+ const normalized = path.normalize(name);
31
+ if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
32
+ throw new Error(`Entry name must not ascend above its directory level: ${name}`);
33
+ }
34
+ }
35
+ }
36
+ function findMatchingName(dir, names) {
37
+ for (const name of names) {
38
+ if (fs.existsSync(path.join(dir, name))) {
39
+ return name;
40
+ }
41
+ }
42
+ return undefined;
43
+ }
@@ -1,2 +1,3 @@
1
- export { DEFAULT_ROOT_MARKERS, findProjectRoot, type FindProjectRootOptions, type ProjectRoot, type ProjectRootSource, } from './findProjectRoot.js';
1
+ export { type DirectoryChainMatch, type DirectoryChainMatchOptions, findDirectoryChainMatch, listDirectoryChainMatches, } from './directory-chain-matches.js';
2
+ export { listDirectoryChain, type ListDirectoryChainOptions } from './listDirectoryChain.js';
2
3
  export { type CascadeStopReason, type ConfigCascade, type ConfigEntry, loadConfigCascade, type LoadConfigCascadeOptions, } from './loadConfigCascade.js';
@@ -1,2 +1,3 @@
1
- export { DEFAULT_ROOT_MARKERS, findProjectRoot, } from "./findProjectRoot.js";
1
+ export { findDirectoryChainMatch, listDirectoryChainMatches, } from "./directory-chain-matches.js";
2
+ export { listDirectoryChain } from "./listDirectoryChain.js";
2
3
  export { loadConfigCascade, } from "./loadConfigCascade.js";
@@ -0,0 +1,4 @@
1
+ export declare function listDirectoryChain(startDir: string, options?: ListDirectoryChainOptions): [string, ...string[]];
2
+ export interface ListDirectoryChainOptions {
3
+ stopAtDir?: string | undefined;
4
+ }
@@ -0,0 +1,22 @@
1
+ import path from 'node:path';
2
+ export function listDirectoryChain(startDir, options = {}) {
3
+ const { stopAtDir } = options;
4
+ const resolvedStartDir = path.resolve(startDir);
5
+ const resolvedStopAtDir = stopAtDir === undefined ? undefined : path.resolve(stopAtDir);
6
+ const chain = [resolvedStartDir];
7
+ let dir = resolvedStartDir;
8
+ let hasReachedStopAtDir = dir === resolvedStopAtDir;
9
+ while (!hasReachedStopAtDir) {
10
+ const parentDir = path.dirname(dir);
11
+ if (parentDir === dir)
12
+ break;
13
+ chain.push(parentDir);
14
+ dir = parentDir;
15
+ hasReachedStopAtDir = dir === resolvedStopAtDir;
16
+ }
17
+ if (resolvedStopAtDir !== undefined && !hasReachedStopAtDir) {
18
+ throw new Error('Stop directory must be the start directory or one of its ancestors: ' +
19
+ `stopAtDir=${resolvedStopAtDir}, startDir=${resolvedStartDir}`);
20
+ }
21
+ return chain;
22
+ }
@@ -1,9 +1,7 @@
1
- import { type ProjectRoot } from './findProjectRoot.js';
2
1
  export declare function loadConfigCascade<TConfig = unknown>(options: LoadConfigCascadeOptions<TConfig>): Promise<ConfigCascade<TConfig>>;
3
- export type CascadeStopReason = 'predicate' | 'project-root';
2
+ export type CascadeStopReason = 'predicate' | 'stop-dir';
4
3
  export interface ConfigCascade<TConfig> {
5
4
  entries: ConfigEntry<TConfig>[];
6
- projectRoot: ProjectRoot;
7
5
  stopReason: CascadeStopReason;
8
6
  }
9
7
  export interface ConfigEntry<TConfig> {
@@ -13,7 +11,7 @@ export interface ConfigEntry<TConfig> {
13
11
  }
14
12
  export interface LoadConfigCascadeOptions<TConfig> {
15
13
  fileNames: ReadonlyArray<string>;
16
- markers?: ReadonlyArray<string> | undefined;
17
14
  shouldStopAscent?: ((config: TConfig) => boolean) | undefined;
18
15
  startDir: string;
16
+ stopAtDir: string;
19
17
  }
@@ -6,56 +6,22 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
6
6
  }
7
7
  return path;
8
8
  };
9
- import fs from 'node:fs';
10
- import path from 'node:path';
11
9
  import { pathToFileURL } from 'node:url';
12
- import { findProjectRoot } from "./findProjectRoot.js";
10
+ import { listDirectoryChainMatches } from "./directory-chain-matches.js";
13
11
  export async function loadConfigCascade(options) {
14
- const { fileNames, markers, shouldStopAscent, startDir } = options;
15
- assertLevelRelativeFileNames(fileNames);
16
- const projectRoot = findProjectRoot(startDir, { markers });
17
- const candidates = collectCandidates(path.resolve(startDir), projectRoot.rootDir, fileNames);
12
+ const { fileNames, shouldStopAscent, startDir, stopAtDir } = options;
13
+ const matches = listDirectoryChainMatches(startDir, fileNames, { stopAtDir });
18
14
  const entries = [];
19
- let stopReason = 'project-root';
20
- for (const { dir, filePath } of candidates) {
21
- const config = await importDefaultExport(filePath);
22
- entries.push({ config, dir, filePath });
15
+ let stopReason = 'stop-dir';
16
+ for (const { dir, entryPath } of matches) {
17
+ const config = await importDefaultExport(entryPath);
18
+ entries.push({ config, dir, filePath: entryPath });
23
19
  if (shouldStopAscent?.(config) === true) {
24
20
  stopReason = 'predicate';
25
21
  break;
26
22
  }
27
23
  }
28
- return { entries, projectRoot, stopReason };
29
- }
30
- function assertLevelRelativeFileNames(fileNames) {
31
- for (const fileName of fileNames) {
32
- if (path.isAbsolute(fileName)) {
33
- throw new Error(`Config file name must be relative to its directory level: ${fileName}`);
34
- }
35
- const normalized = path.normalize(fileName);
36
- if (normalized === '..' || normalized.startsWith(`..${path.sep}`)) {
37
- throw new Error(`Config file name must not ascend above its directory level: ${fileName}`);
38
- }
39
- }
40
- }
41
- function collectCandidates(startDir, rootDir, fileNames) {
42
- const candidates = [];
43
- let dir = startDir;
44
- let previousDir = '';
45
- while (dir !== previousDir) {
46
- for (const fileName of fileNames) {
47
- const filePath = path.join(dir, fileName);
48
- if (fs.existsSync(filePath)) {
49
- candidates.push({ dir, filePath });
50
- break;
51
- }
52
- }
53
- if (dir === rootDir)
54
- break;
55
- previousDir = dir;
56
- dir = path.dirname(dir);
57
- }
58
- return candidates;
24
+ return { entries, stopReason };
59
25
  }
60
26
  async function importDefaultExport(filePath) {
61
27
  const configModule = await import(__rewriteRelativeImportExtension(pathToFileURL(filePath).href));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@williamthorsen/toolbelt.filesystem",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "Filesystem utilities",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/williamthorsen/toolbelt/tree/main/packages/filesystem#readme",
@@ -1,11 +0,0 @@
1
- export declare const DEFAULT_ROOT_MARKERS: ReadonlyArray<string>;
2
- export declare function findProjectRoot(startDir: string, options?: FindProjectRootOptions): ProjectRoot;
3
- export interface FindProjectRootOptions {
4
- markers?: ReadonlyArray<string> | undefined;
5
- }
6
- export interface ProjectRoot {
7
- marker: string | null;
8
- rootDir: string;
9
- source: ProjectRootSource;
10
- }
11
- export type ProjectRootSource = 'marker' | 'package-json' | 'start-dir';
@@ -1,33 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- export const DEFAULT_ROOT_MARKERS = [
4
- '.git',
5
- 'pnpm-workspace.yaml',
6
- 'pnpm-lock.yaml',
7
- 'package-lock.json',
8
- 'yarn.lock',
9
- 'bun.lock',
10
- ];
11
- export function findProjectRoot(startDir, options = {}) {
12
- const { markers = DEFAULT_ROOT_MARKERS } = options;
13
- const resolvedStartDir = path.resolve(startDir);
14
- let nearestPackageJsonDir;
15
- let dir = resolvedStartDir;
16
- let previousDir = '';
17
- while (dir !== previousDir) {
18
- for (const marker of markers) {
19
- if (fs.existsSync(path.join(dir, marker))) {
20
- return { marker, rootDir: dir, source: 'marker' };
21
- }
22
- }
23
- if (nearestPackageJsonDir === undefined && fs.existsSync(path.join(dir, 'package.json'))) {
24
- nearestPackageJsonDir = dir;
25
- }
26
- previousDir = dir;
27
- dir = path.dirname(dir);
28
- }
29
- if (nearestPackageJsonDir !== undefined) {
30
- return { marker: null, rootDir: nearestPackageJsonDir, source: 'package-json' };
31
- }
32
- return { marker: null, rootDir: resolvedStartDir, source: 'start-dir' };
33
- }