codebase-snapshot 0.9.12 → 0.9.14

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
@@ -23,6 +23,385 @@ deprecated with an explanatory message instead. `latest` has pointed at 0.9.0 si
23
23
 
24
24
  ---
25
25
 
26
+ ## 0.9.14
27
+
28
+ A fifth audit, of what the tool does to text it did not write. Three of the defects below were
29
+ editing the user's code: a redaction rule erased 36 lines of source, a truncation left a code fence
30
+ open, and a preview budget did not apply to a file with CR line endings. The rest are credentials
31
+ left in clear text, two records that disagreed with the files beside them, and the performance work
32
+ the previous audits kept deferring.
33
+
34
+ Every item has a test that fails without it, with the exception of two performance changes that have
35
+ no behavioural signal — both are named.
36
+
37
+ ### A redaction rule erased source code
38
+
39
+ `PEM_BLOCK_PATTERN` is lazy over `[\s\S]`, which places no restriction on what may lie between a
40
+ `BEGIN` and the next `END`. A file holding an *unterminated* header — a truncated key, a fixture, a
41
+ half-written file — and, later, a properly terminated block had **everything between the two
42
+ headers replaced with blank lines**, including the source code that happened to sit there.
43
+
44
+ This repository did it to its own test suite: the security fixture's "unterminated key" test was
45
+ followed by another test whose key was terminated, and the generator bridged the two — 36 lines of
46
+ assertions, braces and the second test's declaration, replaced with 36 empty newlines.
47
+
48
+ The body may no longer contain another `-----BEGIN`. A header with no `END` before the next `BEGIN`
49
+ does not match as a block, and `PEM_UNTERMINATED_PATTERN` handles its header and its own base64 body,
50
+ which is what that pattern is for. The newline after the header is deliberately still not required:
51
+ a PEM inside a JSON or `.env` value is written with escaped `\n`, two characters rather than a line
52
+ break, and a rule that insisted on one would mask the header alone and leave the base64 in clear.
53
+
54
+ ### A truncated section left its code fence open
55
+
56
+ When the fixed metadata of a section would not fit the part budget, the *rendered* section was cut at
57
+ a byte offset. The cut lands in the body or in the opening fence, and the closing fence it removes is
58
+ the one that ends the block: every section after it was rendered inside this file's code block, and
59
+ `parsePartSections` merged the next file into this one's body.
60
+
61
+ The body now goes and the notice takes its place inside the block, with the file marked `preview`
62
+ first so the re-rendered `- Meta:` line describes what the reader will actually find. A section that
63
+ is a closed, empty, labelled block is readable; an unclosed fence corrupts the files after it, and no
64
+ block at all is invisible to the part reader.
65
+
66
+ ### A file with CR line endings was never previewed
67
+
68
+ `countLines` and `truncatePreview` split on `\n` alone, so a CR-only file — an old Mac file, anything
69
+ written by a tool that predates LF on that platform — counted as **one line**. `largeFileBytes`
70
+ therefore never fired: a 5 MB CR file went into a part whole, with its carriage returns intact, and
71
+ `manifest.json` reported `lines: 1` for a 40 000-line file. The redaction pass counted terminators
72
+ with `/\r\n|\n|\r/g`, so `reports/audit.md` reported line numbers the manifest disagreed with.
73
+
74
+ All four places now normalise CR, LF and CRLF, and `countLines` counts by scanning rather than
75
+ splitting — it runs on every file body and every part, and normalising first allocated a second copy
76
+ of a multi-megabyte string to answer a question about its terminators.
77
+
78
+ ### Four credential shapes were left in clear text
79
+
80
+ **`${VAR:-fallback}`, unquoted.** `POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-MyProdPassword123!}`
81
+ defeated every rule, and each refusal was individually reasonable: the quoted rules need a quote
82
+ after the separator, and the unquoted ones need the value to begin with a word character — a
83
+ fallback begins with `$`. That idiom is the norm in `.env` files, docker-compose, GitHub Actions and
84
+ shell scripts. A rule for the shape now masks the fallback and keeps the variable name.
85
+
86
+ **`readonly` and `declare` in shell files.** Those words introduce a binding in TypeScript and an
87
+ assignment in POSIX sh and `.envrc` — the same word, opposite meaning. The guard that suppresses
88
+ redaction for declarations therefore suppressed it for exactly the files most likely to hold a
89
+ credential. Bare `declare` is gone from the keyword list (the form that can initialise a binding in
90
+ TS is `declare const`, matched through `const`), and the guard now knows which files are assignments.
91
+
92
+ **A credential inside a string literal.** `startsInsideStringLiteral` rejected any match whose name
93
+ began immediately after a quote, which is right for a list of key names and wrong for every
94
+ connection string: `const dsn = "password=Secret;host=db"` and `curl -d "password=Secret"` were
95
+ emitted in clear. The test now also asks whether the literal closes between the name and the value.
96
+
97
+ **The MCP line cap.** `createLineReader` checked `MAX_LINE_BYTES` only on the buffer it had just
98
+ appended to, so a chunk carrying a discarded line's terminator *and* the start of the next message
99
+ left that remainder unmeasured until the stream ended — and `onEnd` delivered it whole to
100
+ `JSON.parse`. It also compared `.length`, which is UTF-16 code units: a line of astral characters
101
+ could be twice the ceiling in bytes and still pass. The cap is applied at every path that can
102
+ deliver, and measured in bytes.
103
+
104
+ ### `--with-dependents` lost an importer, and three languages had no import graph
105
+
106
+ The closure was computed from `file.summary.imports`, and a previewed or oversized file has no
107
+ summary. An importer over `largeFileBytes` was therefore dropped as unchanged while `validate` and
108
+ `explain`, which read the same file from disk, called it a dependent — generate and validate
109
+ disagreed about the snapshot generate had just written. Import specifiers are cheap to extract from
110
+ text already in hand, so they are now collected for files whose summary was refused, and the size
111
+ ceiling lives in one place so all three commands refuse the same files.
112
+
113
+ `resolveSpecifier` only understood JavaScript's `./x` shape, so the Python, Ruby, C/C++ and Dart
114
+ imports the profiles have been recording for several releases resolved to nothing. Each language
115
+ spells "this directory" differently — Python with dots, Ruby with `require_relative`, C with a
116
+ quoted include, Dart with nothing at all — and each is now resolved on its own terms. Package-rooted
117
+ languages (Go modules, Rust `crate::`, PHP PSR-4, JVM packages, Swift modules) stay unresolved on
118
+ purpose: their specifiers go through a manifest, and guessing a path would fabricate edges that
119
+ `validate` then has to disagree about.
120
+
121
+ ### `setup --format json` could corrupt its own output
122
+
123
+ `setup` replaced `process.stdout.write` with a capture and restored the function it had captured on
124
+ entry. A nested generate with an `async` hook installed the hook diversion inside that window, so
125
+ `setup`'s restore ran first and removed it — the hook's `console.log`, after its first `await`, went
126
+ straight to the real stdout — and the hook's restore then put back `setup`'s *finished* capture.
127
+ Every later write in the process was appended to a dead string and silently discarded.
128
+
129
+ Both borrow stdout and neither can own it, so there is now one router, installed at most once and
130
+ released by reference counting, consulting two `AsyncLocalStorage` scopes.
131
+
132
+ The same function also merged a failed nested generate in as `manifest`: `{ setup, manifest: { error } }`
133
+ came out with exit code 1 and no top-level `error`. The failure is now at the top level.
134
+
135
+ ### A transient lock on the old snapshot could cost the new one
136
+
137
+ `fs.rmSync(backupDir)` ran inside the `try` whose `catch` restores the previous snapshot. `rmSync`
138
+ throws EPERM or EBUSY on Windows and on network filesystems when an indexer, an antivirus or an
139
+ editor holds a handle in the old snapshot — and by then `outDir` already holds the new one. The catch
140
+ deleted the new snapshot, restored the old one and rethrew; the retry then failed with ENOENT
141
+ because the staging directory no longer existed. Removing the backup is no longer part of the
142
+ transaction.
143
+
144
+ ### Two runs could take two different lock files
145
+
146
+ `realpathSync(stagingRoot)` was used when the root existed and the unresolved path when it did not —
147
+ and the run creates the root *after* taking the lock. On macOS, where `/var` is a symlink to
148
+ `/private/var`, process 1 hashed `/var/folders/…/snapshot.tmp` and process 2, starting once the
149
+ directory existed, hashed `/private/var/…`: two locks, no mutual exclusion, two concurrent writers.
150
+ The key now resolves the nearest existing ancestor. Reclaiming a stale lock also re-checks the file's
151
+ identity immediately before unlinking it, so two runs that both find the same dead lock cannot
152
+ delete each other's.
153
+
154
+ ### The MCP worker ran the hook the docs say it does not
155
+
156
+ `docs/mcp.md` tells the reader that `hooks.afterGenerate` does not run for a generation started from
157
+ MCP. It did: the worker resolved the config file with its hooks intact, so an after-generate script
158
+ ran from inside an MCP tool call, and a hook still running when the worker posted its result kept
159
+ the thread alive for as long as the server did. The worker now empties `hooks` before resolving, and
160
+ is terminated as soon as the result is settled.
161
+
162
+ ### A byte-order mark in `package.json` broke four readers
163
+
164
+ `findConfigFile` reads `package.json` through `stripJsonComments`, which drops a BOM, so `generate`
165
+ loaded a BOM'd project happily. Every other reader used bare `JSON.parse`, which throws on `\uFEFF`,
166
+ and every one of them caught the throw and reported the ordinary, wrong answer: an existing
167
+ `"snapshot"` block was invisible, `"type": "module"` was invisible, the scripts and devDependency
168
+ were skipped with a note, and `nextStepHint` told the user to run `npx codebase-snapshot generate` —
169
+ which resolves the *published* package instead of the one the run had just installed. The
170
+ `npm create` shim had the same bug in `hasSnapshotScript`.
171
+
172
+ A BOM is what a Windows editor writes when it saves UTF-8. All five readers now parse the same way
173
+ the config loader does, and `init` writes the mark back rather than silently removing it.
174
+
175
+ ### `validate` could not name seven of the settings it compared
176
+
177
+ `maxPartTokens`, `maxMonolithBytes`, `skeleton`, `sarif`, `changedSince`, `staged` and
178
+ `withDependents` were recorded in the manifest and folded into the configuration digest, but never
179
+ joined the field-by-field block. The digest can say *that* something changed; only the per-field
180
+ comparisons say *what*. Toggling `--skeleton` produced the generic "the settings differ" line and
181
+ nothing else.
182
+
183
+ ### A `.vue` or `.astro` script block was always parsed as `.ts`
184
+
185
+ `<Button onClick={() => go()}>` is a type assertion followed by six parse errors to the TypeScript
186
+ grammar, and no component at all, for a perfectly valid Vue `lang="tsx"` block. The script kind now
187
+ follows the component: Astro frontmatter is JSX-capable by construction, and a Vue or Svelte block
188
+ says so with `lang="tsx"`.
189
+
190
+ ### Performance
191
+
192
+ - **The split loop re-rendered every part on every pass.** `partitionFilesForBuckets` renders all
193
+ groups, finds the first one over budget, splits it, and goes round again — up to 64 times. Each
194
+ pass re-assembled, re-token-estimated and SHA-256 hashed *every* part. A group's rendered size
195
+ depends on its sections, its index and the *width* of the numbering, and the width only changes
196
+ when the part count crosses 1 000, so sizes are now measured instead of rendered and cached per
197
+ group. Each part is hashed exactly once.
198
+ - **The skeleton pass parsed with `setParentNodes: true`** and walked every node a second time to set
199
+ `.parent`, which nothing reads. It also expanded empty bodies from `{}` to `{ /* ... */ }`, making
200
+ small files larger, and rewrote the text once per span — 400 functions copied a 200 KB file 400
201
+ times.
202
+ - **`fullContents` retained every file body on runs that wrote no monolith.** The map held a
203
+ reference to every pre-partition body and was read only inside `if (config.includeMonolith)`,
204
+ pinning the multi-megabyte originals that the partitioner had just replaced with previews.
205
+ - **31 regexes were recompiled for every file.** `scanAndRedactSecrets` called `toGlobal` per rule
206
+ per call, and it is called once per file: a 20 000-file scan built 620 000 identical objects.
207
+ - **`--watch` re-derived the whole repository for one changed file.** The derivation — redaction,
208
+ findings, skeleton, preview, summary — is a pure function of (bytes, settings, path), so it is now
209
+ cached and reused when the content hash and a settings digest both match. Validity is the **content
210
+ hash**, not `mtime`: a formatter or a `git checkout` can rewrite a file within the same timestamp
211
+ tick at the same length. Measured on a synthetic 250-file TypeScript repository: cold 520 ms, warm
212
+ 196 ms of wall clock (2.65x) — the remainder is reading, hashing, partitioning and writing, which
213
+ a per-file cache cannot remove. The suite asserts the *work* instead, in CPU time on a denser
214
+ fixture: 1 046 ms against 48 ms, 21x, against a 3x floor.
215
+
216
+ ### What has no test, and why
217
+
218
+ The last two are structural. The regex cache and the `fullContents` change have no behavioural
219
+ signal — the output is byte-identical by construction — so they are covered by the existing suite
220
+ and by the reasoning above rather than by a new test. The `--watch` cache is different: four of its
221
+ five tests are correctness guards that pass on the previous build (that is the requirement — the
222
+ cache must be invisible), and the fifth measures the CPU time the cache saves and fails without it.
223
+
224
+ ---
225
+
226
+ ## 0.9.13
227
+
228
+ A fourth audit, this time of the seams: places where two code paths answer the same question with
229
+ different rules, and places where a command accepts something it does not apply. Every item below
230
+ has a test that fails without it, except one — the per-layer budget overflow, which I could not
231
+ reproduce and which is called out honestly at the end.
232
+
233
+ ### A scoped snapshot could expire and still validate
234
+
235
+ `validate` re-resolves the recorded changed-files scope and checks that every file it *skipped* as
236
+ `unchanged` is still outside that scope. It never asked the mirror question. So:
237
+
238
+ ```bash
239
+ git add src/a.ts
240
+ codebase-snapshot generate --staged # snapshot holds src/a.ts
241
+ git reset HEAD src/a.ts # same bytes on disk, nothing staged
242
+ codebase-snapshot validate --staged # said: "Snapshot is valid and up to date"
243
+ ```
244
+
245
+ The file on disk is byte-identical, so the hash comparison passed — and the scope the snapshot was
246
+ taken over had silently become empty. A file that is in the snapshot and would not be if it were
247
+ generated now is drift, and it is now reported, with `--with-dependents` still allowing for the
248
+ one-hop importer exception.
249
+
250
+ ### A part name in `manifest.json` could reach any file on the host
251
+
252
+ `validate` has confined part filenames since 0.9.9, but the **MCP server** resolved
253
+ `parts[].filename` with a bare `path.resolve`, in `snapshot_read` and in the `snapshot://parts/{n}`
254
+ resource. A repository under review could commit a manifest naming `../../../.env`, and a client
255
+ would read it — resource reads are auto-approved as read-only. A part name is bare by construction,
256
+ so `describeManifestShapeProblem` now requires one, which makes all five readers (`validate`,
257
+ `report`, `diff`, `snapshot_read`, the resource) refuse the same manifest for the same stated
258
+ reason.
259
+
260
+ `snapshot_explain` had the mirror problem. `toRelativePath` resolved *after* its "is it absolute?"
261
+ test and `normalizeTarget` returned anything starting with `..` unchanged, so `../other-repo/.env`
262
+ was joined to the root and passed to `existsSync`: one read-only, auto-approved call per probe, for
263
+ any path on the machine. Out-of-root targets now answer `outside-root` without touching the
264
+ filesystem. A file named `..foo.ts` is no longer mistaken for a traversal.
265
+
266
+ ### Four credentials that looked like code
267
+
268
+ - `password: "${DB_PASSWORD:-hunter2}"` — the shell and Compose idiom for a **default** — was
269
+ exempted as "an interpolation" because it starts with `${` and ends with `}`. So was
270
+ `${prefix}-real_secret-${suffix}` and any value with a `??` fallback. Only a value that is one
271
+ interpolation of a plain (optionally dotted) reference is exempt now; anything else in the braces
272
+ settles it the other way.
273
+ - `export api_key=hunter2` — bare `export` was in `DECLARATION_KEYWORDS`, so a shell assignment was
274
+ read as a JavaScript binding whose initializer is an expression. `export` can never be followed by
275
+ a bare assignment in JS or TS (`export foo = 1` does not parse), so the word only ever suppressed
276
+ redaction for the files most likely to hold a secret.
277
+ - An **indented, unterminated** private key — `privateKey: |` in YAML, or an indented template
278
+ literal — leaked every body line. The rule required base64 at column 0 and knew nothing about the
279
+ RFC 1421 header lines (`Proc-Type`, `DEK-Info`) that an encrypted key puts after the marker.
280
+ - A masked key block in a **CR-only** file swallowed its line breaks, because pass 1 counted `\n`
281
+ only, so every reported line number after it was wrong.
282
+
283
+ `.github/workflows/deprecate.yml` and `unpublish.yml` interpolated `${{ inputs.… }}` straight into
284
+ `run:` in jobs that carry `NPM_TOKEN`. GitHub expands an expression into the script *before* bash
285
+ parses it, so a dispatch input of `"; curl … $NODE_AUTH_TOKEN #` ran as a command. Inputs now arrive
286
+ through `env:` and are referenced quoted.
287
+
288
+ ### Two enumerations, two answers
289
+
290
+ The same repository produced different snapshots depending on `--discovery`, which is the one
291
+ property a reproducible snapshot tool cannot have:
292
+
293
+ - A tracked **file symlink** was dropped as `symlink` in `git` mode — the loop tested
294
+ `depth <= parts.length`, which includes the candidate itself — and emitted as a file by the walk.
295
+ - A **broken** symlink was `symlink` in `git` mode, because `targetReal` started at the link's own
296
+ path and so always compared as "inside the root". It is `read-error`, or `outside-root` when its
297
+ written target leaves the tree, in both modes now.
298
+
299
+ The output directory's crash leftovers had the same shape: the backup and partial-swap directories
300
+ were recognised by the hardcoded prefix `snapshot.backup-`. With `outDir: ".context"` an interrupted
301
+ run left `.context.backup-<pid>-<ts>` — a copy of the previous snapshot — to be scanned back in as
302
+ user source. The names now come from the resolved output and staging directories. `--watch` also
303
+ matched only `.backup-`, and only the directory itself, so an event for `part-001.md` *inside* a
304
+ `.partial-` copy was treated as a source edit: on a cross-device swap that is an infinite
305
+ regeneration loop.
306
+
307
+ ### The index described files that were not there
308
+
309
+ - `summarizeMarkdown` called a leading `---` a front-matter block if *any* later line was `---`,
310
+ so a document opening with a horizontal rule and using another as a section divider lost every
311
+ heading between them. The block must now also *be* YAML — a heading, a fence or prose means the
312
+ `---` was a rule. `...` closes a YAML document as well as `---`.
313
+ - The JSONC reader in `src/ast/structured.ts` ended a `//` comment only at `\n` and never removed a
314
+ trailing comma, so a `.jsonc` or an editor-reformatted `tsconfig.json` summarised as
315
+ `invalid-json` with no keys. Both are JSONC rather than extensions of it.
316
+ - `summarizeTypeScriptSource` looked at `ImportDeclaration` and `ExportDeclaration` and nothing
317
+ else, so `require("./x")`, `import("./x")` and `import fs = require("fs")` produced no import
318
+ edge. A CommonJS project had no import graph at all, and `--with-dependents` had nothing to walk.
319
+ `export { a } from "./x"` was not recorded as a re-export either.
320
+
321
+ ### Flags that were accepted and not applied
322
+
323
+ - `--only-artifacts audit` in a project whose config says `artifacts: { audit: false }` disabled the
324
+ other six and left audit off too: nothing written, exit 0. The flag was forwarded only as its
325
+ complement, and a suppression list cannot switch anything on.
326
+ - `findConfigFile` parsed `package.json` with bare `JSON.parse` while the loader used the JSONC
327
+ reader, so a BOM or a trailing comma made the two disagree about whether the project was
328
+ configured. And `"snapshot": []` counted as this tool's configuration, which skipped the setup
329
+ question entirely.
330
+ - `--skeleton` covered `.ts`, `.tsx`, `.mts` and `.cts` while `ts.createSourceFile` parses `.js`,
331
+ `.jsx`, `.mjs` and `.cjs` natively — so the files most repositories are made of were the ones it
332
+ could not compress, and `.d.ts` files, which have no bodies, were the ones it did parse.
333
+ - Over MCP, `WorkspaceOverrides` accepted only `maxPartBytes` and `outDir`. `snapshot_validate`
334
+ re-derives the configuration and compares digests, so a `--skeleton` snapshot could not be
335
+ validated at all. `maxPartTokens`, `skeleton`, `sarif`, `changedSince`, `staged` and
336
+ `withDependents` are now accepted by `snapshot_plan`, `snapshot_generate`, `snapshot_validate`
337
+ and `snapshot_explain` — schema and application coming from one definition, so a tool cannot
338
+ advertise a flag it would ignore.
339
+ - `npm create codebase-snapshot -- --preset=nextjs` ignored the preset: the shim parsed `argv` with
340
+ `indexOf`, which finds nothing for `--flag=value`, treats a missing value as "not given", and
341
+ ignores `--forse` entirely. `node:util.parseArgs` in strict mode now rejects all three by name.
342
+
343
+ ### Configuration answers that disagreed with each other
344
+
345
+ - `loadConfigFileDetailed` did not attach `CONFIG_DIR_SYMBOL` — only `loadConfigFile` did — so
346
+ `resolveConfig((await loadConfigFileDetailed(dir)).config)` scanned `process.cwd()`.
347
+ - `normalizeResolvedConfig` re-derived `artifacts` with `includeMonolith` as the default, so
348
+ `{ ...resolved, includeMonolith: true }` produced a config claiming `includeMonolith: true`
349
+ beside `artifacts.monolith: false`, and `generate` — which reads the map — wrote no monolith.
350
+ - `configDigest` hashed `budgets` with `JSON.stringify`, which preserves insertion order, so
351
+ `{ docs, api }` and `{ api, docs }` produced different digests and `validate` reported drift for
352
+ a snapshot nobody changed.
353
+
354
+ ### Cost
355
+
356
+ - `pathScanRules` returned `[...DEFAULT_SECRET_PATTERNS, ...extraRules]` although
357
+ `config.secretRules` already begins with those patterns: every file path, skip record and AST
358
+ summary string ran all 30 built-in rules twice. `disableDefaultPatterns` also had no effect on
359
+ paths while it worked on content.
360
+ - `globToRegExp` and `matchGitignorePattern` compiled a fresh `RegExp` per call — over a million per
361
+ run on a 20 000-file repository with 55 patterns. Both are cached by pattern. A malformed
362
+ character class no longer escapes as a raw `SyntaxError`: a `.gitignore` range like `[z-a]`
363
+ matches nothing (which is what git's own `fnmatch` does), while the same range in a configured
364
+ glob is reported as the configuration mistake it is, naming the pattern.
365
+ - `generateSnapshotAsync` resolved the configuration twice, and `validate` in the CLI re-read and
366
+ re-parsed `manifest.json` to re-run a shape check the validator had already run.
367
+ - `pendingHooks` was drained only by `generateSnapshotAsync`, so a synchronous caller left one
368
+ settled promise per output directory for the life of the process.
369
+
370
+ ### Lifecycle
371
+
372
+ - Each `hooks.afterGenerate` installed its own `process.stdout.write` wrapper and restored it only
373
+ if the current write was still its own. With two overlapping async hooks the first to finish never
374
+ restored, and the second put the first one's wrapper back — permanently. There is now one scope
375
+ and one wrapper, released by reference counting.
376
+ - The update check used `request.setTimeout`, an *idle socket* timeout that resets on every chunk:
377
+ a trickling proxy kept the CLI alive indefinitely. It has a wall-clock deadline now, `.unref()`d
378
+ so a pending check never holds the process open. And `isNewer` stripped prerelease tags before
379
+ comparing, so `0.9.13-alpha.1` was recommended to everyone on a stable release and a stable
380
+ release was never announced to anyone on a beta.
381
+
382
+ ### Smaller
383
+
384
+ - `wrapOneLine` measured `room` in terminal cells and cut with `String.prototype.slice`, which
385
+ counts UTF-16 code units. CJK text produced rows twice as wide as the frame believed, and a
386
+ surrogate pair could be cut in half. The cut advances by code point now.
387
+ - Part numbering is decided **before** rendering, in one pass over every budget bucket, instead of
388
+ partitioning each budget and re-numbering the merged set afterwards.
389
+
390
+ ### What I could not reproduce
391
+
392
+ The audit reported that per-layer budgets caused parts to overflow their budget by seven to twenty
393
+ bytes when `renumberParts` rewrote their headers, and were then hard-clipped into a broken document.
394
+ **I could not make that happen.** I swept file sizes against a two-budget configuration and again
395
+ with six layers and eight kinds whose names are 180 characters long, and no part ever came out over
396
+ its budget, with or without the clamp. The arithmetic explains why: `partitionFiles` reserves about
397
+ 2.2 kB of header room up front and shrinks sections to `budget - reserve`, so a rendered part sits
398
+ roughly 1.4 kB below its ceiling, and the header growth being reported is fourteen bytes.
399
+
400
+ The mechanism was removed anyway — the numbering pass now happens before rendering, so a fitted part
401
+ cannot be rewritten — but the claim was not verified, and this changelog will not pretend it was.
402
+ The four tests added with it pass on the previous build too; they pin the invariants the refactor
403
+ must not break rather than reproducing a defect.
404
+
26
405
  ## 0.9.12
27
406
 
28
407
  A third audit, this time of what the snapshot *costs* as well as what it gets wrong. Two of the
package/README.md CHANGED
@@ -200,7 +200,7 @@ Everything an agent needs is available as data, and the tool can install its own
200
200
  ```bash
201
201
  npx codebase-snapshot describe --format json # contract: workflow, options, exit codes
202
202
  npx codebase-snapshot skill --install # into .claude/skills + .cursor/rules
203
- npx -y codebase-snapshot@0.9.12 mcp # or serve it over MCP
203
+ npx -y codebase-snapshot@0.9.14 mcp # or serve it over MCP
204
204
  ```
205
205
 
206
206
  An agent told only "use `npx codebase-snapshot`" can read the contract, follow its ordered