@williamthorsen/toolbelt.filesystem 0.8.3 → 0.9.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,9 +2,73 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.9.0 — 2026-09-15
6
+
7
+ ### 🎉 Features
8
+
9
+ - 🚨 **Breaking:** Move createTempTree from toolbelt.filesystem to toolbelt.testing (#313)
10
+
11
+ - Moves `CreateTempTreeOptions` and the `TempTree` handle to `@williamthorsen/toolbelt.testing/candidate` along with `createTempTree`.
12
+
13
+ Migration: Import `createTempTree`, `CreateTempTreeOptions`, and `TempTree` from `@williamthorsen/toolbelt.testing/candidate`, and declare `@williamthorsen/toolbelt.testing` in the manifest that declared `@williamthorsen/toolbelt.filesystem` for them.
14
+
15
+ - Add a ReadyUp adoption kit to toolbelt.filesystem (#316)
16
+
17
+ - Adds a ReadyUp adoption kit to `@williamthorsen/toolbelt.filesystem` that recommends `writeAtomic` where a project writes a file to a temporary path and renames it into place, and `listDirectoryChain`, `findDirectoryChainMatch`, or `listDirectoryChainMatches` where a loop ascends to the filesystem root by `path.dirname`.
18
+ - Promotes `writeAtomic` to the candidate tier.
19
+
20
+ Migration: Change any import of `writeAtomic` from `@williamthorsen/toolbelt.filesystem/proposed` to `@williamthorsen/toolbelt.filesystem/candidate`.
21
+
22
+ - Read a directory walk's probed name through a binding or constant (#324)
23
+
24
+ - Extends the ReadyUp adoption kit in `@williamthorsen/toolbelt.packaging` to recommend `findPackageRoot` or `resolveSelfVersion` for a hand-rolled `package.json` search that checks for the file through a variable declared once inside its loop and never reassigned there, or through a string constant declared once in the same file.
25
+ - Stops the kit in `@williamthorsen/toolbelt.filesystem` from reporting such a search, which it previously treated as a generic directory walk.
26
+
27
+ - Report hand-rolled dedents in the strings adoption kit (#330)
28
+
29
+ - Adds `no-joined-line-array`, which reports an array of string or template literals that spans several lines and is joined with a newline.
30
+ - Adds `no-layout-breaking-template`, which reports an untagged template literal whose later lines drop below the indentation of the line on which it opens.
31
+
32
+ ### 🐛 Bug fixes
33
+
34
+ - Stop reading each segment of a probe's path as a separately probed name (#320)
35
+
36
+ - Fixes an issue in which `toolbelt.filesystem`'s ReadyUp adoption kit treated a loop over parent directories as a search for each directory's own `package.json` when the path checked at each level contained a `'package.json'` literal, as in `path.join(dir, 'node_modules', name, 'package.json')`, and so did not report the loop under `no-hand-rolled-directory-walk`.
37
+
38
+ ### ♻️ Refactoring
39
+
40
+ - Move directory-walk recognition from filesystem into packages/adoption (#318)
41
+
42
+ - Adds `listDirectoryAscents` to `packages/adoption`, which reports each directory ascent once with the names probed by its innermost loop, and reduces `filesystem`'s `listChainWalkSites` to a partition of that output, so a `toolbelt.packaging` kit can partition the same ascents without its own copy of the recognition.
43
+ - Renames the hand-off rule `isProjectRootSearch` to `isManifestSearch` and rewrites the text in `packages/adoption` and `filesystem` that credited `findProjectRoot` alone with a `package.json` walk.
44
+ - Stops `filesystem`'s kit from reporting a loop that ascends one binding around an inner loop ascending another binding and probing for `package.json`, which is the only finding changed by the move.
45
+
46
+ ### 🧪 Tests
47
+
48
+ - Move the rdy run report reader into the adoption test utilities (#323)
49
+
50
+ - Replaces the `rdy run --json` report reader copied into each of the twelve `pragma-suppression.tool.test.ts` suites with `listKitCheckReports`, a helper added to `@williamthorsen/toolbelt.adoption/test-utils` that runs a package's compiled kit over a fixture repo and returns its check reports, so a change to the shape of readyup's report needs one edit rather than twelve.
51
+ - Fixes the error thrown for a kit that does not load: Each copy discarded the load error recorded by `rdy` on the kit's entry and threw "the run reported no adoption checks", and the helper throws with `rdy`'s own message instead.
52
+
53
+ ### ⚙️ Tooling
54
+
55
+ - Remove the stale repo-local cliff.toml and normalize changelog titles (#327)
56
+
57
+ - Stops `release-kit prepare` from printing a "skipped due to grouping error(s)" warning for each releasable workspace by letting it resolve the git-cliff template bundled with release-kit, previously overridden by the root `cliff.toml`.
58
+ - Excludes commits without a ticket prefix from future changelog entries.
59
+ - Renames the section titles in every `packages/*/.meta/changelog.json`, except `Dependency updates`, to the headings of release-kit's work-type taxonomy, such as "🎉 Features" and "🏗️ Internal features", and regenerates each `CHANGELOG.md` so that release-kit orders existing and new sections by the same rule.
60
+ - Causes the next `release-kit prepare` to plan patch releases of `dstructs`, `hof`, and `sets`, which had no other commits since their last release, because the changelog commit touches every workspace.
61
+
62
+ ### 📚 Documentation
63
+
64
+ - Align prose with plain-speech doctrine and writing conventions (#306)
65
+
66
+ - Copy-edits prose across the repo: comments, test names, package READMEs, and `AGENTS.md`.
67
+ - Rewrites a few user-facing strings as well, among them `configure-project`'s help text and the errors from `parseProjectSpec`, `securityCommands`, and `hashString`.
68
+
5
69
  ## 0.8.3 — 2026-09-06
6
70
 
7
- ### Documentation
71
+ ### 📚 Documentation
8
72
 
9
73
  - Repair reduced object relatives in passages recurring across files (#262)
10
74
 
@@ -30,7 +94,7 @@ All notable changes to this project will be documented in this file.
30
94
 
31
95
  ## 0.8.1 — 2026-08-28
32
96
 
33
- ### Refactoring
97
+ ### ♻️ Refactoring
34
98
 
35
99
  - Upgrade eslint-config-typescript to v12.0.1 and satisfy its new rules (#236)
36
100
 
@@ -38,7 +102,7 @@ All notable changes to this project will be documented in this file.
38
102
 
39
103
  ## 0.8.0 — 2026-08-24
40
104
 
41
- ### Features
105
+ ### 🎉 Features
42
106
 
43
107
  - Add a recursive listFiles to createTempTree's entry API (#212)
44
108
 
@@ -46,7 +110,7 @@ All notable changes to this project will be documented in this file.
46
110
 
47
111
  ## 0.7.0 — 2026-08-21
48
112
 
49
- ### Features
113
+ ### 🎉 Features
50
114
 
51
115
  - 🚨 **Breaking:** Fix createTempTree's symlink guard and disposal, and complete its entry API (#207)
52
116
 
@@ -60,13 +124,13 @@ All notable changes to this project will be documented in this file.
60
124
 
61
125
  ## 0.6.0 — 2026-08-16
62
126
 
63
- ### Features
127
+ ### 🎉 Features
64
128
 
65
129
  - Add mkdir, symlink, write, and writeJson methods to `TempTree` (#176)
66
130
 
67
131
  Adds four write methods to `TempTree` in `@williamthorsen/toolbelt.filesystem`: `mkdir`, `symlink`, `write`, and `writeJson`. Each takes a tree-relative path, creates the parent directories it needs, resolves through the same containment check `resolve` applies, and returns the absolute path, so a suite writing into a built temporary tree reaches it through the handle rather than through `node:fs`. `symlink` accepts a link path and a target, and picks the link type from the target.
68
132
 
69
- ### Tests
133
+ ### 🧪 Tests
70
134
 
71
135
  - Drop expect-type in favor of expectTypeOf (#175)
72
136
 
@@ -74,7 +138,7 @@ All notable changes to this project will be documented in this file.
74
138
 
75
139
  ## 0.5.0 — 2026-08-15
76
140
 
77
- ### Features
141
+ ### 🎉 Features
78
142
 
79
143
  - 🚨 **Breaking:** Promote createTempTree to the candidate tier with a caller-chosen prefix and binary entries (#148)
80
144
 
@@ -88,7 +152,7 @@ All notable changes to this project will be documented in this file.
88
152
 
89
153
  ## 0.4.1 — 2026-08-13
90
154
 
91
- ### Tooling
155
+ ### ⚙️ Tooling
92
156
 
93
157
  - Remove redundant .gitignore files
94
158
  - Populate manifest metadata and adopt a pnpm catalog (#140)
@@ -97,7 +161,7 @@ All notable changes to this project will be documented in this file.
97
161
 
98
162
  ## 0.4.0 — 2026-08-12
99
163
 
100
- ### Features
164
+ ### 🎉 Features
101
165
 
102
166
  - 🚨 **Breaking:** Add reconcileFile to toolbelt.filesystem and promote describeError to release tier (#122)
103
167
 
@@ -115,7 +179,7 @@ All notable changes to this project will be documented in this file.
115
179
 
116
180
  ## 0.3.0 — 2026-08-08
117
181
 
118
- ### Features
182
+ ### 🎉 Features
119
183
 
120
184
  - Migrate replaceFileExtension into filesystem package (#74)
121
185
 
@@ -141,7 +205,7 @@ All notable changes to this project will be documented in this file.
141
205
 
142
206
  `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.
143
207
 
144
- ### Tooling
208
+ ### ⚙️ Tooling
145
209
 
146
210
  - Migrate Vitest configs to the nmr projects model (#73)
147
211
 
@@ -153,17 +217,17 @@ All notable changes to this project will be documented in this file.
153
217
 
154
218
  ## 0.2.1 — 2026-07-27
155
219
 
156
- ### Tooling
220
+ ### ⚙️ Tooling
157
221
 
158
222
  - Normalize Vitest, and lint configs
159
223
 
160
- ### Documentation
224
+ ### 📚 Documentation
161
225
 
162
226
  - Change license to ISC
163
227
 
164
228
  ## 0.2.0 — 2026-07-24
165
229
 
166
- ### Features
230
+ ### 🎉 Features
167
231
 
168
232
  - Add the filesystem package with bounded cascading config discovery (#71)
169
233
 
package/README.md CHANGED
@@ -1,8 +1,43 @@
1
+ <!-- readme-type: library -->
2
+
1
3
  # @williamthorsen/toolbelt.filesystem
2
4
 
3
5
  Filesystem utilities for TypeScript and JavaScript.
4
6
 
5
- <!-- section:release-notes --><!-- /section:release-notes -->
7
+ <!-- section:release-notes -->
8
+ ## Release notes — v0.9.0 (2026-09-15)
9
+
10
+ ### 🎉 Features
11
+
12
+ - 🚨 **Breaking:** Move createTempTree from toolbelt.filesystem to toolbelt.testing (#313)
13
+
14
+ - Moves `CreateTempTreeOptions` and the `TempTree` handle to `@williamthorsen/toolbelt.testing/candidate` along with `createTempTree`.
15
+
16
+ Migration: Import `createTempTree`, `CreateTempTreeOptions`, and `TempTree` from `@williamthorsen/toolbelt.testing/candidate`, and declare `@williamthorsen/toolbelt.testing` in the manifest that declared `@williamthorsen/toolbelt.filesystem` for them.
17
+
18
+ - Add a ReadyUp adoption kit to toolbelt.filesystem (#316)
19
+
20
+ - Adds a ReadyUp adoption kit to `@williamthorsen/toolbelt.filesystem` that recommends `writeAtomic` where a project writes a file to a temporary path and renames it into place, and `listDirectoryChain`, `findDirectoryChainMatch`, or `listDirectoryChainMatches` where a loop ascends to the filesystem root by `path.dirname`.
21
+ - Promotes `writeAtomic` to the candidate tier.
22
+
23
+ Migration: Change any import of `writeAtomic` from `@williamthorsen/toolbelt.filesystem/proposed` to `@williamthorsen/toolbelt.filesystem/candidate`.
24
+
25
+ - Read a directory walk's probed name through a binding or constant (#324)
26
+
27
+ - Extends the ReadyUp adoption kit in `@williamthorsen/toolbelt.packaging` to recommend `findPackageRoot` or `resolveSelfVersion` for a hand-rolled `package.json` search that checks for the file through a variable declared once inside its loop and never reassigned there, or through a string constant declared once in the same file.
28
+ - Stops the kit in `@williamthorsen/toolbelt.filesystem` from reporting such a search, which it previously treated as a generic directory walk.
29
+
30
+ - Report hand-rolled dedents in the strings adoption kit (#330)
31
+
32
+ - Adds `no-joined-line-array`, which reports an array of string or template literals that spans several lines and is joined with a newline.
33
+ - Adds `no-layout-breaking-template`, which reports an untagged template literal whose later lines drop below the indentation of the line on which it opens.
34
+
35
+ ### 🐛 Bug fixes
36
+
37
+ - Stop reading each segment of a probe's path as a separately probed name (#320)
38
+
39
+ - Fixes an issue in which `toolbelt.filesystem`'s ReadyUp adoption kit treated a loop over parent directories as a search for each directory's own `package.json` when the path checked at each level contained a `'package.json'` literal, as in `path.join(dir, 'node_modules', name, 'package.json')`, and so did not report the loop under `no-hand-rolled-directory-walk`.
40
+ <!-- /section:release-notes -->
6
41
 
7
42
  ## Installation
8
43
 
@@ -12,9 +47,44 @@ pnpm add @williamthorsen/toolbelt.filesystem
12
47
 
13
48
  ## Runtime requirements
14
49
 
15
- `createTempTree`, `findDirectoryChainMatch`, `listDirectoryChainMatches`, `loadConfigCascade`, `reconcileFile`, `reconcileFileFromFile`, and `writeAtomic` 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.
50
+ `findDirectoryChainMatch`, `listDirectoryChainMatches`, `loadConfigCascade`, `reconcileFile`, `reconcileFileFromFile`, and `writeAtomic` 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.
51
+
52
+ `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.
53
+
54
+ ## Adoption checks
55
+
56
+ The package ships a ReadyUp kit, so a project that installs it can ask how far its adoption got:
57
+
58
+ ```sh
59
+ rdy run --packages
60
+ ```
61
+
62
+ The kit reads the project's tracked sources and reports two idioms, each counted against the calls that the project already makes into this package. Both report at `recommend`: They are correct code that a published function expresses better, not defects.
63
+
64
+ `no-hand-rolled-atomic-write` reports a function body that writes a path held in a binding and renames that same binding, naming the function that holds it. A write alone, a rename alone, a `copyFile` followed by a rename, and a rename of a path that the body never wrote report nothing. The site reports at `recommend` however the temp file is staged, because a text scan cannot tell whether two paths share a volume, and one staged under the system temporary directory is the case worth checking by hand: `rename` is atomic only within one filesystem.
16
65
 
17
- `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.
66
+ `no-hand-rolled-directory-walk` reports a loop that ascends by `dirname` until it reaches the filesystem root, naming the line the loop opens on. A loop that only ascends takes [`listDirectoryChain`](#listdirectorychain); one that probes each level for a name takes [`findDirectoryChainMatch`](#finddirectorychainmatch) or [`listDirectoryChainMatches`](#listdirectorychainmatches). The report names the site rather than the shape, so the fix text describes both and the reader chooses from the loop itself. A loop probing for `package.json` is left alone: That site belongs to [`@williamthorsen/toolbelt.packaging`](https://github.com/williamthorsen/toolbelt/tree/main/packages/packaging#readme), whose own kit reports it, and reporting it here would mean seeing one loop twice under conflicting advice.
67
+
68
+ The walk detector under-matches by design. A recursive walk-up function is no loop, an ascent written as `path.resolve(dir, '..')` carries a different anchor, and a loop that computes a parent per item without assigning it back is no ascent. None of the three reports.
69
+
70
+ Bootstrap wrappers under `bin/` are exempt: Such a wrapper imports only builtins so its build-first message survives an incomplete install, and importing this package there would replace that message with a module-resolution failure. Tests are exempt too, since they write these shapes deliberately. A source declared generated or vendored by the project in its own `.gitattributes`, under `linguist-generated` or `linguist-vendored`, is exempt as well: The sweep drops it before the kit sees it, so committed bundler output yields no advice that anyone could act on. The sweep is readyup's, so this holds on readyup 0.35.0 or later.
71
+
72
+ A reviewed site is silenced by an `rdy-ignore` pragma on its own line, or `rdy-ignore-next-line` on the line above. A pragma naming a check's id suppresses that check alone; with no id it covers every check on the line. A failed check prints its id ahead of its fraction, which is the form to write:
73
+
74
+ ```ts
75
+ // rdy-ignore-next-line toolbelt.filesystem/no-hand-rolled-directory-walk -- the ascent stops at a ceiling
76
+ while (dir !== stopAtDir) {
77
+ dir = path.dirname(dir);
78
+ }
79
+ ```
80
+
81
+ Add the package to `.config/readyup.config.ts` to include it in a routine sweep:
82
+
83
+ ```ts
84
+ export default defineRdyConfig({
85
+ packages: ['@williamthorsen/toolbelt.filesystem'],
86
+ });
87
+ ```
18
88
 
19
89
  ## `listDirectoryChain`
20
90
 
@@ -36,7 +106,7 @@ listDirectoryChain('/home/dev/app/src', { stopAtDir: '/home/dev' });
36
106
 
37
107
  `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.
38
108
 
39
- The result type records that the chain is never empty, which is what spares the nearest directory an undefined check:
109
+ The result type records that the chain is never empty, which spares the nearest directory an undefined check:
40
110
 
41
111
  ```ts
42
112
  const [nearestDir] = listDirectoryChain(process.cwd()); // string, not string | undefined
@@ -71,7 +141,7 @@ listDirectoryChainMatches('/home/dev/app/src', ['.git'], { stopAtDir: '/home/dev
71
141
  // [{ dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }]
72
142
  ```
73
143
 
74
- 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.
144
+ 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 lets `.git` be probed without knowing whether the clone is ordinary or a worktree.
75
145
 
76
146
  Each name is a path relative to the level against which it is probed, 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.
77
147
 
@@ -98,7 +168,7 @@ findDirectoryChainMatch('/home/dev/app/src', ['.git']);
98
168
  // { dir: '/home/dev/app', entryName: '.git', entryPath: '/home/dev/app/.git' }
99
169
  ```
100
170
 
101
- 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.
171
+ 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.
102
172
 
103
173
  ## `loadConfigCascade`
104
174
 
@@ -113,9 +183,9 @@ loadConfigCascade<TConfig>(options: {
113
183
 
114
184
  Loads every config file between `startDir` and `stopAtDir`, nearest first, and reads nothing above that boundary.
115
185
 
116
- 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.
186
+ 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 that `listDirectoryChain` sets out.
117
187
 
118
- 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.
188
+ The boundary is required, and it is the caller's to choose. That 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.
119
189
 
120
190
  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:
121
191
 
@@ -130,7 +200,7 @@ interface ConfigCascade<TConfig> {
130
200
  }
131
201
  ```
132
202
 
133
- A config is the module's default export. A matched module declaring none is rejected by name; validating what a config contains stays with the caller, which is what lets one mechanism serve schemas sharing no fields.
203
+ A config is the module's default export. A matched module declaring none is rejected by name; validating what a config contains stays with the caller, which lets one mechanism serve schemas sharing no fields.
134
204
 
135
205
  ### The `shouldStopAscent` convention
136
206
 
@@ -176,9 +246,9 @@ reconcileFile('.config/tool.config.ts', template);
176
246
  // { filePath: '.config/tool.config.ts', outcome: 'created' }
177
247
  ```
178
248
 
179
- Missing parent directories are created. `isDryRun` writes nothing and creates no directory, returning the outcome that the real call would have produced, which is what lets a `--dry-run` flag print what the run itself would. A write that would fail is the exception: nothing detects that without attempting it, so a dry run reports the write's intended outcome.
249
+ Missing parent directories are created. `isDryRun` writes nothing and creates no directory, returning the outcome that the real call would have produced, which lets a `--dry-run` flag print what the run itself would. A write that would fail is the exception: Nothing detects that without attempting it, so a dry run reports the write's intended outcome.
180
250
 
181
- `conflictPolicy` decides what becomes of an existing file whose content differs, and decides nothing else: it is consulted in that case alone. The default, `'skip'`, never replaces a file that the user may have edited.
251
+ `conflictPolicy` decides what becomes of an existing file whose content differs, and decides nothing else: It is consulted in that case alone. The default, `'skip'`, never replaces a file that the user may have edited.
182
252
 
183
253
  | exists | differs | `conflictPolicy` | outcome |
184
254
  | ------ | ------- | ---------------- | ------------- |
@@ -189,7 +259,7 @@ Missing parent directories are created. `isDryRun` writes nothing and creates no
189
259
 
190
260
  What counts as differing follows the policy, which is the part worth reading twice. `'replace'` promises the file holds exactly `content` afterwards, so only byte-identical content reports `up-to-date`; a file differing from `content` only in trailing whitespace is rewritten, because calling it up to date would leave the caller holding a file that is not what it asked for. `'skip'` modifies nothing either way, so its comparison decides a message alone and ignores trailing whitespace per line and at end of file, which keeps formatter churn from reading as a conflict. `up-to-date` therefore means the same thing under both: This policy has no work to do.
191
261
 
192
- The result discriminates on `outcome`, so a failure always carries its reason:
262
+ The result discriminates on `outcome`, so a failure always names its reason:
193
263
 
194
264
  ```ts
195
265
  type FileReconciliation =
@@ -198,13 +268,13 @@ type FileReconciliation =
198
268
  | { filePath: string; outcome: 'failed'; error: string };
199
269
  ```
200
270
 
201
- An I/O error on the write path reports `failed` rather than throwing, which is what lets a command writing several files collect a result for each instead of losing the rest to the first failure.
271
+ An I/O error on the write path reports `failed` rather than throwing, which lets a command writing several files collect a result for each instead of losing the rest to the first failure.
202
272
 
203
273
  Three behaviors are worth knowing before they surprise you:
204
274
 
205
- - A `skipped` result carrying an `error` means the existing file could not be read for comparison. The file was left alone, which is exactly what `'skip'` promises, so this is not a failure and a command exiting non-zero on failures should not count it as one.
206
- - The existence probe follows symlinks. A dangling symlink therefore reports as non-existent: the outcome is `created`, the result names the link, and the bytes land at the link's target.
207
- - The probe and the write are separate calls, leaving a window in which another process can create or remove the file. That gap is left open deliberately: the callers that this serves are scaffolding commands with no competing writer, and an exclusive-create flag would close only the create half of it.
275
+ - A `skipped` result with an `error` means the existing file could not be read for comparison. The file was left alone, which is exactly what `'skip'` promises, so this is not a failure and a command exiting non-zero on failures should not count it as one.
276
+ - The existence probe follows symlinks. A dangling symlink therefore reports as non-existent: The outcome is `created`, the result names the link, and the bytes land at the link's target.
277
+ - The probe and the write are separate calls, leaving a window in which another process can create or remove the file. That gap is left open deliberately: The callers that this serves are scaffolding commands with no competing writer, and an exclusive-create flag would close only the create half of it.
208
278
 
209
279
  ## `reconcileFileFromFile`
210
280
 
@@ -216,7 +286,7 @@ reconcileFileFromFile(
216
286
  ): FileReconciliation;
217
287
  ```
218
288
 
219
- Reconciles `filePath` against the content of `sourcePath`, which is what a command copying a bundled template reaches for:
289
+ Reconciles `filePath` against the content of `sourcePath`, for which a command copying a bundled template reaches:
220
290
 
221
291
  ```ts
222
292
  import { reconcileFileFromFile } from '@williamthorsen/toolbelt.filesystem';
@@ -225,9 +295,9 @@ reconcileFileFromFile('.config/git-cliff.toml', bundledTemplatePath);
225
295
  // { filePath: '.config/git-cliff.toml', outcome: 'created' }
226
296
  ```
227
297
 
228
- It is [`reconcileFile`](#reconcilefile) with the read supplied: the outcome table, the conflict policy, the created parent directories, and the result type are that function's, unchanged. Three things are this one's own.
298
+ It is [`reconcileFile`](#reconcilefile) with the read supplied: The outcome table, the conflict policy, the created parent directories, and the result type are that function's, unchanged. Three things are this one's own.
229
299
 
230
- The source is read as utf8 text, so a binary source is not supported: it would be decoded and re-encoded on the way through.
300
+ The source is read as utf8 text, so a binary source is not supported: It would be decoded and re-encoded on the way through.
231
301
 
232
302
  A source that cannot be read reports `failed` rather than throwing, and a missing source is not distinguished from an unreadable one. The reason names the source and the cause:
233
303
 
@@ -235,134 +305,13 @@ A source that cannot be read reports `failed` rather than throwing, and a missin
235
305
  Failed to read /pkg/cliff.toml.template: ENOENT: no such file or directory, open '/pkg/cliff.toml.template'
236
306
  ```
237
307
 
238
- The path is interpolated rather than left to the underlying message, which carries none of its own at the read stage: reading a directory yields `EISDIR: illegal operation on a directory, read`. Under `ENOENT` the path therefore reads twice. The result's `filePath` is the destination on this path as on every other, so a caller copying several templates keys its results by destination and still sees which source failed.
308
+ The path is interpolated rather than left to the underlying message, which has none of its own at the read stage: Reading a directory yields `EISDIR: illegal operation on a directory, read`. Under `ENOENT` the path therefore reads twice. The result's `filePath` is the destination on this path as on every other, so a caller copying several templates keys its results by destination and still sees which source failed.
239
309
 
240
310
  The source is read even under `isDryRun`, because the outcome depends on comparing its content. A dry run can therefore report `failed` where `reconcileFile`'s cannot, and it still writes nothing.
241
311
 
242
- ## `createTempTree`
243
-
244
- Candidate tier: imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than the package root, and subject to change.
245
-
246
- ```ts
247
- createTempTree(entries: Record<string, string | Uint8Array>, options?: { prefix?: string }): TempTree;
248
- ```
249
-
250
- Builds a throwaway directory tree and returns a handle that removes it when the binding leaves scope:
251
-
252
- ```ts
253
- import { createTempTree } from '@williamthorsen/toolbelt.filesystem/candidate';
254
-
255
- {
256
- using tree = createTempTree({
257
- '.git/': '',
258
- 'packages/app/package.json': '{ "name": "app" }',
259
- });
260
-
261
- tree.dir; // '/private/var/folders/.../toolbelt-a1b2c3'
262
- tree.resolve('packages/app'); // '/private/var/folders/.../toolbelt-a1b2c3/packages/app'
263
- }
264
- // The tree is gone here.
265
- ```
266
-
267
- 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.
268
-
269
- A value is text or the bytes themselves, so a body that no UTF-8 round trip survives is as writable as a string:
270
-
271
- ```ts
272
- using tree = createTempTree({ 'logo.png': pngBytes });
273
- ```
274
-
275
- `prefix` names the directory built under the system temporary directory, defaulting to `toolbelt-`. Set it to whatever is doing the building, so a tree outliving a crashed run says what made it:
276
-
277
- ```ts
278
- using tree = createTempTree({}, { prefix: 'rdy-tsconfig-' });
279
- tree.dir; // '/private/var/folders/.../rdy-tsconfig-a1b2c3'
280
- ```
281
-
282
- A prefix that would place the tree anywhere but directly inside the system temporary directory is rejected before anything is created. `mkdtemp` appends its random suffix to the joined path as given, so a prefix holding `/` or `\` targets a nested directory that has to already exist, or, where it ascends, a directory outside the temporary one; and a prefix that normalizes away (`''`, `'.'`, or `'..'`) lands the suffix beside the temporary directory rather than within it.
283
-
284
- ```ts
285
- interface TempTree extends Disposable {
286
- readonly dir: string;
287
- exists(entryPath: string): boolean;
288
- list(entryPath?: string): string[];
289
- listFiles(entryPath?: string): string[];
290
- mkdir(entryPath: string): string;
291
- read(entryPath: string): string;
292
- readJson(entryPath: string): unknown;
293
- resolve(...segments: string[]): string;
294
- rm(entryPath: string): void;
295
- symlink(linkPath: string, targetPath: string): string;
296
- write(entryPath: string, contents: string | Uint8Array): string;
297
- writeAll(entries: Record<string, string | Uint8Array>): void;
298
- writeJson(entryPath: string, value: unknown): string;
299
- }
300
- ```
301
-
302
- `dir` is realpath-resolved, because `os.tmpdir()` is a symlink on macOS and a caller comparing paths against it would otherwise see a mismatch that it did not cause.
303
-
304
- `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.
305
-
306
- `mkdir`, `symlink`, `write`, `writeAll`, and `writeJson` write into the tree after it is built, for a fixture that varies per test or a file created to trigger a re-read:
307
-
308
- ```ts
309
- using tree = createTempTree({ 'packages/app/package.json': '{ "name": "app" }' });
310
-
311
- tree.write('packages/app/src/main.ts', 'export {};\n'); // '/private/var/folders/.../packages/app/src/main.ts'
312
- tree.writeJson('tsconfig.json', { include: ['src'] });
313
- tree.mkdir('packages/empty');
314
- ```
315
-
316
- Each creates the parent directories that it needs, resolves through the same containment check as `resolve`, and returns the absolute path of what it wrote. `symlink`'s link path is checked; its target is not, being a string held by the link rather than a location to which the tree writes.
317
-
318
- `writeAll` takes the same map as the constructor, `/`-suffix convention included, so a fixture built in one call can be added to in one call:
319
-
320
- ```ts
321
- tree.writeAll({ 'packages/empty/': '', 'packages/app/src/main.ts': 'export {};\n' });
322
- ```
323
-
324
- It returns nothing, there being no single path to return, and unlike the constructor it is not atomic: a failure part-way leaves the entries already written in place, there being no whole tree to discard.
325
-
326
- They part company on an entry that already exists: `write` replaces it, `mkdir` leaves it and its contents alone, and `symlink` raises `EEXIST`.
327
-
328
- `symlink` takes the link first and the target second, inverting `fs.symlinkSync`, so that it reads like the other methods: the path being created leads. The target is stored verbatim, so it may be absolute or relative, name something outside the tree, or dangle until the target appears; a relative one resolves against the link's own directory, as POSIX resolves it. Code under test that reads a link rather than following it therefore sees the string that was passed, which is what a consumer hashing a link's target depends on.
329
-
330
- ```ts
331
- using tree = createTempTree({ 'store/kit/package.json': '{ "name": "kit" }' });
332
-
333
- tree.symlink('node_modules/kit', '../store/kit'); // reads back as '../store/kit'
334
- tree.symlink('node_modules/.bin', tree.resolve('store/kit/bin')); // reads back absolute
335
- ```
336
-
337
- The link type is chosen from the target, which is where the one portability difference lives. An absolute directory target is linked as a junction, which Windows creates without the elevation needed by a directory symlink; a relative directory target is linked as a directory, which needs that elevation, because Node normalizes a junction's target to an absolute path and would discard the relative string. Every other target, one that does not exist included, is linked as a file, matching what Node falls back to when no type is given.
338
-
339
- `exists`, `list`, `listFiles`, `read`, `readJson`, and `rm` read the tree back and remove from it, each through the same containment check:
340
-
341
- ```ts
342
- using tree = createTempTree({ 'packages/app/package.json': '{ "name": "app" }', 'packages/app/src/main.ts': 'export {};\n' });
343
-
344
- tree.list(); // ['packages'], defaulting to the tree root
345
- tree.list('packages/app'); // ['package.json', 'src'], sorted
346
- tree.listFiles('packages'); // ['app/package.json', 'app/src/main.ts'], at any depth
347
- tree.read('packages/app/src/main.ts'); // 'export {};\n'
348
- tree.readJson('packages/app/package.json'); // unknown, for the caller to narrow
349
- tree.exists('packages/app/tsconfig.json'); // false
350
- tree.rm('packages/app');
351
- ```
352
-
353
- `listFiles` reaches every depth and reports paths relative to the directory given to it, sorted, with `/` as the separator on every platform: a path in a test's assertion is a value rather than a location, so `'app/src/main.ts'` should not vary by platform. It parts from `list` twice. A directory that is not there returns `[]` where `list` raises `ENOENT`, which is what lets a suite assert that a build emitted nothing without guarding the call; a path that exists as a file still raises `ENOTDIR`, as `list` does. And a symlink below the directory given to it is neither named nor descended, so every path in the result names a file held inside the tree, where `list` reports a link by name at its own level. The directory given as the argument is the exception, followed as `list`, `read`, and `exists` follow theirs: one naming a link out of the tree lists the target's files.
354
-
355
- `read` returns UTF-8 text, and a missing entry raises `ENOENT` rather than returning an empty string -- `exists` is the check. `readJson` returns `unknown`, so a caller narrows it rather than trusting an asserted type; contents that do not parse raise an error naming the entry, which the parse error alone does not. `exists` follows a symlink, so it returns `false` for a dangling one. `rm` is recursive and silent on an entry that is not there.
356
-
357
- `writeJson` writes two-space-indented JSON ending in a newline, so a tree outliving a crashed run reads as a real config file would. A fixture needing exact bytes goes through `write` instead. A value that `JSON.stringify` cannot represent -- `undefined`, a function, a symbol -- is refused rather than written, so an optional binding that arrived empty fails at the call that passed it instead of surfacing later as a parse error.
358
-
359
- Disposal is idempotent, and it removes a tree that has been made unwritable: unlinking an entry needs write permission on the directory containing it, so disposal restores permission across the tree and retries once before giving up. A suite that chmods a directory to exercise a write-failure path therefore needs no wrapper to chmod it back.
360
-
361
- `Disposable` is declared in `lib.esnext.disposable.d.ts` alone, so consuming this export requires `ESNext.Disposable` in your `lib`.
362
-
363
312
  ## `replaceFileExtension`
364
313
 
365
- Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
314
+ Proposed tier: Imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
366
315
 
367
316
  ```ts
368
317
  replaceFileExtension(filePath: string, newExtension: string, options?: { oldExtension?: string }): string;
@@ -390,7 +339,7 @@ Two inputs throw rather than returning a path that would quietly be wrong: a `fi
390
339
 
391
340
  ## `writeAtomic`
392
341
 
393
- Proposed tier: imported from `@williamthorsen/toolbelt.filesystem/proposed` rather than the package root, and subject to change.
342
+ Candidate tier: Imported from `@williamthorsen/toolbelt.filesystem/candidate` rather than the package root, and subject to change.
394
343
 
395
344
  ```ts
396
345
  writeAtomic(filePath: string, content: string | Uint8Array): Promise<void>;
@@ -399,19 +348,19 @@ writeAtomic(filePath: string, content: string | Uint8Array): Promise<void>;
399
348
  Writes `content` to `filePath` through a temp file and a rename, so a concurrent reader sees either the previous file or the complete new one, never a partial write:
400
349
 
401
350
  ```ts
402
- import { writeAtomic } from '@williamthorsen/toolbelt.filesystem/proposed';
351
+ import { writeAtomic } from '@williamthorsen/toolbelt.filesystem/candidate';
403
352
 
404
353
  await writeAtomic('.agents/manifest.json', `${JSON.stringify(manifest, null, 2)}\n`);
405
354
  ```
406
355
 
407
- The temp file is a sibling of the target, which is the part that a hand-rolled copy most often gets wrong: `rename` is atomic only within one filesystem, so a temp file staged under the system temporary directory fails with `EXDEV` the moment the target lives on another volume. Its name is dot-prefixed and carries a random component, so it stays out of `*` globs and two processes writing the same target do not collide.
356
+ The temp file is a sibling of the target, which is the part that a hand-rolled copy most often gets wrong: `rename` is atomic only within one filesystem, so a temp file staged under the system temporary directory fails with `EXDEV` the moment the target lives on another volume. Its name is dot-prefixed and contains a random component, so it stays out of `*` globs and two processes writing the same target do not collide.
408
357
 
409
358
  Missing parent directories are created, as they are for [`reconcileFile`](#reconcilefile).
410
359
 
411
- An existing target's permission bits are carried onto the replacement. A plain `writeFile` truncates the file in place and so preserves its mode, while a rename replaces the inode and would otherwise reset it to the platform default; without this, swapping a plain write for an atomic one would silently widen a `0o600` file to world-readable. A target that does not exist yet gets the platform default, exactly as a plain write would.
360
+ An existing target's permission bits are copied onto the replacement. A plain `writeFile` truncates the file in place and so preserves its mode, while a rename replaces the inode and would otherwise reset it to the platform default; without this, swapping a plain write for an atomic one would silently widen a `0o600` file to world-readable. A target that does not exist yet gets the platform default, exactly as a plain write would.
412
361
 
413
362
  Three behaviors are worth knowing before they surprise you:
414
363
 
415
- - Nothing is fsynced. "Atomic" here means no torn reads, not survives-power-loss: a write from which this function has returned can still be lost to a power failure. A durability option is additive if a caller ever needs one.
364
+ - Nothing is fsynced. "Atomic" here means no torn reads, not survives-power-loss: A write from which this function has returned can still be lost to a power failure. A durability option is additive if a caller ever needs one.
416
365
  - A symlink at `filePath` is replaced by a regular file rather than written through, because the rename replaces the target's directory entry. The link's former target is left untouched.
417
366
  - A failure removes the temp file best-effort and rethrows the error that caused it, never the cleanup's own. Where the cleanup also fails, the temp file survives beside the target under its dot-prefixed name ending in `.tmp`, which is where to look for one.
@@ -1,2 +1 @@
1
1
  export { replaceFileExtension, type ReplaceFileExtensionOptions } from './replaceFileExtension.js';
2
- export { writeAtomic } from './writeAtomic.js';
@@ -1,2 +1 @@
1
1
  export { replaceFileExtension } from "./replaceFileExtension.js";
2
- export { writeAtomic } from "./writeAtomic.js";
@@ -1 +1 @@
1
- export { createTempTree, type CreateTempTreeOptions, type TempTree } from './createTempTree.js';
1
+ export { writeAtomic } from './writeAtomic.js';
@@ -1 +1 @@
1
- export { createTempTree } from "./createTempTree.js";
1
+ export { writeAtomic } from "./writeAtomic.js";
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Writes `content` to `filePath` through a sibling temp file and a rename, so a concurrent reader sees either the
3
- * previous file or the complete new one, never a partial write. Siting the temp file beside the target is what
3
+ * previous file or the complete new one, never a partial write. Siting the temp file beside the target
4
4
  * keeps the rename within one filesystem, where it is atomic.
5
5
  *
6
- * Missing parent directories are created. An existing target's permission bits are carried onto the replacement,
6
+ * Missing parent directories are created. An existing target's permission bits are copied onto the replacement,
7
7
  * which a bare `writeFile` would preserve by truncating in place and a rename would otherwise reset to the
8
8
  * platform default.
9
9
  *
@@ -19,6 +19,6 @@
19
19
  *
20
20
  * @category Filesystem
21
21
  * @experimental
22
- * @stage proposed
22
+ * @stage candidate
23
23
  */
24
24
  export declare function writeAtomic(filePath: string, content: string | Uint8Array): Promise<void>;
@@ -2,7 +2,7 @@
2
2
  * Returns `startDir` resolved to an absolute path, followed by each of its ancestors, nearest first.
3
3
  *
4
4
  * The chain runs to the filesystem root unless `stopAtDir` bounds it, and always holds at least the start
5
- * directory, which is what the return type records. Paths are manipulated as strings; nothing is read from disk.
5
+ * directory, which the return type records. Paths are manipulated as strings; nothing is read from disk.
6
6
  *
7
7
  * @example
8
8
  * listDirectoryChain('/home/dev/app/src', { stopAtDir: '/home/dev' });
@@ -7,7 +7,7 @@
7
7
  * true, the ascent halts and no farther file is imported. Nothing above `stopAtDir` is ever read:
8
8
  * Each name must stay within the level against which it is probed, so one that escapes is rejected up front.
9
9
  *
10
- * The boundary is the caller's to choose, which is what keeps this function free of any notion of what
10
+ * The boundary is the caller's to choose, which keeps this function free of any notion of what
11
11
  * marks a project. `findProjectRoot` from `@williamthorsen/toolbelt.packaging` resolves one from markers.
12
12
  *
13
13
  * Each config is the module's default export; validating its contents is the caller's job.
@@ -7,7 +7,7 @@
7
7
  * ignores trailing whitespace per line and at end of file, which keeps formatter churn from reading as a
8
8
  * conflict. `up-to-date` therefore means the same thing under both policies: This one has no work to do.
9
9
  *
10
- * An I/O error is reported as `failed` rather than thrown, which is what lets a caller writing several files
10
+ * An I/O error is reported as `failed` rather than thrown, which lets a caller writing several files
11
11
  * collect a result for each instead of losing the rest to the first failure. A dry run writes nothing and
12
12
  * creates no directory, returning the outcome that the real call would have produced, short of a write failure,
13
13
  * which nothing detects without attempting the write.