codebase-snapshot 0.0.0-stage → 0.9.1

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 ADDED
@@ -0,0 +1,1289 @@
1
+ # Changelog
2
+
3
+ All notable changes to `codebase-snapshot`.
4
+
5
+ ---
6
+
7
+ ## A note on version numbers
8
+
9
+ This package started its public life as `@ankurhalder/codebase-snapshot`, which reached 4.4.1. It
10
+ moved to the unscoped name and **restarted at 0.9.0**, so the numbers below are not a continuation of
11
+ the old line — `0.9.0` is newer than `4.4.1`, not older. 1.0.0 is reserved for when the CLI surface
12
+ is settled.
13
+
14
+ Two versions exist on the registry that are not releases, and both are deprecated:
15
+
16
+ | Version | What it is |
17
+ | :-- | :-- |
18
+ | `codebase-snapshot@4.5.0` | Published for about an hour under the new name before the scheme was reset to 0.9.0. Withdrawn, never the supported version. |
19
+ | `codebase-snapshot@0.0.0-stage` | npm's own staging placeholder, created automatically during the first publish of the name. Not a release. |
20
+
21
+ Deleting them requires account-level rights that the release pipeline does not have, so they are
22
+ deprecated with an explanatory message instead. `latest` has pointed at 0.9.0 since it existed.
23
+
24
+ ## 0.9.1
25
+
26
+ ### The documentation now derives its version from `package.json`
27
+
28
+ A documented version is a promise about the release, and every one of them was hand-written — so they
29
+ rotted silently at each bump. The README still described a `0.x` line whose numbers appeared nowhere
30
+ in it, `SECURITY.md` advertised **"4.x supported"** on a project whose latest release was 0.9.0, and
31
+ both `setup` examples printed `4.4.0` and `4.1.1` as the dependency the command adds.
32
+
33
+ Versions now live in exactly one place, and `scripts/sync-docs.mjs` writes them into the documentation:
34
+
35
+ - Prose carries marked spans — `<!--@majorMinor-->0.9<!--/@majorMinor-->` — which GitHub renders as
36
+ the value and hides as a comment.
37
+ - Code-block examples are matched by shape instead, because inside a fence an HTML comment is literal
38
+ text and the marker syntax would leak into the page.
39
+ - `npm run verify` fails when anything is out of step, and `npm run site` and `prepack` both sync
40
+ first, so the docs site and the published README cannot contradict the package they describe.
41
+
42
+ Changelog history is deliberately excluded. It records what happened at each version, and rewriting a
43
+ past entry would falsify that record rather than maintain it.
44
+
45
+ ### Other corrections in this pass
46
+
47
+ - `SECURITY.md` supported versions: 0.9.x, with a sentence explaining why 0.9.0 is newer than 4.4.1.
48
+ - The README states the pre-1.0 contract once, in wording that does not go stale.
49
+ - The changelog opens with a note on the version numbering, so the registry's three versions are not
50
+ a mystery to anyone reading them.
51
+ - The `v4.5.0` release and git tag were deleted; that version no longer exists on npm.
52
+
53
+ ---
54
+ ## 0.9.0
55
+
56
+ ### The package is now `codebase-snapshot`, unscoped
57
+
58
+ **Breaking:** published as `codebase-snapshot` rather than `@ankurhalder/codebase-snapshot`.
59
+
60
+ The old name is **deprecated, not unpublished**, so existing instructions keep working:
61
+
62
+ ```bash
63
+ npx @ankurhalder/codebase-snapshot generate # still resolves, now prints a deprecation notice
64
+ npx codebase-snapshot generate # the supported name from 0.9.0
65
+ ```
66
+
67
+ Why: npm's search index ranks on text match rather than downloads — a package with a few thousand
68
+ weekly downloads outranks one with a hundred million when the query matches its name better — so a
69
+ name that *is* the phrase people search is the strongest free signal available. Doing this at
70
+ version 0.9.0 with no dependents costs one migration; doing it after documentation, articles and
71
+ tutorials all point at the scoped name costs far more.
72
+
73
+ The rename is not cosmetic. The package name is written into every ownership marker and every
74
+ manifest, and the output-directory guard compares it exactly, so renaming it naively would have made
75
+ **every snapshot written by 1.0–4.4.x unrecognisable to its own tool** — the tool would have refused
76
+ to update its own output. Ownership now accepts the legacy spelling and rewrites it to the current
77
+ name on the next run, so an existing snapshot keeps working and is upgraded in place.
78
+
79
+ ### Metadata and discoverability
80
+
81
+ - `keywords` went from 9 to 20, chosen by measured position rather than intuition — phrases like
82
+ `codebase-to-prompt` and `repo-to-llm` that describe how people actually ask for this.
83
+ - The `description` now contains the word "codebase" and names the clients people search for.
84
+ - The README's 16 relative links became absolute. On npmjs.com — the most-linked page the package
85
+ has — every documentation link was resolving to a 404.
86
+ - The README states build provenance, which the release pipeline has been producing since 4.0.0
87
+ without ever mentioning.
88
+
89
+ ### Documentation site
90
+
91
+ The docs now have a crawlable home at <https://ankurhalder.github.io/codebase-snapshot/>, generated from
92
+ `README.md` and `docs/` in this repository, with a sitemap, its own `robots.txt`, an `llms.txt` at
93
+ the site root and a Markdown twin per page. GitHub's `robots.txt` blocks directory-tree and raw-file
94
+ paths and publishes no repository sitemap, so `docs/` had no crawlable entry point before this.
95
+
96
+ The GitHub wiki is closed. The same prose was being maintained in the wiki, `docs/` and the README
97
+ with no canonical copy; `Home.md` is now a single pointer and the other pages are one-line stubs so
98
+ inbound links keep resolving.
99
+
100
+ ### New guides
101
+
102
+ - [Feeding a codebase to ChatGPT, Claude or Cursor](docs/how-to-feed-a-repo-to-claude.md)
103
+ - [Budgeting LLM context](docs/how-to-context-budget.md)
104
+ - Comparisons with [repomix](docs/vs-repomix.md), [gitingest](docs/vs-gitingest.md) and
105
+ [code2prompt](docs/vs-code2prompt.md), each stating where the alternative is the better choice
106
+
107
+ ### Tooling
108
+
109
+ - `npm run seo-rank` measures this package's position in npm and GitHub search for a fixed query
110
+ set, appends to `seo-log.jsonl`, and `--check` fails when a query we hold slips. It runs weekly in
111
+ CI.
112
+ - `npm run site` renders the documentation site; `scripts/check-site-links.mjs` fails the deploy on
113
+ an unresolvable internal link.
114
+
115
+ ---
116
+
117
+ ## 4.4.1
118
+
119
+ ### `setup` ran the install with npm's own environment, which could refuse it
120
+
121
+ Running `setup` through `npx` — the documented way — put the parent npm's resolved configuration
122
+ into `npm_config_*` variables, and the nested `npm install` inherited them. With an npm that
123
+ supports an `allowScripts` switch the child aborted with
124
+ `EALLOWSCRIPTS: --allow-scripts is not allowed in project-scoped installs`, so the install was
125
+ skipped and only a warning said so. The nested run now gets the caller's environment minus
126
+ `npm_config_*` (and npm's lifecycle variables), so it reads the project's own `.npmrc` and flags —
127
+ which is the point of using the project's package manager at all. A shim-based test pins it: the
128
+ package manager is invoked, and none of npm's config reaches it.
129
+ ## 4.4.0
130
+
131
+ ### One command that does the whole first run
132
+
133
+ `npx @ankurhalder/codebase-snapshot setup` — detect the project, write the commented config (only
134
+ when the repository has none; an existing one is kept byte for byte and the output says so), add
135
+ the npm scripts, the devDependency and the `.gitignore` entries, install dependencies with the
136
+ package manager the repository's lockfile names (`bun`, `pnpm`, `yarn` or npm), then generate
137
+ through the normal `generate` command so every guard, warning and JSON field is the same one a
138
+ plain run produces.
139
+
140
+ - `--no-install` skips the install: CI, an offline machine, or a lockfile the project's own tooling
141
+ owns.
142
+ - A failed install is a warning, not a dead end — the snapshot is still generated, because the
143
+ scripts resolve the package through `npx` until it is installed.
144
+ - `--dry-run` prints the plan and the config it would write, and writes nothing.
145
+ - `--format json` emits one document: `{ "setup": { … }, "manifest": { … } }`.
146
+ - The ownership guard is untouched: an `outDir` holding files this tool did not write is still
147
+ refused (exit 1), and the refusal names the entries.
148
+
149
+ `init` keeps its old behaviour — it refuses when a config exists and needs `--force`, because
150
+ someone who typed `init` should be told. `setup` is the merge-friendly path.
151
+
152
+ ### An outDir that holds only empty directories is replaced
153
+
154
+ Reported from a real repository: `generate` refused with "it holds no .codebase-snapshot marker
155
+ (found parts, reports) — remove the directory yourself" for a directory that held **no files**, just
156
+ `parts/` and `reports/` from an interrupted run. Replacing a tree with no files destroys nothing, so
157
+ the guard now treats it as empty. The walk is iterative, a symlink counts as content, and a
158
+ directory holding even one file is still refused without a marker.
159
+ ## 4.3.0
160
+
161
+ ### Node 18 and 20 actually run the suite now — and one of them was broken
162
+
163
+ `engines` has said `>=18`, and until this release no run had ever executed on 18 or 20: the publish
164
+ workflow ran `npm run verify` on one Node version only. The gate now runs on 18, 20 and 22 for every
165
+ release, and the first honest run found a real defect — see the next section. Verified locally on
166
+ 18.20.4 and 20.19.4: 498 tests, 493 pass, 0 fail, 5 version-dependent skips.
167
+
168
+ ### `validate` called an unreadable file "missing from disk" on Node 18
169
+
170
+ A Windows `Deny-Read` ACE makes Node 18's `statSync` throw `EPERM` and `existsSync` answer `false`,
171
+ so a file the user could not read looked *deleted*: `validate` reported "no longer exists on disk"
172
+ for a file sitting right there. The validator now asks why a path cannot be stat'd (`ENOENT` means
173
+ gone, `EPERM`/`EACCES` means unreadable) instead of trusting a boolean, and both drift classes are
174
+ deduplicated — an unreadable file is seen by the scan loop and the manifest loop.
175
+
176
+ ### A base64-wrapped private key is detected
177
+
178
+ `LS0tLS1CRUdJTi…` is the base64 of `-----BEGIN `, so a wrapped key is found by its prefix without
179
+ decoding anything. The new `base64-private-key` rule takes the built-in set to 30; archives and
180
+ other encodings remain out of scope, and `SECURITY.md` now says which is which.
181
+
182
+ ### A dependency manifest is data, not documentation
183
+
184
+ `requirements.txt` and its variants were classified kind `doc` because they end in `.txt`. They are
185
+ machine input, like `package.json`, so they are `data` now.
186
+
187
+ ### The monolith explains the one field it cannot carry
188
+
189
+ `full-codebase.json` embeds a copy of the manifest, and a file cannot record its own sha256. The
190
+ embedded copy keeps `artifacts.fullJson: null` and carries a `selfReference` sentence pointing at
191
+ `manifest.json`, which holds the real byteSize and digest, instead of leaving the reader to wonder
192
+ why the two manifests disagree.
193
+
194
+ ### Empty and trailing-blank line accounting is documented
195
+
196
+ `includedLines` counts content lines after CRLF normalisation and without a trailing empty element:
197
+ an empty file records `0` while the fence shows one empty line. That is the intended meaning of the
198
+ field, and `docs/output.md` now states it rather than leaving it to be rediscovered.
199
+
200
+ ### The audit release
201
+
202
+ Every other change here came out of a full audit of the tool against eight real repositories and an
203
+ adversarial review of every module. The three defects that mattered most were all in the two
204
+ promises this package leads with — "never leak secrets" and "machine-readable output".
205
+
206
+ ### Two ways a secret kept its tail
207
+
208
+ A quoted credential containing an escaped quote was masked only up to the backslash:
209
+ `JSON.parse('{"password": "p\"ssw0rd"})` was emitted as
210
+ `"[REDACTED: potential secret-like value omitted from snapshot]"ssw0rd"` — most of the value in
211
+ clear, and the line left syntactically broken. An unquoted value stopped at the first character
212
+ outside `[A-Za-z0-9_+=/-]`, so a percent-encoded token kept its tail:
213
+ `?access_token=abc%2Edef&scope=read` lost `abc` and shipped `%2Edef`. Both value classes now
214
+ describe the value they are masking — escaped pairs are consumed by the quoted rule, and `%`,
215
+ `:` and `~` are token characters — and the mask replaces the whole value.
216
+
217
+ ### A compressed fixture was emitted as mojibake, at twice its own size
218
+
219
+ `.br` was missing from `BINARY_EXTENSIONS`, so ripgrep's 186-byte `sherlock.br` failed UTF-8
220
+ decoding instead of being recognised as binary: it was emitted `full` as 341 bytes containing 78
221
+ U+FFFD — the one case where the artifact is larger than its source and says nothing. `.br` (and
222
+ `zst`, `lz4`, `lzma`) are now in the list, and a second guard catches the next unknown format:
223
+ at least 32 undecodable bytes **and** at least a third of the file means binary. The bar is high
224
+ on purpose — a Latin-1 document is legitimately full of invalid UTF-8 bytes and must stay
225
+ `utf-8-lossy`.
226
+
227
+ ### A config that exported nothing ran on the built-in defaults
228
+
229
+ `export default null;` was erased by a nullish fallback (`imported.default ?? imported`), so the
230
+ module namespace stood in for the config, every setting was ignored, and the run generated with
231
+ the defaults at exit 0. The loader now distinguishes "no default export" from "the default
232
+ export is null" and refuses with the same message every other wrong export gets.
233
+
234
+ ### An unknown setting was dropped in silence
235
+
236
+ `{ maxPartByte: 4096 }` generated with the default budget at exit 0 — the typo was invisible, and
237
+ it is the silent fallback this project forbids. A config file (module, JSON or `.snapshotrc`)
238
+ now rejects any key that is not a setting, naming it and pointing at the generated config as the
239
+ list of what exists.
240
+
241
+ ### `--format json` produced nothing when the arguments were wrong
242
+
243
+ Every usage error — unknown flag, unknown command, bad value, missing `--cwd`, wrong arity —
244
+ exited 2 with **0 bytes** on stdout, while the docs promised one parseable document per JSON-mode
245
+ invocation. The format is now read from raw argv before validation, so the error document is
246
+ emitted for usage errors too; the human line stays on stderr.
247
+
248
+ ### An async hook could append to the JSON payload
249
+
250
+ The stdout redirect that keeps `console.log` inside a hook out of the payload wrapped only the
251
+ synchronous call. A hook that logged after an `await` appended its text after the JSON, so
252
+ `--format json` output was no longer parseable while the command still exited 0. The redirect now
253
+ lives until the returned promise settles, and the payload is written to the real stdout exactly
254
+ once.
255
+
256
+ ### Truncation was invisible in the audit
257
+
258
+ `reports/audit.md` printed "✅ No files exceeded the large file budget" while a third of a
259
+ document was missing from the snapshot, because the cut came from `maxPartBytes` rather than
260
+ `largeFileBytes`. It is structural for the `docs` preset, whose 100 KB part budget sits *below*
261
+ the 200 KB large-file threshold. Section 3 now reports truncation from either cause, with bytes,
262
+ lines and a `Cut by` column, and `generate` warns when `maxPartBytes < largeFileBytes`.
263
+
264
+ ### A part-budget cut carried no marker
265
+
266
+ When the part budget was the binding constraint the block simply stopped — no
267
+ `[N lines omitted]` anywhere, and the manifest said `truncated: true` while the artifact read as
268
+ complete. Every budget-forced cut now carries a marker naming the budget
269
+ (`… [19940 lines omitted: part budget 1887 B] …`), recomputed correctly when a second shrink pass
270
+ runs.
271
+
272
+ ### The preview tail window lost lines on newline-terminated files
273
+
274
+ `content.split("\n")` yields a trailing empty element, which consumed a tail slot: a 10-line file
275
+ at `previewHeadLines: 2`, `previewTailLines: 2` emitted `line 1`, `line 2`, an omission marker and
276
+ **no tail at all**, while `includedLines` claimed one more line than the block carried. The tail
277
+ window and the omission count now describe what is emitted.
278
+
279
+ ### A filename could inject a link into `reports/navigation.md`
280
+
281
+ The link label escaped `|` and control characters but not brackets, so a file named `b](b).ts`
282
+ produced `[src/b](b).ts](…)` — a live Markdown link to `b` — and `x](/evil).ts` linked to `/evil`.
283
+ The label is escaped; the link target and anchor are unchanged.
284
+
285
+ ### A deep directory tree crashed the run
286
+
287
+ The project-tree walker recursed per directory, so a ~6,000-level chain overflowed the stack after
288
+ 76 s and wrote **no snapshot**. The walk is iterative, the output order is unchanged, and the tree
289
+ is capped at 512 levels with a marker and a warning.
290
+
291
+ ### One oversized text file aborted the whole run
292
+
293
+ A file over ~512 MiB of *text* threw `Cannot create a string longer than 0x1fffffe8 characters`
294
+ from inside the run, with no `skipped[]` record; the equivalent *binary* file was skipped
295
+ correctly. A decode failure is now a per-file `read-error` skip and the run continues.
296
+
297
+ ### `validate`, `report` and `diff` now agree on what a snapshot is
298
+
299
+ `validate` called a manifest "valid and up to date" with `fingerprint` deleted, `report` printed
300
+ `undefined`, and `diff` rejected the same file. One shared guard now requires `generatedAt`,
301
+ `schemaVersion`, a 64-character lowercase `fingerprint`, `counts`, `totals`, `layers` and the
302
+ `files`/`skipped`/`parts` arrays, and all three commands use it.
303
+
304
+ ### `diff` notices a schema or a parts change
305
+
306
+ A copy with `schemaVersion: "9.9.9"`, a deleted `parts` array, or a changed part digest was
307
+ reported as `+0 -0 ~0 =N unchanged`. `diff` now reports a schema mismatch as a difference and
308
+ compares part metadata by filename.
309
+
310
+ ### `--discovery walk` now honours `.gitignore` escapes
311
+
312
+ `\#hashfile.ts`, `\!bangfile.ts` and `spaced\ .ts` were all *included* in walk mode while git
313
+ ignored them — the parser trimmed and sniffed first characters, and the matcher normalised `\` to
314
+ `/`. The parser follows gitignore(5)'s line grammar and an escape-aware matcher handles patterns
315
+ containing a backslash; plain `!` negation is unchanged.
316
+
317
+ ### `init` writes the repository's real branch
318
+
319
+ The generated config always said `branch: "main"`, so on any repository whose default branch is
320
+ `master` or `vNext` the first `validate` printed a branch-mismatch warning the user never caused.
321
+ It now records the branch `getGitInfo` reports and falls back to `"main"` only when git cannot
322
+ report one.
323
+
324
+ ### The contract publishes `check` and `--mode`, and stops over-promising `--option=value`
325
+
326
+ `check` is documented as an alias of `validate`, the hidden `--mode` selector is in
327
+ `describe.options` with its values and contradictions, and the help sentence now says
328
+ "value-taking options" — booleans never accepted `=true`.
329
+
330
+ ### Documentation
331
+
332
+ The complete list of corrections is in the wiki and `docs/`: option counts, the removed `agents`
333
+ skill target, `--tmp-dir` (a config key, not a flag), the `--watch` message, the shipped README's
334
+ 404 links (`CHANGELOG.md` and `SECURITY.md` now ship; the rest are absolute URLs), and
335
+ measurement-backed statements for determinism, text-mode output streams, monolith byte counts and
336
+ ignore granularity.
337
+
338
+ ### The generated config was a lint warning in every Next.js project
339
+
340
+ `init` wrote `export default { … }`. `import/no-anonymous-default-export`, which
341
+ `eslint-config-next` enables by default, reports that inline form — so in the framework this tool
342
+ detects most often, the one file we tell people to keep and edit arrived flagged the moment it was
343
+ created. The object is now assigned to `config` and exported by name:
344
+
345
+ ```js
346
+ const config = { /* every setting */ };
347
+
348
+ export default config;
349
+ ```
350
+
351
+ Both forms load identically; only one is quiet. The reason is in the file itself, so deleting the
352
+ comment does not invite the warning back.
353
+
354
+ ### A clean repository reported `Warnings: 156`
355
+
356
+ Every masked line was appended to `manifest.warnings` as `<rule> at <path>:<line>`, so a
357
+ repository with nothing wrong with it reported 156 "warnings" — the first thing a new user sees
358
+ after `init` + `generate`, and the reason a 583-file project looked broken. Worse, the summary
359
+ printed ten of them under that count with nothing to say the other 146 existed.
360
+
361
+ Redaction is not a warning: a masked value is the tool doing its job, while a warning says the
362
+ snapshot may surprise you. The two are now separate — the summary prints
363
+ `Redacted: N line(s) — values masked; see reports/audit.md` and keeps `Warnings:` for the things
364
+ worth acting on, and a warnings list longer than the ten-row preview ends with
365
+ `… and N more in manifest.warnings[]`. When `reports/audit.md` is disabled the manifest says the
366
+ per-line detail was not written instead of losing it silently. `manifest.totals.redactedLines`
367
+ carries the count for machines.
368
+
369
+ ### One line of code was counted twice
370
+
371
+ `scanAndRedactSecrets` pushed a finding per (line, rule) pair, so an unquoted
372
+ `JWT_SECRET=<value>` — which matches both `credential-assignment-unquoted` and
373
+ `env-secret-assignment` — appeared twice in `reports/audit.md` on the same file and line and
374
+ inflated the total, making a 78-site repository report 156 findings. A finding is now one *line*
375
+ with `rules[]` naming every rule that matched it; the audit table has one row per line and a
376
+ `Rules` column that lists them.
377
+
378
+ ### Artifact counts followed the generating machine's locale
379
+
380
+ `reports/audit.md` and the snapshot `README.md` called `toLocaleString()` with no locale, so the
381
+ same repository reported `1,24,976` lines on an `en-IN` machine and `124,976` on an `en-US` one —
382
+ different bytes from the same commit, which defeats diffing two snapshots or caching one. Both
383
+ generators now share `formatNumber()` with the CLI, which already pinned `"en-US"`.
384
+
385
+ ## 4.1.1
386
+
387
+ ### `init` left the package undeclared, so nothing reached `node_modules`
388
+
389
+ `init` wrote `npx @ankurhalder/codebase-snapshot generate` into `package.json` and no dependency
390
+ at all. `npm install` therefore installed nothing, every `npm run snapshot` resolved `@latest`
391
+ from the registry instead of a pinned copy, and the `npm install && npm run snapshot` it printed
392
+ as the next step could not have installed it. `init` now records
393
+ `devDependencies["@ankurhalder/codebase-snapshot"]` in the same single write as the scripts, and
394
+ leaves a range that is already declared — including a deliberate pin — untouched.
395
+
396
+ ### `--minimal` wrote a config that documented nothing
397
+
398
+ The flag wrote only the settings that differed from the defaults, so a Next.js project received
399
+
400
+ ```js
401
+ export default { preset: "nextjs" };
402
+ ```
403
+
404
+ — 179 bytes listing none of the twenty-six settings, in the one file a user opens to change
405
+ them. The flag is gone: `init` always writes the complete commented config, and `describe` no
406
+ longer advertises an option the parser rejects.
407
+
408
+ ### `skill --install` wrote a tool's documentation into the project's `AGENTS.md`
409
+
410
+ `AGENTS.md` is a repository's own agent contract — build commands, conventions, house rules —
411
+ not a registry for third-party skills. The install created it when missing, which meant a file
412
+ whose name claims the repository documents itself to agents, holding only this tool's text; and
413
+ it merged a marked section into it when present, editing a tracked file on the user's behalf.
414
+ It now writes only the two registries keyed by skill name —
415
+ `.claude/skills/codebase-snapshot/SKILL.md` and `.cursor/rules/codebase-snapshot.mdc` — and
416
+ appends exactly what it *creates* to `.gitignore`, so an install no longer turns a one-line
417
+ command into a decision about which untracked files to commit. A file that already existed is
418
+ never ignored, and a section left behind by 4.1.0 is reported rather than rewritten. To put the
419
+ skill in `AGENTS.md`, print it and paste it: `codebase-snapshot skill >> AGENTS.md`.
420
+
421
+ `init` also stops printing the tip that led there: an install's whole footprint is now the
422
+ config, `package.json` and `.gitignore`.
423
+
424
+ ### A file's recorded bytes and lines described the file, not the block
425
+
426
+ `includedBytes` counted the final newline that the closing fence swallows — 6 290 of 6 344 files in
427
+ a real repository were off by exactly one byte — and a CRLF checkout, which is written as LF, was
428
+ over-counted by one byte per line. A bounded preview reported `head + tail + 1` lines, one more than
429
+ its block carried. Both numbers are now computed once, from the body after normalisation and after
430
+ the closing newline is dropped, and an invariant test compares every `### FILE:` block in every part
431
+ against the manifest entry.
432
+
433
+ ### A large file was parsed for a summary the snapshot could not carry
434
+
435
+ A file above `largeFileBytes` is emitted as a head/tail excerpt, yet it was still parsed into an AST
436
+ and had every identifier masked so a whole-file summary could be attached — a summary describing
437
+ lines no artifact contains, which the partitioner then discarded. On a 1.9 MB source file that was
438
+ 70% of the wall clock: 1.8 s / 283 MB peak RSS became 0.5 s / 105 MB. The manifest names every
439
+ skipped preview in one grouped warning; `summaryOmitted` is deliberately not reused for them,
440
+ because it means "dropped for budget" and the part prints that reason.
441
+
442
+ ### `--format json` wrote nothing at all when a command failed
443
+
444
+ Progress goes to stderr and payloads to stdout, which meant a failed JSON-mode run produced an empty
445
+ stdout: an agent had to notice the empty string, then parse stderr prose and guess. Every failure now
446
+ writes one parseable document — `{ "error": { "message": …, "exitCode": … } }` — while the human line
447
+ stays on stderr and the exit code is unchanged. A successful run never carries an `error` key.
448
+
449
+ ### Smaller corrections
450
+
451
+ - Masking no longer walks left through a URL path: `https://example.com/v1/<key>` was emitted as
452
+ `https://example.[REDACTED]`, contradicting "only the matched span is masked".
453
+ - `explain` answers a file inside an ignored directory with the record its directory carries,
454
+ naming the ancestor, instead of the weaker `not-discovered`.
455
+ - `package.json#snapshot` as an array is reported like any other wrong type instead of being
456
+ silently ignored, and an unreadable config file names itself in the error.
457
+ - `init` no longer fails with a raw `EISDIR` when `.gitignore` is a directory — after the config had
458
+ already been written. The `.gitignore` line is a convenience, so it is a note, and `init` finishes.
459
+ - `--format`'s scope is the same machine-readable eight-command list as every other option, and a
460
+ test now fails if any option's scope regresses to prose or names a command that does not exist.
461
+ - The hook documentation says what "not awaited" does not mean: Node keeps the process alive while
462
+ the hook's own timers and sockets are outstanding, so `afterGenerate` should stay short.
463
+ - The generators agree with each other: `full-codebase.md` escapes control and bidi characters like
464
+ every other artifact, the snapshot `README.md` links only to reports that were actually written,
465
+ and a layer or kind name containing `|` or a backtick can no longer break the table or the inline
466
+ code span it is listed in.
467
+ - The wizard no longer treats `package.json` `"snapshot": []` as an existing configuration.- The README and `llms.txt` describe what 4.1.0 actually guarantees — the eleven skip reasons, the
468
+ `encoding` field, discovery modes, the emitted-byte accounting — instead of the feature list from
469
+ before the release.
470
+
471
+ ---
472
+
473
+ ## 4.1.0
474
+
475
+ ### A UTF-16 source file was invisible, and a Latin-1 one was quietly mangled
476
+
477
+ Two encoding decisions were silent, and one was wrong in both directions. `generate` decided
478
+ "binary" from a NUL in the first 8 MB and then decoded with `buffer.toString("utf8")`, so a UTF-16
479
+ source file — which is half NUL bytes — was skipped as `binary` and vanished from the snapshot,
480
+ while a Latin-1 file was emitted with U+FFFD in place of every non-ASCII byte, and `validate` then
481
+ reported the snapshot as current because the hash covers the raw bytes.
482
+
483
+ Decoding is now explicit. A BOM, or the NUL padding of a BOM-less UTF-16 file, is recognised and
484
+ the file is transcoded; a file that is not valid UTF-8 is kept, decoded lossily, and labelled
485
+ `encoding: "utf-8-lossy"` with the count of undecodable bytes in `warnings`; a NUL anywhere in the
486
+ file — including past the 8 MB head scan, which used to be the end of the check — marks it binary
487
+ and `skipped[]` records the offset. `files[].encoding` is present only when a file was not plain
488
+ UTF-8, so its absence keeps meaning "clean UTF-8". `explain` answers the same question the same
489
+ way. Verified: 12 new tests covering both endiannesses, BOM-less detection, five classes of
490
+ invalid sequence, a NUL at byte 8388608, and hash stability across two runs.
491
+
492
+ ### A file the tool did not write could be deleted without a word
493
+
494
+ Ownership checking had a hole with a nice name. `.gitkeep` and `.gitignore` were treated as
495
+ placeholders and subtracted from the set of entries belonging to somebody else, so a snapshot
496
+ directory this generator owned and that a user then added a `.gitignore` to was replaced — atomically,
497
+ exit 0, `manifest.warnings` empty — and the file was gone. A directory holding nothing but
498
+ `.gitkeep`, or nothing but a `.gitignore` (`*`, `!.gitignore`), was replaced without any ownership
499
+ evidence at all, because the placeholder shortcut fired before the marker was ever read.
500
+
501
+ Both are files a user creates; the generator never writes either into an output directory. They are
502
+ foreign entries now, so the run refuses and names them, exactly as it already did for `notes.md`.
503
+ `.DS_Store` remains the only tolerated entry, because Finder writes it and it carries no intent.
504
+ Verified: 6 new tests — owned directory plus `.gitignore`, bare `.gitkeep`, bare `.gitignore`, the
505
+ refusal naming the entry, `.DS_Store` still discarded, and a genuine snapshot still replaced.
506
+
507
+ ### A directory named `snapshot` disappeared, and the manifest said nothing
508
+
509
+ The output directory was excluded from the scan by name, at every depth: `snapshot`,
510
+ `snapshot.tmp` and `snapshot.backup-*` were dropped before the branch that records a `skipped[]`
511
+ entry, so a directory the user committed under one of those names was missing from the snapshot
512
+ with no record anywhere — the exact omission `skipped[]` exists to prevent, and it contradicted
513
+ the documented promise that every discovered path is accounted for. `generate` and `explain` also
514
+ disagreed: one dropped the path, the other called it `not-discovered`.
515
+
516
+ Exclusion is now evidence-based. A directory is this tool's output when it carries the
517
+ `.codebase-snapshot` marker, and only the exact `outDir`/`tmpDir` paths a run is about to write are
518
+ excluded by name — so a first run still does not record its own output (two identical runs keep
519
+ producing identical manifests) while a user's directory with the same name is scanned like any
520
+ other. `snapshot` and `snapshot.tmp` also left the built-in ignore list, where they had been
521
+ second-layer protection for the same mistake. Verified: 5 new tests, including the same fixture
522
+ through the git and the walking discovery paths, a full generate that now lists
523
+ `snapshot/keep.md`, and the determinism guard that the second run does not record the first.
524
+
525
+ ### A hook could kill a run that had already succeeded
526
+
527
+ `hooks.afterGenerate` ran after the snapshot was swapped into place. A hook that returned a
528
+ rejected promise was never awaited and never caught, so Node turned it into an unhandled rejection:
529
+ the process exited 1 *after* printing "Snapshot generated … Integrity: passed" and writing its
530
+ `--format json` payload, and nothing about the failure reached `manifest.json`. A hook's own files
531
+ were also missing from the ownership inventory, so the next run refused a directory the tool had
532
+ written itself, and a `console.log` inside a hook landed on stdout in the middle of the JSON
533
+ payload.
534
+
535
+ A returned promise is now tracked rather than awaited — this is a synchronous API — and its
536
+ rejection is recorded as a warning on the manifest and in the marker, which are re-shipped when it
537
+ lands. The marker is written after the hook for the same reason it now includes the hook's files.
538
+ stdout is redirected to stderr for the duration of the hook and restored afterwards, so the payload
539
+ channel stays pure. Verified: 6 end-to-end tests through the built CLI, including a rejected async
540
+ hook that must exit 0 with a parseable payload and a recorded warning.
541
+
542
+ ### `validate` would read any file the manifest named
543
+
544
+ A manifest is an input, and `validate` treated it as trustworthy. It joined `parts[].filename`
545
+ onto `snapshot/parts` with `path.join`, which accepts `../../../some/file` and absolute paths, so a
546
+ manifest naming a file outside the snapshot made `validate` read that file and then report its byte
547
+ size — a read primitive, with a size oracle for the file it named, reachable by anyone who can
548
+ write a manifest. Part names are bare names by construction, so a name that does not stay inside
549
+ `snapshot/parts` is now a drift error and is never read. `explain`'s text form also re-spells
550
+ control and bidi characters in paths, layer names, kind names and part names, because a committed
551
+ `snapshot.config.*` can put a U+202E in a layer name and the terminal renders it.
552
+
553
+ Verified: 5 new tests — a traversal name, an absolute name, the byte-size oracle specifically
554
+ proven closed, a legitimate snapshot still current, and control characters escaped while accented
555
+ text passes through untouched.
556
+
557
+ ### Also in this release
558
+
559
+ - **`--watch` reloads a module config.** Node's ESM loader caches by URL, so `snapshot.config.ts`
560
+ (exactly what `init` writes) was never re-read: an edited config was ignored and a broken one
561
+ was invisible while the run printed `passed`. The loader now evicts the CJS cache and imports
562
+ with a per-load token. 6 tests.
563
+ - **`--watch` never drops an edit.** A change during a run, a change inside the post-run quiet
564
+ window, and a source directory whose name matched the outDir basename were all lost silently
565
+ while the manifest kept reporting `Integrity: passed`. 6 tests plus live repros.
566
+ - **JWTs are redacted.** Only three-segment JWTs with long signatures were masked; two-segment,
567
+ padded, empty-signature and short-signature forms reached the parts verbatim. 8 tests.
568
+ - **Display hygiene.** Control, C1 and bidi characters in file and layer names are escaped wherever
569
+ a path is displayed (parts, tree, navigation, audit, explain), raw bytes are kept in
570
+ `manifest.json` and file bodies, and the snapshot README and part header say that contents are
571
+ untrusted repository data. 12 tests.
572
+ - **The hint after `init` is runnable.** It printed `npm run snapshot` with no `package.json`, and
573
+ a bare package name on re-runs. 4 tests. The scaffolder now records the tool in
574
+ `devDependencies` at its own version instead of resolving it from the registry on first run.
575
+ 7 tests.
576
+ - **The linter ignores prose.** Content rules matched comments, so documenting why `console` is
577
+ banned failed the lint that bans it.
578
+
579
+ ### `validate` and `report` could be talked into saying the state was fine
580
+
581
+ Four ways the state checks answered confidently and wrongly.
582
+
583
+ A run killed mid-write left the output directory holding a marker and no manifest, and `validate`
584
+ answered "run generate first" for ever — an instruction that could never resolve, because
585
+ generation had already run and failed. The marker records the state, the run id and the start time,
586
+ so the message now names them and says whether to wait or regenerate.
587
+
588
+ A manifest with no `config.digest` fell back to comparing a handful of fields, so a changed
589
+ `maxMonolithBytes`, `layers`, `kinds` or custom secret rule passed as "valid and up to date". A
590
+ configuration that cannot be verified is not a match: the absence of the digest is itself reported
591
+ as drift.
592
+
593
+ A file that existed but could not be read was reported as "no longer exist on disk", sending the
594
+ reader to look for a file that was right there. Unreadable and deleted are separate findings now,
595
+ and the summary says which.
596
+
597
+ `report` printed whatever numbers the manifest contained — `-5 files included`,
598
+ `Total lines: 1.5`, `Total size: Infinity MB` — and both `report` and `validate` accepted
599
+ `fingerprint: "12345"`. Counts must be non-negative integers and a fingerprint must be a sha256
600
+ digest, or the file is refused as not being a snapshot manifest.
601
+
602
+ Verified: 17 new tests across `tests/snapshot-incomplete.test.js`,
603
+ `tests/validate-drift-classes.test.js` and the guards suite, including a real Windows ACL deny for
604
+ the unreadable case and a control that an untouched snapshot still reports no drift at all.
605
+
606
+ ### `validate` called a binary file a new file
607
+
608
+ `generate` decides "binary" twice: from the extension and the 8 MB head scan, and again on the
609
+ bytes it reads, where a control-byte payload or a NUL past the head scan only reveals itself.
610
+ `validate` re-evaluated skipped paths with the cheap pre-read question alone, so a file `generate`
611
+ had just skipped came back as `new file` and the first `validate` after a `generate` failed on an
612
+ untouched tree. Both commands decide the same way now.
613
+
614
+ ### `--discovery` chooses how files are found
615
+
616
+ A `.git` directory that exists but cannot be listed made discovery refuse, and there was no
617
+ override — the only way forward was to repair the repository or copy the tree out of it. The
618
+ refusal also justified itself with a gap that no longer exists (a walk does honour nested
619
+ `.gitignore` files now), so it names the differences that remain: `info/exclude`, the global
620
+ excludes file, tracked files that a `.gitignore` now matches, and a submodule as one entry.
621
+
622
+ `--discovery auto|git|walk` (config `discovery`) makes that choice explicit. `auto` keeps the old
623
+ behaviour, `git` requires git, `walk` scans the tree and reads `.gitignore` directly. The mode is
624
+ validated, scoped to the commands that scan, published in the contract, written to the init
625
+ template, and recorded in `manifest.config`, so changing it is config drift. 10 tests.
626
+
627
+ ### A workspace is classified by what its packages are
628
+
629
+ Detection read only the repository root, so a Next.js app under `apps/web` in a pnpm workspace
630
+ detected as `monorepo` and a docs repository whose markdown lived under `docs/` detected as
631
+ `generic` — both contradicting the published preset table. Detection now probes a bounded number
632
+ of workspace packages and markdown directories, so a workspace is classified by the frameworks its
633
+ packages use. The bounds keep it honest: a bare `apps/` without a workspace declaration does not
634
+ trigger it, and a framework two levels down still detects as `monorepo`. 8 tests.
635
+
636
+ ### `skill --install` could write outside the repository
637
+
638
+ The installer built its three targets from `--cwd` and wrote them without checking where they
639
+ landed, so a pre-existing junction (`.claude` itself, or `.claude/skills/codebase-snapshot`) or a
640
+ hard-linked `AGENTS.md` made it write outside the repository. Targets are resolved and refused
641
+ when they leave it. The section merge also repairs malformed marker layouts — duplicated blocks,
642
+ an inverted pair, a missing end marker — instead of appending another one, while a well-formed
643
+ file stays byte-identical on a rerun. 11 tests.
644
+
645
+ ### `diff` saw no change when the payload changed
646
+
647
+ `diff` compared `relativePath` plus the source hash, so two snapshots of identical sources with
648
+ different `--max-part-bytes` or `--large-file-bytes` — a 4 KB preview against a 36 KB full body,
649
+ 275 KB of parts against 307 KB — reported "0 added / 0 removed / 0 changed". The per-file answer
650
+ stays the same, and a payload block now says the configuration differs and why (the config digest,
651
+ the generator version, part and layer totals), which is what "what changed in the context we hand
652
+ to the model?" was asking. 5 tests.
653
+
654
+ ---
655
+
656
+ ## 4.0.9
657
+
658
+ ### The tool executed a config it had been told to ignore
659
+
660
+ `generate --watch --no-config` printed "config file ignored" and then reloaded the repository
661
+ config on every regeneration, running its side effects and applying its include filter (4 tracked
662
+ files became 1). The watch loop now honours the flag.
663
+
664
+ `diff` also loaded the repository config, so comparing two manifests executed repository code —
665
+ and `--no-config` was rejected for that command, leaving no way to opt out. `diff` reads two
666
+ manifests and nothing else now; it never loads a config, and the flag is accepted there for
667
+ consistency. Verified: a config that writes a file on import no longer does so during `diff`, and
668
+ still does during `generate`.
669
+
670
+ ### Accepted risk: the ownership marker is evidence, not authentication
671
+
672
+ A hand-written marker plus a stub manifest with a matching digest can still pass the ownership
673
+ check, because there is no secret involved — anyone who can write those two files can also delete
674
+ the directory directly. The check exists to stop the tool from deleting a directory *it* did not
675
+ create, not to resist an attacker with write access. `SECURITY.md` now says so.
676
+
677
+ ---
678
+
679
+ ## 4.0.8
680
+
681
+ ### A dry run could write and delete
682
+ `--dry-run` printed "nothing was written" and then ran `hooks.afterGenerate`. A hook can write
683
+ files anywhere and delete parts of the live snapshot — one did exactly that during a dry run. The
684
+ hook no longer runs when nothing is written.
685
+
686
+ ### Two runs to one output directory both claimed success
687
+ Runs were serialised on `tmpDir` only, so two invocations with different `tmpDir`s but the same
688
+ `outDir` each took their own lock, both exited 0, and one snapshot was silently lost. Both keys are
689
+ locked now, in a fixed order so two runs cannot deadlock: one succeeds, the other refuses.
690
+
691
+ ### A git failure no longer changes what gets scanned
692
+ When `git ls-files` failed (corrupt index, git missing) the scan fell back to a directory walk,
693
+ which honours only the root `.gitignore` and ignores nested ones — a gitignored secrets file
694
+ entered `files[]` with no warning, and the snapshot looked clean. A git checkout whose files git
695
+ cannot list is now an error: fix the repository rather than silently scanning something different.
696
+ ---
697
+
698
+ ## 4.0.7
699
+
700
+ Wave-2 findings: nine independent critics, one dimension each.
701
+
702
+ ### Credentials
703
+ - **A branch name is user input.** `git checkout -b "release2/<ghp_…>"` copied the token raw into
704
+ `manifest.git.branch`, the README and the audit report — a channel that never passed through
705
+ redaction. It is masked now.
706
+ - **Azure had no rules.** DevOps PATs, AD client secrets (`…8Q~…`) and SAS `sig=` values reached
707
+ parts and both monoliths verbatim. Added.
708
+ - **Missing prefixes** in otherwise exhaustive alternations: `ABIA` for AWS bearer keys, `glrt-`
709
+ (GitLab runner), `hvb.` (Vault batch), `1//0…` (GCP refresh), `sk_test_` (Stripe test).
710
+ - **`exclude` in a config** is the flag's spelling of `excludePatterns` and used to be ignored
711
+ silently, so a directory the user excluded was still emitted. It is accepted as an alias now.
712
+
713
+ ### The documented programmatic flow scanned the wrong project
714
+ `resolveConfig(await loadConfigFile(dir))` ignored `dir` and generated into `process.cwd()`, exit 0.
715
+ The loaded config now carries the directory it came from (non-enumerable), so the documented flow
716
+ scans the project the config describes.
717
+
718
+ ### Small lies in the CLI
719
+ - `--mode` was discarded when a command word was present: `generate --mode validate` wrote a
720
+ snapshot. A contradiction is now exit 2, and an unknown mode is an error either way.
721
+ - `--only-artifacts monolith` wrote nothing but the marker, because the artifact was off. Naming
722
+ the monolith now enables it.
723
+ - `init` wrote `"snapshot": "codebase-snapshot generate"` into `package.json` without installing the
724
+ package, so the very next line it printed (`npm run snapshot`) failed with "not recognized".
725
+ The scripts are `npx @ankurhalder/codebase-snapshot …` now, which works either way.
726
+ ---
727
+
728
+ ## 4.0.6
729
+
730
+ ### A NUL byte past the first 8 KB
731
+ Binary detection sampled 8 KB, so a file with a NUL at byte 9000 was emitted as text into a part.
732
+ The whole file is scanned now, up to 8 MB, in 64 KB chunks. Verified: a 20 KB file with one NUL at
733
+ offset 9000 is skipped as `binary`.
734
+
735
+ ### Layer and kind names are masked wherever they are derived
736
+ Masking `config.layers` was not enough: the *derived* values were raw — `manifest.layers` keys,
737
+ `files[].layer`, part headers, the README parts table, the audit layer table, navigation, report and
738
+ `config.budgets.byLayer` keys. Eleven sinks, one root. The name is now masked where it is assigned,
739
+ so every derived field inherits the mask. Verified with a `ghp_…` layer name: zero occurrences in
740
+ any artifact.
741
+ ---
742
+
743
+ ## 4.0.5
744
+
745
+ ### `validate` now notices every configuration change
746
+
747
+ It compared a handful of settings field by field and silently ignored the rest: `layers`, `kinds`,
748
+ `secrets.customPatterns`, `disableDefaultPatterns`, `maxMonolithBytes`. With a config that is not
749
+ itself in the snapshot (`include: ["src/**"]`), changing classification or redaction rules still
750
+ produced "✅ valid and up to date" on a stale snapshot — the "config changes are drift" promise
751
+ was false for exactly the rules that decide what goes in.
752
+
753
+ The manifest now records a digest of the effective settings, computed from the raw values, and
754
+ `validate` compares it. That covers every setting, including the ones no field comparison lists,
755
+ and it works where masked values cannot: two different credential-shaped exclude patterns both
756
+ mask to the same placeholder, so a masked comparison would call them equal.
757
+
758
+ ```
759
+ $ validate # same config
760
+ ✅ Snapshot is valid and up to date with disk.
761
+
762
+ $ validate # after adding a layer rule
763
+ ❌ config: configuration: the settings differ from the ones this snapshot was generated with (run generate)
764
+ ```
765
+
766
+ ### Still open, recorded rather than hidden
767
+ 100k files take about 67 s (I/O bound); parallel runs with different `outDir`s can still hit the
768
+ staging lock and exit 1 instead of waiting; `--watch`'s quiet window can defer an edit made during
769
+ a run; layer and kind names supplied in the config are still emitted raw in derived fields; binary
770
+ detection samples the first 8 KB; the monolith is built before `--max-monolith-bytes` rejects it.
771
+ ---
772
+
773
+ ## 4.0.4
774
+
775
+ Five defects found by driving the published package from another agent's environment.
776
+
777
+ ### A stale shim exited 0 without doing anything
778
+ `dist/cli.js` exported `main()` and never called it. `dist/bin.js` is the shipped entry point, but a
779
+ `node_modules/.bin` shim left by an earlier install can point at `cli.js`, which then imported, did
780
+ nothing and exited 0 — indistinguishable from a successful run with empty output. `cli.js` now runs
781
+ itself when it is the entry file, and only then.
782
+
783
+ ### Redaction was breaking the parser
784
+ Structural summaries were built from the **redacted** text, so masking an identifier such as
785
+ `const secret = previousSecrets[i]` produced source the TypeScript parser could not read: 969
786
+ "parse errors" on a codebase that has none, and incomplete summaries everywhere. The parser now
787
+ reads the original source, and every string that leaves a summary — exports, variables, functions,
788
+ classes, headings, JSON keys — is masked before it is written. Structure stays correct; nothing raw
789
+ reaches an artifact.
790
+
791
+ ### Audit diagnostics
792
+ `reports/audit.md` gained a **Parse & AST Diagnostics** section listing every file whose summary
793
+ was incomplete, with its parse-error count. The CLI warned about these; the artifact an auditor
794
+ reads did not.
795
+
796
+ ### Formatting
797
+ - Summary lines no longer mix separators (`snapshot\parts` instead of `snapshot\parts/`).
798
+ - Line counts use `en-US` grouping rather than the host locale, so a CI runner and a developer
799
+ machine print the same number.
800
+
801
+ ### Known gaps, not fixed here
802
+ `validate` does not compare `layers`, `kinds` or `secrets.customPatterns`, so changing those does
803
+ not register as drift; a changed credential-shaped value compares equal because both sides mask;
804
+ layer and kind **names** from your config are still emitted raw in derived fields; the ownership
805
+ marker can record a hook-written file whose name contains a token; and binary detection samples the
806
+ first 8 KB only.
807
+ ---
808
+
809
+ ## 4.0.3
810
+
811
+ ### A snapshot could fail its own `validate`
812
+
813
+ 4.0.2 masks config values in the manifest, which is right — an `excludePatterns` entry or an
814
+ `include` glob can contain a credential-shaped string. But `validate` compared that masked value
815
+ against the **raw** current config, so a repository that generated cleanly failed validation one
816
+ second later, and the mismatch line printed the very token the mask was hiding.
817
+
818
+ Both sides are now masked before comparing, and the mismatch text uses the masked form. Verified:
819
+ `generate` then `validate` with `excludePatterns: ["ghp_…"]` exits 0, and the token appears in no
820
+ file under `snapshot/`.
821
+
822
+ Known and unchanged: a layer or kind **name** supplied in the config is still emitted raw in the
823
+ derived fields (`manifest.layers` keys, `files[].layer`, part headers). It is your own string
824
+ rather than a discovered secret, but it is the same class and will be fixed with the next batch.
825
+ ---
826
+
827
+ ## 4.0.2
828
+
829
+ ### The last place a path secret could reach an artifact
830
+
831
+ 4.0.1 masked the path field of every skip record. It did not mask the **detail** beside it, and
832
+ that text names the link target or the exclude pattern: a symlink to `real-ghp_…` and an
833
+ `excludePatterns: ["ghp_…"]` both printed the token in `manifest.skipped[].detail`, the audit
834
+ table and `explain`. Config values recorded for drift detection (`include`, `excludePatterns`,
835
+ layer and kind names) had the same problem. All of them now go through the same rules.
836
+
837
+ ### `--watch` idles again
838
+
839
+ After 4.0.1's staging changes the watcher fed itself: 15 regenerations in 8 seconds with no edits,
840
+ because Windows reports its own writes with paths the segment filter did not recognise. The
841
+ watcher now also stays quiet for a short window after each run. Measured: one regeneration in nine
842
+ idle seconds, and an edit still triggers exactly one more.
843
+
844
+ ### Honest answers where there used to be silence
845
+
846
+ - `loadConfigFile()` returned `null` for a config that existed but could not be parsed, so
847
+ `resolveConfig(await loadConfigFile(cwd))` — the documented example — silently generated on
848
+ defaults. It now throws, and `loadConfigFileDetailed` already reported the reason.
849
+ - Custom secret rules are no longer stored in a module-level variable: a `collectFiles` call for
850
+ one project applied the previous project's rules. Every caller passes its own.
851
+ - `describe` lists `.snapshotrc.json` before `.snapshotrc`, matching the real precedence.
852
+
853
+ ### Measured this round
854
+
855
+ 100k files: 56.6 s (was 336.4 s in 4.0.0), 20k: 11.8 s, 100k `validate`: 50.3 s, 100k `report`:
856
+ 1.28 s, 50 MB single TypeScript file: 1.33 s. About 60% of the remaining time is Windows file I/O
857
+ — read, stat and open — not parsing or redaction.
858
+ ---
859
+
860
+ ## 4.0.1
861
+
862
+ ### A credential in a file name no longer reaches the snapshot
863
+
864
+ A path that contained a token was masked only when the file was skipped *for that reason*. If it
865
+ was skipped earlier — as a credential-bearing file (`certs/AKIA….key`), by `.gitignore`, by a user
866
+ exclude or by the built-in ignore list — the raw path was written into `manifest.skipped[].path`
867
+ and the audit table, and a binary file whose name carried a token still reached the manifest as
868
+ `binary`. Custom `secrets.customPatterns` were not applied to paths at all, so a file named
869
+ `src/CORP-<32>.ts` was *included* and the token landed in the parts, the navigation index and the
870
+ project tree — the payload written to be pasted into a model.
871
+
872
+ Path masking now runs over every skip record regardless of reason, and the included-file check
873
+ uses the default **and** custom rules, so a custom-rule hit becomes a `sensitive-path` skip with a
874
+ masked path. A tested fixture with all four shapes produces zero occurrences of either token in
875
+ any file under `snapshot/`.
876
+
877
+ ### `report` no longer re-scans the repository
878
+
879
+ `report` printed an integrity verdict by calling the validator, which re-reads every file: 4.5 s
880
+ on a 20 000-file repository to print a summary already on disk. It now reads `manifest.json` and
881
+ nothing else — **0.54 s** on the same repository — and says plainly that drift is `validate`'s job.
882
+ ---
883
+
884
+ ## 4.0.0
885
+
886
+ ### `snap` is back
887
+
888
+ 3.0.0 removed the `snap` alias after I misdiagnosed why `npx @ankurhalder/codebase-snapshot`
889
+ printed nothing. **The removal was unnecessary and this release reverses it** — both
890
+ `codebase-snapshot` and `snap` are published again.
891
+
892
+ The real cause, confirmed by reading npm's own resolver
893
+ (`libnpmexec/lib/get-bin-from-manifest.js`): npm resolves a package from its `bin` entries, and
894
+ when every entry points at the same file it treats them as aliases and resolves one. Two
895
+ aliases were always fine. What actually broke the bare command was a **stale global install on
896
+ the test machine** — a leftover `npm link` back to the development checkout, which made `npx`
897
+ run that instead of the published package. A clean profile runs the same command correctly, with
898
+ either one or two aliases.
899
+
900
+ If you ever hit a silent exit 0:
901
+
902
+ ```bash
903
+ npm ls -g --depth=0 | grep codebase-snapshot
904
+ npm rm -g @ankurhalder/codebase-snapshot
905
+ ```
906
+
907
+ ### Security: the redaction layer was rebuilt
908
+
909
+ Independent adversarial review found credentials reaching `parts/*.md` while `audit.md` reported
910
+ them as redacted. That is the worst failure this tool can have — its output is written to be
911
+ pasted into a model — so the layer was rewritten rather than patched.
912
+
913
+ Leaks that were real and are now closed, each with a regression test:
914
+
915
+ - **CRLF checkouts.** Line-oriented rules ended in `$`, and a `\r` before the newline stopped the
916
+ match, so `export TOKEN=…` in a Windows checkout printed in the clear while the byte-identical
917
+ LF file was redacted. Scanning now splits on `\r\n`, `\n` and `\r` and preserves line numbers.
918
+ - **Trailing comments and trailing spaces.** `export TOKEN=abc # rotate quarterly` leaked because
919
+ the value had to be the last thing on the line. The rule now matches the value, not the tail.
920
+ - **camelCase keys and short values.** `awsSecret`, `dbPassword: "hunter2"` and similar were never
921
+ matched because only snake_case spellings were. Credential **names** now drive matching, and the
922
+ value is masked regardless of length.
923
+ - **Unquoted YAML / docker-compose.** `POSTGRES_PASSWORD: hunter2` is now covered, while
924
+ expressions (`password: req.body.password`, `getToken()`, `process.env.SECRET`) are explicitly
925
+ not.
926
+ - **Sensitive file names.** `.env~`, `.env.swp`, `.npmrc ` (trailing space), `.vault-token`,
927
+ `prod.env`, `secrets.toml` and friends are denied; deny-list matching normalises the name
928
+ instead of anchoring on the exact spelling.
929
+ - **Secrets in a path.** A filename containing a token is skipped as `sensitive-path` with the
930
+ path span-masked, instead of being printed in the tree, the navigation index and the manifest.
931
+ - **Authorization headers, and nine vendor prefixes** (`glpat-`, `hvs.`, `s.`, `xoxc/xoxd`,
932
+ `whsec_`, `dckr_pat_`, `hf_`, `ya29.`) are now detected.
933
+
934
+ Masking is **span-only**: `dbPassword: "[REDACTED…]"` keeps the key and the rest of the line. That
935
+ also fixed the opposite defect — over-redaction that corrupted ordinary code (`id-token: write`,
936
+ `{ pass: 3 }`, and an unrelated string on a line that also contained a credential) is now
937
+ explicitly negative-tested.
938
+
939
+ The built-in rule count is now **29** (was 21).
940
+
941
+ **Known limitations, stated rather than hidden** (see [SECURITY.md](SECURITY.md)): detection is
942
+ pattern-based, so a secret deliberately split across two string literals is not caught, and
943
+ unusual credential shapes can still be missed. Do not treat a clean audit as a guarantee.
944
+
945
+ ### The ownership marker is now evidence, not a filename
946
+
947
+ `generate` deletes the previous snapshot, and it decides whether it is allowed to by checking
948
+ whether it wrote that directory. The check was forgeable: an empty `.codebase-snapshot` file, or a
949
+ three-key `manifest.json`, was enough to make the tool delete a directory full of someone else's
950
+ files.
951
+
952
+ `.codebase-snapshot` is now a JSON record — generator name, schema version, a random run id, a
953
+ state, a digest of the manifest and an inventory of everything written. Replacement requires the
954
+ marker to be complete and every file in the directory to be in that inventory; anything else
955
+ blocks the run and is named in the message. The same evidence rule now applies to `tmpDir`, which
956
+ used to be deleted wholesale. Concurrent runs take a per-`outDir` lock, so four simultaneous
957
+ `generate` invocations leave one complete snapshot rather than a manifest describing parts that
958
+ are gone.
959
+
960
+ ### Correctness
961
+
962
+ - **JSONC comments in strings.** A valid `package.json` containing `//` anywhere in a string
963
+ (`"build": "tsc && echo done//ok"`) made every command fail with "the config could not be
964
+ read". The stripper is now a state machine that respects strings and escapes.
965
+ - **Configs that are not objects.** `export default Promise.resolve({…})` was silently treated as
966
+ an empty config; a thenable, array or primitive is now a hard error naming what it received.
967
+ - **Wrong-typed settings.** `excludePatterns: "src/**"` and `previewHeadLines: 0` were silently
968
+ ignored. Every setting is now validated, and the error names the setting, the expected shape and
969
+ what arrived.
970
+ - **`layers` rejected the documented shape.** `{ name: "api", match: … }` — the shape the type
971
+ declares and the generated config recommends — crashed `generate` with a `TypeError`. Both
972
+ `layers` and `kinds` are validated up front, `name` is canonical for layers with `layer` as an
973
+ alias, and a bad rule is an error naming the index rather than a crash.
974
+ - **`preset: "detected"`**, which `describe` published as the default, was rejected as an unknown
975
+ preset. `detected` and `auto` are now accepted.
976
+ - **Budget precedence.** `--max-part-bytes` lost to `budgets.default`, so the banner said "Part
977
+ budget: 4 KB" while 151 KB parts were written. The order is now CLI > config `maxPartBytes` >
978
+ `budgets.default` > 300 KB, and `budgets.byLayer` is capped by the chosen budget.
979
+ - **The output directory was scanned into the next snapshot.** `--out-dir .context` produced a
980
+ snapshot that included its own previous manifest, so the fingerprint changed on every run and
981
+ `validate` failed immediately after `generate`. The resolved `outDir`/`tmpDir` are excluded as
982
+ tool-owned paths, silently and identically on the first run.
983
+ - **The monolith contained previews.** `full-codebase.md` was supposed to be the whole repository
984
+ in one file, but a 400 KB file contributed about 3 KB — its head/tail preview. The monolith now
985
+ carries full redacted bodies; `largeFileBytes` governs the parts only.
986
+ - **A failing hook disappeared.** `hooks.afterGenerate` throwing printed a warning, but the
987
+ manifest had already been written, so the warning was not in it. The manifest is now rewritten
988
+ with the final warning list (the hook still runs once).
989
+ - **`--out-dir` with a missing parent** failed with a raw `ENOENT` from the rename; parents are
990
+ now created, an ancestor that is a file is refused cleanly, and a failed run no longer leaves
991
+ `snapshot.tmp` behind.
992
+ - **`validate` exempted skipped files forever.** A file skipped as binary, then rewritten as
993
+ source, was never reported as new. Skips are re-evaluated.
994
+ - **`--dry-run` skipped the output guard**, claiming it would write into a directory the real run
995
+ refuses. A dry run now runs the same guard and exits the same way.
996
+ - **`diff` reported `before undefined undefined file(s)` and exited 0** on a manifest that was not
997
+ a snapshot. Both sides are validated and the malformed one is named.
998
+ - **`reactComponents` listed plain classes.** A component now requires JSX/`createElement` or a
999
+ React base class.
1000
+ - **A filename could escape a code fence.** A file named ``` ``` ``` closed the `## Files` fence
1001
+ and let the next filename be rendered as live markdown — a prompt-injection vector in a file
1002
+ built for LLMs. Fences are now chosen from their content.
1003
+ - **`--watch` never reloaded the config.** Editing the config while watching appeared to work
1004
+ while the old settings stayed in force; a broken config regenerated silently instead of
1005
+ reporting the error.
1006
+ - **`reports/audit.md` said "Redacted Content Bytes"** when it meant emitted bytes. Renamed
1007
+ "Emitted Content Bytes".
1008
+
1009
+ ### CLI honesty
1010
+
1011
+ - **Options that do not apply to a command are now rejected** (exit 2) instead of being parsed and
1012
+ ignored: `--watch` is generate-only, `--dry-run` is generate/init, config and size options do
1013
+ not apply to `diff`/`describe`/`skill`. The published `appliesTo` column is now generated from
1014
+ the same table the parser enforces, so the contract cannot drift from the CLI again.
1015
+ - **`--no-config`** ignores `snapshot.config.*` and runs on the built-in defaults — useful in a
1016
+ checkout you do not trust, because a `.ts`/`.js`/`.mjs`/`.cjs` config is imported and executed.
1017
+ - **`explain` always exits 0** when it can answer; the verdict is `included`/`reason`. Exit 1 now
1018
+ only means the command could not run.
1019
+ - **`skill --format json`** emits JSON instead of markdown.
1020
+ - **`init` writes `includeMonolith`** (the file now lists every setting, as it claims), honours
1021
+ `--out-dir`, refuses to silently override a `package.json#snapshot` config, and no longer says
1022
+ `Next: npm run snapshot` in a directory with no `package.json`.
1023
+
1024
+ ### Upgrading from 3.0.0
1025
+
1026
+ - **Old snapshots are refused, not replaced.** A snapshot written by 3.0.0 carries a marker in the
1027
+ old format, so `generate` will stop and ask you to remove the directory or point `--out-dir`
1028
+ somewhere else. This is deliberate: the old marker could not prove the directory was ours.
1029
+ - `explain` no longer exits 1 for a file that would be skipped.
1030
+ - Attention-only flags (`--watch` on `validate`, for example) are now usage errors.
1031
+ - With `--include-monolith`, the monolith is much larger, because it now actually contains the
1032
+ code.
1033
+
1034
+ ### Documentation and packaging
1035
+
1036
+ Every document was rewritten against the current behaviour rather than patched:
1037
+
1038
+ - **New** [docs/cli.md](docs/cli.md) — every command, every option with its default and which
1039
+ commands it applies to, exit codes, output streams, worked examples.
1040
+ - **New** [docs/output.md](docs/output.md) — the snapshot layout and the complete
1041
+ `manifest.json` schema, field by field, including how to read `skipped[]`.
1042
+ - **New** [docs/troubleshooting.md](docs/troubleshooting.md) — every message the tool prints,
1043
+ its cause and its fix.
1044
+ - **Rewritten** README, [docs/install.md](docs/install.md),
1045
+ [docs/configuration.md](docs/configuration.md) and [docs/agent.md](docs/agent.md).
1046
+ - **New** [AGENTS.md](AGENTS.md) for agents working in the repository and
1047
+ [llms.txt](llms.txt) for AI discovery.
1048
+ - **New** CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, issue forms and a pull request
1049
+ template.
1050
+
1051
+ Corrections worth calling out, because the old text was wrong:
1052
+
1053
+ - The generated config was documented as importing `defineConfig`; it has been a plain object
1054
+ with no imports since 2.2.0, so that it loads when the package is only used through `npx`.
1055
+ - The "bin-less placeholder" explanation for the bare `npx` failure has been removed. The
1056
+ placeholder version is unrelated and harmless.
1057
+ - `snap init` writes an `.mjs` config for a CommonJS project, not a `.ts` one — the
1058
+ documentation now states the rule.
1059
+
1060
+ Packaging: `prepare` was replaced with `prepack`, so installing the package no longer triggers an
1061
+ install-script warning, and the published tarball is still built from source. The test suite now
1062
+ includes an end-to-end agent simulation that follows the six workflow steps published by
1063
+ `describe` and asserts the resulting snapshot.
1064
+
1065
+ ---
1066
+
1067
+ ## 3.0.0
1068
+
1069
+ ### BREAKING: the `snap` executable is gone; use `codebase-snapshot`
1070
+
1071
+ The package now ships **exactly one** executable, `codebase-snapshot`.
1072
+
1073
+ This is what makes the bare form work. `npx @ankurhalder/codebase-snapshot generate`
1074
+ used to exit 0 printing nothing, on every npm version and every OS, because of how npm
1075
+ picks a binary: it matches the command you typed against the package's `bin` keys, and no
1076
+ key can contain an `@scope/`. With two executables (`snap` and `codebase-snapshot`)
1077
+ nothing matched, so nothing ran. With one, npm uses it regardless of its name.
1078
+
1079
+ Verify the rule against packages you know: `@sentry/cli`, `@babel/cli` and
1080
+ `@changesets/cli` all ship one executable and run fine by bare name;
1081
+ `@swc/cli` ships three and fails with "could not determine executable to run";
1082
+ `npm-check-updates` ships two but is unscoped, so its name matches one of its bins.
1083
+
1084
+ Migration:
1085
+
1086
+ | Before | After |
1087
+ | :--- | :--- |
1088
+ | `snap generate` | `codebase-snapshot generate` |
1089
+ | `npx snap` | `npx codebase-snapshot` |
1090
+ | `npm run snapshot` (generated by `init`) | unchanged — it already used `codebase-snapshot` |
1091
+
1092
+ Nothing else in the API changed: the library exports, the config format, the manifest
1093
+ schema and every command and option are identical.
1094
+
1095
+ ### Changed
1096
+
1097
+ - The agent contract no longer claims the bare spec is unusable. It documents one
1098
+ executable and explains why, so an agent does not add a pin it does not need.
1099
+ - `docs/install.md` explains the single-executable rule instead of the previous
1100
+ (incorrect) "bin-less placeholder" theory.
1101
+
1102
+ ### Note on `0.0.0-stage`
1103
+
1104
+ Earlier notes suggested unpublishing a `0.0.0-stage` placeholder to fix the bare form.
1105
+ That diagnosis was wrong — the placeholder is unrelated and can stay. The npm behaviour
1106
+ above is the real cause, and the single-executable change fixes it for everyone with no
1107
+ registry surgery.
1108
+
1109
+ ---
1110
+
1111
+ Built for AI agents. Everything an agent needs is now available as data, and the tool can
1112
+ install its own skill where agents look for it.
1113
+
1114
+ ### Added
1115
+
1116
+ - **snap describe** — a machine-readable contract of the whole tool: the invocation that
1117
+ works, an ordered workflow array, every command, every option with its default, the
1118
+ exit-code contract, all 25 presets, the artifact list, config file names, output layout,
1119
+ safety rules and a troubleshooting table. --format json for machines, text for humans.
1120
+ Generated from the same code the CLI runs; a test compares its documented defaults
1121
+ against a real `generate --dry-run` manifest so it cannot drift into lying.
1122
+ - **snap skill** — prints the bundled agent skill (to stdout, so it can be redirected),
1123
+ or with `--install` writes it to `AGENTS.md`, `.claude/skills/codebase-snapshot/SKILL.md`
1124
+ and `.cursor/rules/codebase-snapshot.mdc`. `--target claude|agents|cursor|all`.
1125
+ The install is idempotent and replaces only its own marked block, so an existing
1126
+ `AGENTS.md` keeps its conventions.
1127
+ - `snap init` now points at `skill --install`.
1128
+ - [docs/agent.md](docs/agent.md) explains the contract and the skill for humans.
1129
+
1130
+ ### Why
1131
+
1132
+ `--help` is prose for a person. An agent told "use npx @ankurhalder/codebase-snapshot"
1133
+ had to guess option names and failure modes. `describe` removes the guessing, and the
1134
+ skill removes the discovery problem: once installed, an agent finds the tool without being
1135
+ told it exists.
1136
+
1137
+ ### Tests
1138
+
1139
+ 138 pass (130 before): the contract's shape and defaults, skill output and frontmatter,
1140
+ installation to all three targets, idempotency, and that an existing `AGENTS.md` is
1141
+ preserved.
1142
+
1143
+ ---
1144
+
1145
+ ## 2.1.1
1146
+
1147
+ A correctness and contract pass driven by an independent CLI audit of 2.1.0. No new
1148
+ features; every item below was reproduced against the released build first.
1149
+
1150
+ ### Fixed
1151
+
1152
+ - **`validate` never printed the bookkeeping mismatches it counted.** It reported
1153
+ "1 manifest bookkeeping value(s) disagree" without saying which one, while
1154
+ `--format json` carried the detail. The text output now names them
1155
+ (`counts.totalFiles: manifest 999 vs actual 5`).
1156
+ - **`generate` claimed artifacts it had not written.** `--only-artifacts parts` still
1157
+ printed a `Reports:` line and a `Manifest:` line; `--skip-artifacts manifest` still
1158
+ printed `Manifest:`. The summary now reflects the selection, and an all-disabled run
1159
+ says so instead of listing files that do not exist.
1160
+ - **`diff` leaked raw Node errors** — `ENOENT: no such file or directory, stat …` for a
1161
+ missing path, and `Cannot read properties of undefined (reading 'totalFiles')` for a
1162
+ parseable but foreign manifest. Both are actionable messages now.
1163
+ - **`validate` double-counted a tracked-but-deleted file**: it appeared in
1164
+ `missingOnDisk` twice, so the text said "2 file(s) no longer exist" for one file.
1165
+ - **A bad `--cwd` exited 1** although the documented contract reserves 2 for usage
1166
+ errors. A missing directory or a file passed as `--cwd` is now exit 2.
1167
+ - **`--max-part-bytes -5`** produced the parser's "option argument is ambiguous" text;
1168
+ it now says the value looks like another flag and points at the `--option=value` form.
1169
+ - **Empty option values silently meant "use the default"**, so `--out-dir "$UNSET"`
1170
+ wrote to `./snapshot` instead of failing. Every string option now rejects an empty
1171
+ value with a usage error.
1172
+ - **A detached HEAD was recorded as branch `"HEAD"`**, then `validate` warned about it
1173
+ forever. A detached HEAD cannot name a branch, so the configured fallback is used.
1174
+ - **A read-only output directory produced a bare `EPERM`**. It now says the directory is
1175
+ not writable and suggests `--out-dir`; the previous snapshot still survives.
1176
+ - **A wrong-typed `"snapshot"` key in `package.json` was silently dropped.** It now
1177
+ warns. It stays non-fatal on purpose — other tools use that key too.
1178
+ - The all-artifacts-disabled warning claimed the directory "will be empty", which
1179
+ stopped being true once the ownership marker was introduced.
1180
+
1181
+ ### Tests
1182
+
1183
+ 130 pass (119 before), including a POSIX-only check that a read-only output directory
1184
+ fails with a clear message.
1185
+
1186
+ ---
1187
+
1188
+ ## 2.1.0
1189
+
1190
+ Multi-project support and an interactive setup.
1191
+
1192
+ ### Added
1193
+
1194
+ - **Interactive setup.** `npm create codebase-snapshot` (or `npx @ankurhalder/codebase-snapshot init`)
1195
+ detects the project, walks every tunable parameter, and writes the config. Every
1196
+ prompt has a flag, so it works non-interactively in CI.
1197
+ - **A self-documenting config.** The generated `snapshot.config.mjs` lists *every*
1198
+ setting at its current value, commented and grouped — not just the ones you
1199
+ changed. Deleting any line is safe. `--minimal` writes the terse variant.
1200
+ - **25 presets**, up from 4: nextjs, nuxt, sveltekit, remix, astro, angular,
1201
+ solid, vue-vite, react-vite, nest, node-express, node-lib, python-django,
1202
+ python-fastapi, python-lib, go, rust, java-spring, dotnet, php-laravel,
1203
+ ruby-rails, monorepo, infra, docs, generic.
1204
+ - **Per-ecosystem ignores** contributed by each preset — `__pycache__`, `.venv`,
1205
+ `.mypy_cache` (Python), `target` (Rust), `.gradle` (JVM), `bin`/`obj` (.NET),
1206
+ `vendor`/`storage` (PHP), `.svelte-kit`/`.angular`/`.astro` (JS frameworks) —
1207
+ merged with `excludePatterns` and still recorded per file.
1208
+ - **Structural summaries for non-TypeScript languages.** Python, Go, Rust,
1209
+ Java/Kotlin/Scala, C#, PHP, Ruby and C/C++ now get imports, declarations and
1210
+ exports. Vue/Svelte/Astro components have their `<script>` block handed to the
1211
+ real TypeScript compiler.
1212
+ - **`summaryQuality`** (`ast` / `structural` / `heuristic`) recorded per file and
1213
+ rendered in every part section, so a line-scan is never presented as a parse.
1214
+ - **`preset`** option to force a preset by name; an unknown name is an error with
1215
+ the full list rather than a silent fallback.
1216
+
1217
+ ### Changed
1218
+
1219
+ - Preset detection now reads the repository once per run and shares that view
1220
+ with every detector. `peerDependencies` and `optionalDependencies` count.
1221
+ - A bare `package.json` ranks below any definitive language manifest, so a Go or
1222
+ Rust repo with a `package.json` for tooling is not mistaken for a Node library.
1223
+
1224
+ ### Fixed
1225
+
1226
+ - **`outDir`/`tmpDir` pointing at an existing directory could destroy it.** A
1227
+ config with `outDir: "src"` or `tmpDir: "src"` deleted the user's source tree
1228
+ and exited 0: the scratch directory was removed outright, and the atomic swap
1229
+ renamed the directory aside, moved the snapshot in, then deleted the backup. A
1230
+ regular file named `snapshot` was destroyed the same way. Both directories are
1231
+ now refused unless they are empty, hold only placeholder dotfiles, or carry
1232
+ proof that a previous run wrote them (a `.codebase-snapshot` marker or a
1233
+ parseable `manifest.json`). Filenames alone are never treated as proof, so an
1234
+ existing `docs/README.md` is safe and hook-written artifacts do not block
1235
+ reruns.
1236
+ - **A config that could not be parsed was silently ignored** — a typo produced a
1237
+ snapshot with none of the developer's settings and exit 0. Unparseable configs
1238
+ are now a hard error (exit 1, nothing written). A `.ts` config on a Node without
1239
+ type stripping is still only a warning.
1240
+ - **Absolute `--out-dir` was honoured by `generate` but not by the readers.**
1241
+ `validate`, `report` and `explain` joined instead of resolving the path, so they
1242
+ looked for `<cwd>\<absolute path>` and reported a missing manifest immediately
1243
+ after a successful generate.
1244
+ - **`--preset`/`--include`/`--exclude`/`--yes`/`--minimal`/`--force` were accepted
1245
+ and ignored outside `init`.** `snap generate --preset nextjs` silently used the
1246
+ detected preset. These are now a usage error (exit 2) on any other command.
1247
+ - **`init --preset <typo>` silently fell back to detection**, writing a config for
1248
+ a preset nobody asked for. It is now a usage error listing every valid preset.
1249
+ - **Astro components were summarized as markup.** The leading `---` fence — where
1250
+ the TypeScript actually lives — was not parsed, so the common case degraded to
1251
+ `heuristic` quality. It now goes through the compiler like Vue and Svelte.
1252
+ - Summaries for `.jsonc` strip comments instead of reporting `invalid-json`.
1253
+ - `export default function Foo` records both `Foo` and `default`.
1254
+ - Parser diagnostics are counted and surfaced as warnings.
1255
+
1256
+ ---
1257
+
1258
+ ## 2.0.0
1259
+
1260
+ Configurable snapshots, `explain`/`diff` commands and correctness fixes.
1261
+
1262
+ ### Added
1263
+
1264
+ - `artifacts`: switch each of the seven artifacts off individually, with
1265
+ `--skip-artifacts` and `--only-artifacts`.
1266
+ - `include`: allow-list globs, applied before `excludePatterns`.
1267
+ - `budgets.byLayer`: per-layer part budgets.
1268
+ - `layersMode`: user layers merge with the detected preset by default.
1269
+ - `hooks.afterGenerate`.
1270
+ - `snap explain <path>` and `snap diff <a> <b>`.
1271
+ - `generate --watch`.
1272
+ - Byte budgets accept human sizes (`120k`, `1.5mb`) from CLI and config.
1273
+ - Globs support brace groups and character classes (`*.{ts,tsx}`, `[abc]`).
1274
+
1275
+ ### Fixed
1276
+
1277
+ - The monolith is built from pre-partition content, so `full-codebase.*` really is
1278
+ the whole codebase even when a part truncates a file.
1279
+ - The part byte bound is enforced after the split-pass cap instead of being
1280
+ silently exceeded.
1281
+ - Default-ignored files are recorded with `reason: "default-ignore"` instead of
1282
+ vanishing, including leftover `snapshot.backup-*` directories.
1283
+ - Watch mode no longer retriggers itself on its own writes.
1284
+
1285
+ ---
1286
+
1287
+ ## 1.0.0
1288
+
1289
+ Initial release.