@kaisers-io/refs 0.11.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/CHANGELOG.md +339 -2
  2. package/dist/refs.mjs +60 -51
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -5,7 +5,343 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.13.0] - 2026-09-12
9
+
10
+ ### Upgrading
11
+
12
+ **`refs add <url> --description "…"` now refuses a source with workspace members.** It holds one
13
+ description, about the repository, and none for a package — and a package's description is no
14
+ longer read out of its manifest, so there is nothing for it to fall back on. Such a source exits
15
+ `3`, listing every package the proposal will need a description for and printing the two-phase
16
+ commands to run instead, with the source quoted into them. The shortcut still finalizes a
17
+ repository with no detected packages, and one whose only detected package is the root at `.`.
18
+
19
+ If a script calls the one-shot against a monorepo, it will start failing. The two-phase flow is the
20
+ replacement and always was the documented agent path.
21
+
22
+ **Package descriptions already in `config.toml` are left exactly as they are.** A description
23
+ imported from a manifest before this release stays; nothing distinguishes it from one someone
24
+ wrote, so nothing rewrites it. To review them: `refs show <ref> --packages --json`, and
25
+ `refs edit <ref> --package <name> description "…"` to replace one.
26
+
27
+ **A ref key containing an unpaired surrogate is now rejected.** No configuration written by refs
28
+ can contain one — TOML has no escape for it — so this only affects a hand-built key.
29
+
30
+ ### Fixed
31
+
32
+ - **`refs add` says when it could not fully inspect a repository's declared workspaces.** A
33
+ repository declaring `packages/**` — an ordinary spelling the pattern classifier cannot expand —
34
+ produced a proposal missing every member, with no indication why. That is not self-explanatory:
35
+ a thin `packages` record is also what an ordinary repository produces, and a detected root makes
36
+ even a memberless result look complete. The configuration was then built without those packages,
37
+ and the drift probe returns immediately for a ref that configures none, so nothing downstream
38
+ could recover it either. Both `--dry-run` and the `--description` one-shot now carry a warning
39
+ naming what stopped the scan — an unexpandable pattern, an unreadable manifest, a candidate that
40
+ could not be inspected — alongside any clone warning rather than instead of it.
41
+
42
+ - **A drift probe that could not finish looking says so, instead of reporting `ok`.** The pass that
43
+ finds packages a checkout declares and the configuration does not have stands down whenever
44
+ workspace detection may have missed something — an unreadable manifest, a pattern the classifier
45
+ cannot expand. That conservatism is right: a second declaration of the same name could be behind
46
+ the obstacle, and naming one path from a partial view would prescribe something registration
47
+ might not do. What was wrong is that it then said nothing at all, so `refs doctor` answered
48
+ `config-drift: ok — every configured package path resolves` while an unregistered package sat in
49
+ the checkout. One malformed `package.json` anywhere in a monorepo was enough, permanently.
50
+
51
+ Both discovery passes now report the obstacle. `structure` gains `discovery_incomplete`, naming
52
+ what stopped them (`packages/c: manifest_unreadable`), and its `status` is `unknown` rather than
53
+ `ok`. The findings about configured entries are unaffected — that half never needed the scan to
54
+ begin.
55
+
56
+ - **A ref whose key carries `@`, a space or any non-ASCII character can be locked, and therefore
57
+ synced.** `zRefKey` admits every character but `/`, `\`, `%` and `:`; the lock alphabet is far
58
+ narrower, and the derived lock name was passed through unchanged. Such a ref could be added and
59
+ read but never locked — so `refs sync`, `refs remove`, `refs resolve`'s package verification and
60
+ `refs doctor`'s drift check all failed for it, with a message naming the lock name rather than
61
+ the ref. A name the lock alphabet will not accept now falls back to the same bounded digest form
62
+ a too-long name already used. Keys the alphabet does admit keep their readable name unchanged.
63
+ Reachable through a self-hosted url or a hand-edited `config.toml`; no forge allows these
64
+ characters in a repository path.
65
+ - **A ref key carrying an unpaired surrogate is rejected.** Such a character is a valid JavaScript
66
+ string and has no UTF-8 encoding, so everything that writes the key out — the checkout directory,
67
+ the digest a lock name can fall back to — silently substituted U+FFFD for it. Keys ending
68
+ U+D800, U+D801 and U+FFFD therefore shared one directory and one lock name, and two unrelated
69
+ refs would have serialized against each other. No real config could carry one: TOML has no
70
+ escape for an unpaired surrogate.
71
+ - **A failed sync no longer persists an unbounded amount of remote output into `state.json`.**
72
+ `last_error` holds the failure's message, most often git's own output — which quotes what git was
73
+ working on, ref names among them, chosen by the tracked repository. No persistence-specific limit
74
+ applied: an upstream with 120 conflicting tags produced a 47,816-character error, all of it
75
+ written into a file refs reads back on every command. A newly recorded message now keeps 1500
76
+ characters from the start and 500 from the end, with an exact count of what was dropped in
77
+ between — so both the command that failed and git's own closing hint survive. The result of the
78
+ run that produced the failure still carries the message as it arrived.
79
+
80
+ ### Changed
81
+
82
+ - **Dependencies refreshed.** `zod` 4.5.2 → 4.6.2 — the only production dependency, and bundled
83
+ into the published CLI. The development toolchain moved too: `vitest` 4 → 5, `tsdown` 0.22 →
84
+ 0.23 (rolldown 1.2.8), `oxlint`, `oxfmt` and `vite`. No behaviour depends on any of it.
85
+ - **A package's description is never read out of a manifest.** Workspace detection carried each
86
+ package's `description` from its `package.json`, and `refs add <source> --description "…"`
87
+ persisted it into `config.toml` — text written by whoever owns the upstream repository, crossing
88
+ into a file refs reads back as its own configuration and replays on every `refs list` and
89
+ `refs show`. Detection now carries a package's name and path and nothing else, which is what
90
+ `refs sync`'s `unregistered` finding has done since 0.12.0. Every description in a config entry
91
+ is written by someone who read the source.
92
+
93
+ The one-shot `refs add <source> --description "…"` therefore no longer registers workspace
94
+ members: it has one description, about the repository, and none for a package. It still
95
+ finalizes a source with no detected packages, or one whose only detected package is the
96
+ repository root at `.` — and there the text now wins over whatever the root manifest said about
97
+ itself. For anything else it exits `3`, naming every package and printing the two-phase commands
98
+ to run instead, with the source it was given quoted into them, and listing every package the
99
+ proposal will need a description for — the repository root included, which is not the set the
100
+ refusal is about but is the set `refs add --proposal` requires.
101
+
102
+ A refusal registers no ref. The checkout it cloned stays, and is now recorded as a pending add —
103
+ so for the next 24 hours `doctor` reports it as one instead of offering to delete a checkout the
104
+ printed recovery is about to reuse, and the clone mode actually used survives into the finalize.
105
+ That mode cannot be recovered from the checkout afterwards: git records
106
+ `remote.origin.promisor` and `partialclonefilter` from the requested filter, whether or not the
107
+ server honoured it.
108
+
109
+ Existing entries are untouched. A description imported from a manifest before this release stays
110
+ exactly as it is — nothing distinguishes it from one written by hand, so nothing rewrites it. To
111
+ review them: `refs show <ref> --packages --json`, and `refs edit <ref> --package <name>
112
+ description "…"` to replace one.
113
+
114
+ ### Security
115
+
116
+ - `last_error` is documented as what it is — a failure message, usually git's own output, quoting
117
+ ref names the tracked repository chose — and `skills/refs/COMMANDS.md` now tells agents to read
118
+ it as untrusted evidence rather than as something refs vouches for.
119
+ - `SECURITY.md` claimed refs "never reads checkout content as configuration", which was not true of
120
+ manifest descriptions and is still not true of package names, paths, the default branch, or a
121
+ `tag_format` derived from real tags. The claim is now stated as what the code enforces: refs never
122
+ imports a description out of a manifest. The values that do still cross are named, along with the
123
+ fact that being structural does not make them safe to read — a package name is whatever the
124
+ manifest declares.
125
+
126
+ ## [0.12.0] - 2026-09-08
127
+
128
+ ### Upgrading
129
+
130
+ Run `refs sync` after updating. Two things are worth expecting on that first run.
131
+
132
+ **Packages may be reported as `missing` that are still on disk.** Negated workspace patterns are
133
+ applied now, so a package a repository excludes (`!examples/vue/2*`) is no longer a workspace
134
+ member — and an entry registered for one before this release no longer verifies. Nothing is removed
135
+ automatically; the finding names the entry and leaves the decision alone.
136
+
137
+ **Packages that arrived upstream since the last sync are reported.** Only those: a package the
138
+ configuration never had and that did not arrive in the fetched range stays unmentioned, however
139
+ long it has been there.
140
+
141
+ ### Added
142
+
143
+ - **`refs sync` now reports a package that arrived upstream, and `refs edit --create` registers
144
+ it.** The drift probe checked the packages the configuration already had, so a package added
145
+ upstream after `refs add` stayed invisible — and there was no command to register one either:
146
+ `refs add` refuses an already-tracked ref, and every `refs edit` mode needs an entry to edit. The
147
+ only instruction anyone could give was "hand-edit `config.toml`".
148
+
149
+ `refs sync` answers "did upstream gain a package?" from the range it just fetched, not by
150
+ comparing a scan against the configuration. That distinction is the whole design: a scan cannot
151
+ tell a package that just arrived from one the ref's owner deliberately never tracked, because
152
+ there is no inventory of what was there before — the fetch range is that inventory. A ref whose
153
+ owner tracks 3 packages out of 140 hears about the other 137 exactly never. `refs doctor` lists
154
+ every unregistered member instead, because it was asked to.
155
+
156
+ The question it asks of that range is about package NAMES, not manifest paths. A package
157
+ renamed in place modifies its manifest rather than adding one, and a package merely moved to
158
+ another directory adds one without being new — so a path-based reading is wrong in both
159
+ directions. Only the manifests the range actually changed have to be read out of history: an
160
+ untouched manifest is byte-identical at both ends, so the name it carries now is the name it
161
+ carried before.
162
+
163
+ The repair is a command now rather than a config fragment, with the ref key filled in so it
164
+ runs as printed:
165
+
166
+ ```
167
+ refs edit 'github.com/acme/alpha' --package '@acme/new' --create --path 'packages/new' \
168
+ --description "<what it is>"
169
+ ```
170
+
171
+ It is a distinct mode, not an upsert — an ordinary field edit naming an unregistered package
172
+ still fails with `not_found`, so a typo in `--package` can never become a new entry. The
173
+ finding carries `name` and `path`, both verified against the checkout and both shell-quoted
174
+ (being verified makes a value true, not shell-safe: `zPackagePath` permits `$()` and a manifest
175
+ `name` is checked only for being non-empty), and deliberately no description: a manifest description is untrusted third-party content, and copying it moves it
176
+ into a file refs later reads as its own configuration. The skill instructs agents to propose the
177
+ registration and wait for the user to agree, rather than run it on their own initiative.
178
+
179
+ ### Fixed
180
+
181
+ - **Workspace patterns are matched by `minimatch`, the matcher npm itself uses.** Hand-written
182
+ matching disagreed with the real resolvers in five distinct ways, each found only after the last
183
+ was fixed: extglob (`@(a|b)`) read as a literal directory name, trailing slashes treated
184
+ symmetrically where `minimatch` is asymmetric, repeated separators silently matching nothing, and
185
+ two more. Delegating removes that class of defect rather than the current instance of it.
186
+
187
+ Measured before choosing: `picomatch` disagrees with `minimatch` on ten of 154 comparisons over
188
+ the shapes this scanner supports, exactly on trailing slashes and repeated separators — so it is
189
+ not a drop-in. pnpm matches through `picomatch` but normalizes first, and was measured to agree
190
+ with `minimatch` on every one of those shapes, so one matcher covers both ecosystems.
191
+
192
+ Walking stays here: containment guards, the diagnostics that say why a scan came up short, and
193
+ the deliberate one-level depth policy are unchanged. `minimatch` answers only whether a path
194
+ matches a pattern. Nothing new is installed by `refs` users — the CLI publishes a bundle with no
195
+ dependencies — and that bundle grows by 24 KB.
196
+
197
+ One behaviour improves as a consequence: a negation in a shape this scanner cannot WALK
198
+ (`!packages/{a,b}`) is now applied, because applying an exclusion needs matching and never
199
+ walking. It used to be reported as unsupported and silently ignored.
200
+
201
+ - **Negated workspace patterns are applied instead of ignored.** `!packages/fixtures` was dropped
202
+ as an unsupported shape (a v1 simplification), so `refs add` registered packages the repository
203
+ had explicitly excluded, and every finding about a repository declaring one was silenced —
204
+ TanStack Query declares two, and all hundred of its packages came back `unverifiable`. Negations
205
+ are now expanded exactly like inclusive patterns and subtracted from the result, which makes the
206
+ scan an accurate statement of membership rather than an approximation of one.
207
+
208
+ A wildcard inside the last segment (`examples/vue/2*`) is supported too, since that is the shape
209
+ real repositories exclude by, and a negation nobody can expand costs every finding about the
210
+ repository rather than just the paths it names. Glob syntax that is still unimplemented —
211
+ `{a,b}`, `?`, `[…]` — now reports `unsupported_pattern` rather than reading as a literal
212
+ directory name and silently matching nothing.
213
+
214
+ - **A monorepo can now be resolved by the name in its own root manifest.** Workspace detection
215
+ expands the globs a repository declares, and a workspace root is not one of its own targets — so a
216
+ root that names itself was registered nowhere, and `refs resolve @acme/toolkit` came back empty
217
+ for a repository that was tracked all along. `refs add` now registers a named root at `path: "."`
218
+ alongside the workspace members.
219
+
220
+ Both pnpm and Yarn address a workspace root by that name (`pnpm --filter <root-name>`,
221
+ `yarn workspace <root-name>`); npm and Turborepo use a positional handle instead. Of eighteen
222
+ well-known monorepos surveyed, eight carry a name someone would plausibly use for the repository
223
+ and ten carry a throwaway like `root` or `monorepo-root` — which is what settles it: registering
224
+ the name costs nothing where it is a throwaway, since nobody resolves `"root"`, and answers the
225
+ question where it is not.
226
+
227
+ Two things this deliberately does not do. A repository that declares no workspaces is untouched:
228
+ `refs add`'s npm fallback owns that shape, and probing the root there would displace a locator it
229
+ did not choose — as it also would where a workspace declaration selects nothing, so the package
230
+ named in an `npm:<pkg>` source survives there too. And where a workspace member already claims the
231
+ root's name — `@remix-run/react-router` is a real example, in a repository that also publishes
232
+ `react-router` — the member wins and the root is simply not registered, which costs that
233
+ repository nothing it had before. That rule lives in detection itself rather than in `refs add`,
234
+ so relocation agrees with registration: a member that moves is still found uniquely, instead of
235
+ becoming ambiguous against a same-named root and leaving `resolve` with no path for a package
236
+ that is plainly there. And a root is never reported as a package's new location: its name is an
237
+ alias for the repository, so a member that upstream deletes is reported as gone rather than as
238
+ having moved to the repository root — which would have sent a caller to the wrong directory and
239
+ described a move that never happened.
240
+
241
+ Refs tracked before this change keep the package map they were given, and no command adds one
242
+ entry to an existing ref — `refs add` refuses a tracked ref, `refs edit --package` needs an entry
243
+ to edit. So `refs sync` and `refs doctor`'s `config-drift` check now report a root the
244
+ configuration does not register, with the entry to add — including the path registration would
245
+ actually use, which is the member's rather than the root's where a workspace member declares the
246
+ same name. That costs one manifest read per ref, and a workspace scan only where there is
247
+ something to report; it is asked only of refs that already register packages, since a plain
248
+ reference repository registers none on purpose and is left alone.
249
+
250
+ The root package takes the ref's own description when its manifest carries none, which is the
251
+ ordinary case for a private workspace root. That is not the per-package fallback `refs add`
252
+ otherwise refuses: the root is not a package beside the repository, it is that repository.
253
+
254
+ - **A failed lookup no longer reads as an absent repository.** `refs resolve` exits `4` when a query
255
+ matches nothing, and the message ended "run refs list, or add it: `refs add <url>`". That second
256
+ half is a guess: a query can miss every route while the repository is tracked perfectly well under
257
+ another identifier — a monorepo root whose own package name was never registered, for instance.
258
+ An agent read the suggestion as confirmation and told someone a repository they had tracked was
259
+ not tracked, then stopped.
260
+
261
+ The message now states what was searched and points at evidence rather than prescribing a fix, and
262
+ `--json` carries a `reason` on `resolve`'s routing misses: `unmatched_query` (nothing matched, by
263
+ any route), `package_not_registered` (the ref is tracked and registers no such package), or
264
+ `ref_not_registered` (a canonical git url named an absent ref — the one case where adding it is
265
+ the right answer, since only a canonical url establishes which ref was meant). There is deliberately no reason meaning "this repository does not exist", because nothing
266
+ refs can observe establishes that; and `reason` is absent on every other `not_found`, where its
267
+ absence means no narrowing is available rather than being a fourth value.
268
+
269
+ The skill's instruction changed with it. It used to say exit `4` means the ref is not tracked; it
270
+ now says exit `4` means the query matched no route, and requires a second lookup before any
271
+ conclusion. `refs resolve --ref <ref>`'s own miss also stopped suggesting a bare `refs show`,
272
+ which reports a package count and no names — it now suggests `--packages`, which actually shows
273
+ the map the reader was sent to inspect.
274
+
275
+ - **A stale-lock reclaim could delete a lock another process was using.** refs reclaims a lock left
276
+ behind by a crashed process. The check that decided a lock was abandoned and the removal that
277
+ acted on it were two separate steps, and in the gap between them the lock could legitimately
278
+ become somebody else's: the original holder releases, a waiting process takes the same path, and
279
+ the reclaim then deletes a lock that is actively in use. Both processes go on to `reset --hard`
280
+ the same checkout.
281
+
282
+ Three changes close it for processes running this version:
283
+
284
+ - **Only a process the operating system reports as gone is reclaimed from automatically.** A lock
285
+ whose lease has run out but whose process still answers is now reported rather than taken — a
286
+ live process can release at any instant, and that release is what opened the gap. Same for a
287
+ lock whose metadata never finished being written, or carries no usable identity.
288
+ - **The acquisition is re-identified after the death check.** Proving the recorded process gone is
289
+ not enough on its own: the metadata is read first and the process probed after, so the path can
290
+ change hands in between and the probe then answers about the departed owner. Re-reading the
291
+ identity immediately before the removal is what ties the two together.
292
+ - **The marker that stops two reclaims colliding no longer expires.** It used to be taken over
293
+ after two seconds, so it only excluded a reclaim fast enough to finish inside that window; a
294
+ suspended one lost its marker mid-work and a second reclaim started on the same lock. Age is
295
+ not evidence of abandonment.
296
+
297
+ The protocol's own markers also moved into `locks/.claims/` and `locks/.tombstones/`, which no
298
+ lock name can reach — lock names must start with a letter or digit. That removes a collision
299
+ where a repository literally named `foo.steal-claim` produced the marker path of the lock for
300
+ `foo`, and with it the name-shape guessing `refs doctor` needed to tell the two apart.
301
+
302
+ **What this costs.** Three situations no longer recover on their own and need one explicit
303
+ command, which `refs doctor` prints along with the condition for running it safely — stop every
304
+ refs process on that home first, suspended ones included: a crash after the operating system has
305
+ reused the process id, a crash before the lock finished writing its metadata, and a crash while a
306
+ reclaim was starting.
307
+
308
+ The messages changed to match. `refs doctor`'s `locks` check separates a lock refs will reclaim
309
+ by itself from one it will not, and the failure message when a lock cannot be acquired no longer
310
+ says "already reclaimable — retry" for a lock nothing will ever reclaim. It also stopped
311
+ describing the window as the thing that frees a lock: waiting does not, and only a recorded
312
+ process the operating system reports as gone does.
313
+
314
+ **What it does not fix.** A refs process running an _older_ version follows none of this and
315
+ reclaims on its own terms. And no lock protocol can help when refs is hard-killed while its `git`
316
+ child survives: the successor's lock is honest about the lock, not about the directory.
317
+
318
+ - **Two unrelated refs could share one lock.** The per-ref lock name replaced `/` with `_`, and `_`
319
+ is legal inside a ref key — so `github.com/acme_tools/widget` and `github.com/acme/tools_widget`
320
+ both derived `ref.github.com_acme_tools_widget`. The two then serialized against each other:
321
+ `sync` fans out four refs at a time and the loser failed on a lock conflict after the timeout,
322
+ `resolve`'s verification could block on a sync of a ref it has nothing to do with, and `doctor`'s
323
+ `config-drift` check reported the wrong ref as busy.
324
+
325
+ The name is now injective. A key containing no `_` encodes exactly as before, so it keeps the
326
+ lock name refs has always written for it; a key containing one moves into an escaped form under
327
+ `ref._`, where `_` becomes `_u` and `/` becomes `_s`. The two forms cannot collide, because a ref
328
+ key always starts with `[a-z0-9]` and so a plain name never begins `ref._`.
329
+
330
+ A lock name is one directory entry, so a key long enough to overflow one now falls back to a
331
+ digest under `ref.__` rather than failing `mkdir` with `ENAMETOOLONG`. The budget reserves room
332
+ for the sibling entries the steal protocol derives from a lock name — a name that could be
333
+ created but not renamed to its tombstone would strand an abandoned lock that nothing could then
334
+ reclaim, leaving the ref blocked until someone deleted the directory by hand. Both were already
335
+ true before this change, for keys past roughly 200 characters; reaching it needs a self-hosted
336
+ url, since no forge allows a path that long.
337
+
338
+ **One caveat if you run refs concurrently across an upgrade.** The lock name changes for a ref
339
+ whose key contains `_`, and for one long enough to reach the digest form — roughly 200
340
+ characters, either way. For such a ref, a process from any earlier release (the old scheme dates
341
+ to 0.1.1) derives a different name from a new one, so for the length of that overlap the two
342
+ would not exclude each other. The window is a mid-upgrade concurrent run on the same refs home;
343
+ if that is a situation you can be in, let running operations finish before upgrading. Every other
344
+ ref keeps its name and is unaffected.
9
345
 
10
346
  ## [0.11.0] - 2026-08-31
11
347
 
@@ -689,7 +1025,8 @@ trusted-publishing pipeline end to end.
689
1025
  installed git hooks.
690
1026
  - Agent skill (`skills/refs/`) documenting the investigate/add/maintain workflows.
691
1027
 
692
- [Unreleased]: https://github.com/kaisers-io/refs/compare/v0.11.0...HEAD
1028
+ [Unreleased]: https://github.com/kaisers-io/refs/compare/v0.12.0...HEAD
1029
+ [0.12.0]: https://github.com/kaisers-io/refs/compare/v0.11.0...v0.12.0
693
1030
  [0.11.0]: https://github.com/kaisers-io/refs/compare/v0.10.0...v0.11.0
694
1031
  [0.10.0]: https://github.com/kaisers-io/refs/compare/v0.9.0...v0.10.0
695
1032
  [0.9.0]: https://github.com/kaisers-io/refs/compare/v0.8.3...v0.9.0