Skip to content

fix(build): add clean step so renames cannot leak stale files to npm - #93

Merged
sjsyrek merged 2 commits into
mainfrom
fix/clean-script
Jul 27, 2026
Merged

fix(build): add clean step so renames cannot leak stale files to npm#93
sjsyrek merged 2 commits into
mainfrom
fix/clean-script

Conversation

@sjsyrek

@sjsyrek sjsyrek commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a clean step to the build so a file rename cannot leak stale compiled output into the published npm tarball. Closes the gap left by sync-1q8x, which was marked complete but never actually landed.

The bug, reproduced on main

tsc does not delete output for sources that no longer exist, and package.json ships dist/. So orphaned artifacts from any rename reach the tarball. Verified before the fix:

$ touch dist/sync/sync-strings.js dist/sync/strings-client.js
$ npm run build
$ npm pack --dry-run | grep strings
npm notice 0B dist/sync/strings-client.js
npm notice 0B dist/sync/sync-strings.js

Those filenames are exactly what the v1.1.0 squash-merge existed to keep off the public record.

The prior change was lost without trace — git log -S '"clean"' -- package.json returns no commits, so it was never committed despite the issue being closed "Completed in commit."

Changes Made

+ "clean": "rm -rf dist tsconfig.tsbuildinfo",
- "build": "tsc && chmod +x dist/cli/index.js",
+ "build": "npm run clean && tsc && chmod +x dist/cli/index.js",
  • tests/unit/package-manifest.test.ts — new, 8 assertions pinning these invariants
  • CHANGELOG.md### Changed entry under Unreleased

Why tsconfig.tsbuildinfo must go too

This is the non-obvious part, and the reason the naive fix fails. tsconfig.json sets "incremental": true. Deleting only dist/ leaves the 101 KB build-info file asserting the output is current, so tsc emits nothing and the build dies:

chmod: dist/cli/index.js: No such file or directory

So rm -rf dist on its own — which is what the original issue prescribed — breaks the build. That may well be why the earlier attempt disappeared.

Test Coverage

New tests/unit/package-manifest.test.ts covers:

  • clean exists, and removes both dist and tsbuildinfo
  • build runs clean first, and sets the bin exec bit
  • prepublishOnly rebuilds
  • files excludes *.tsbuildinfo and *.js.map
  • bin targets resolve inside dist/

This change was silently lost once; a test makes a repeat impossible.

Verified on Node 24.18.0:

build from clean state    dist regenerated, bin exec bit set (-rwxr-xr-x)
planted stale files       no longer survive a rebuild
npm pack --dry-run        0 internal-name hits, 323 files, 197.4 kB
                          README + LICENSE present, 0 .map files
npm run lint              pass
npm run type-check        pass
npm test                  232 suites / 5501 tests passed

Backward Compatibility

No runtime change — build tooling only; no source modified.
Same published surface, minus stale artifacts.
⚠️ Builds are now always full, never incremental. tsconfig.tsbuildinfo is removed on every build, so the incremental cache no longer applies to npm run build. Measured cost is a few seconds; correctness of the published tarball is worth more. npm run type-check is unaffected and still incremental.
Breaking changes: none.

Size: Small ✓

One script addition, one script edit, one new test file.

The TypeScript build does not delete output for sources that no longer
exist, so any file rename left orphaned .js/.d.ts in dist/. Since
package.json ships dist/, those artifacts reached the published tarball.
Reproduced on main before this change: planting dist/sync/strings-client.js
and dist/sync/sync-strings.js then running `npm run build` left both in
`npm pack --dry-run` output.

Note the clean step must also remove tsconfig.tsbuildinfo. tsconfig sets
"incremental": true, so deleting dist/ alone leaves the build-info file
claiming the output is current — tsc then emits nothing and the build
fails at `chmod +x dist/cli/index.js`. This is why removing dist/ on its
own is not sufficient.

Adds tests/unit/package-manifest.test.ts to pin the invariants. An
equivalent change was made and silently lost once before (sync-1q8x was
closed as complete, but `git log -S '"clean"' -- package.json` shows it
never landed), so the guard matters as much as the fix.

Verified: build from clean state regenerates dist with the bin exec bit
set, planted stale files no longer survive, tarball has zero
internal-name hits at 323 files / 197.4 kB, and 232 suites / 5501 tests
pass on Node 24.18.0.
@sjsyrek
sjsyrek merged commit c4a64be into main Jul 27, 2026
2 checks passed
sjsyrek added a commit that referenced this pull request Jul 27, 2026
The watchAndSync block intermittently failed CI on unrelated dependency
PRs (#81 Jun 30 Node 22, #91 and #93 Jul 27 Node 24), always as a 10s
test timeout and never as an assertion, hitting a different test each
time. Both #81 and #91 passed on plain re-run with no code change.

Three changes, in order of importance:

1. jest.setTimeout(30_000) for the block. The suite runs in ~1.8s locally
   but was observed at 10.8-10.9s on GitHub runners with 232 suites
   competing for ~4 cores — i.e. sitting on the 10s default, which tipped
   an arbitrary test over each time. This is the headroom the block
   actually needed.

2. flushWatchSetup now waits for an observable readiness signal
   (mockWatcherOn having been called) instead of burning a fixed round
   count, then drains a settle budget. attachDebouncedWatchLoop calls
   watcher.on() in the same synchronous continuation as the
   process.on('SIGINT') registration, so listeners-attached implies the
   handler each test uses for shutdown is registered. A fixed count could
   under-wait; this cannot. On a genuine stall it throws with the pending
   state instead of timing out mutely.

3. Settle rounds 250 -> 25. Every test in the block passes with as few as
   5 (measured), so 25 keeps a 5x margin.

On method: the historical escalation was 20 -> 50 -> 250 rounds, each
assuming the budget was too small. Suite duration is flat across
250/25/5 rounds, so widening it could never have worked. Four further
hypotheses were tested and disproven — slow sweepStaleBackups I/O (its
projectRoot '/test' does not exist, so readdir ENOENTs immediately), a
hang in controller.shutdown() (microtask-only), expensive flush rounds
(0.00ms per 250), and CPU contention (old code passed 12/12 under 16
spinners on 11 cores). Details in bead sync-94ua.

Honest limitation: the flake could not be reproduced locally — ~58 runs
across five contention profiles (CPU, I/O+CPU, full-suite, starved
budget) produced one failure whose identity was lost. So this targets the
signature the evidence supports rather than a reproduction, and the new
diagnostic ensures the next occurrence identifies itself.

Verified: 49 tests in the block pass; 20/20 under combined I/O and CPU
contention; lint, type-check, and 5501 tests green on Node 24.18.0.
@sjsyrek
sjsyrek deleted the fix/clean-script branch July 28, 2026 12:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant