somewhatabstract / somewhatabstract/checksync
Proposal: rewrite checksync in Rust, distributed as a binary-backed npm package
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 20
- Forks
- 3
- Avg merge
- 25m
- Merged PRs (30d)
- 21
Description
Summary
Proposal + plan to rewrite checksync from Node/TypeScript to Rust, shipped as
the same checksync npm package so existing users get a seamless update. The
motivation is speed: Node's cold start (~50–150 ms — dominant for the common
pre-commit "few changed files" case) plus single-threaded, per-line stream parsing
of every file (dominant for full-repo CI on a large monorepo) are the two costs a
compiled, parallel implementation removes. This is also a clean moment to clear tech
debt and land several long-requested features.
This starts as a proposal with a cheap go/no-go gate — not a commitment. See
"Validate first" below.
TL;DR
- Worth it? Likely yes, but decide with a ~1-day benchmark (below) before
committing. The one real risk — silently changing a checksum and churning every
tag in users' repos — is well-mitigated by the existing__examples__conformance
fixtures + published checksum vectors. - Language: Rust. The core work (parallel,
.gitignore-aware walking + globbing)
maps directly onto ripgrep'signore+globsetcrates;serdedeletes the
JSON-schema dependency outright. - Distribution: the esbuild/biome/swc model —
checksynckeeps its name and
ships no binary itself, declaring oneoptionalDependencyper platform
(@checksync/<os>-<cpu>);bin/checksync.jsstays a tiny Node shim that
spawnSyncs the matching binary.npx/ global /pnpmall keep working. Ships
as a major (11.0.0), identical behavior. - Programmatic API (
checkSync,loadConfigurationFile): preserved via a
spawn-based JS shim (requires a newchecksync --print-configsubcommand).
Validate first (Phase −1 — cheap go/no-go, ~1 day, before any rewrite)
The rewrite is a performance bet, and performance is measurable up front without
building the real tool.
- Decompose the current runtime (hours, read-only): time
node -e ''and
checksync --versionfor the fixed Node-startup tax; time a small run (a few
files) vs. a full-repo run to separate startup from walk+parse; optionally
node --cpu-profto split fast-glob walk vs. readline parse vs. hash. - Walk-hash PoC (~half a day): ~40 lines of Rust (
ignoreparallel walker +
read +adlerhash over every candidate file) establishes the performance
floor — it does the expensive ~80% and skips the cheap logic. Compare
wall-clock vs.checksyncon the same real corpus, cold and warm OS page cache.
Decision rule — Go if small-run latency drops from ~150–300 ms to ⪅20 ms
(near-guaranteed by dropping Node) and the full-repo PoC is ⪆3× faster. Stop if
the PoC is within ~1.5× (bottleneck is unavoidable disk I/O, which no language change
fixes) or real invocations are already comfortably sub-100 ms.
The single number that swings the verdict: how many files checksync actually
parses in real usage. A narrow include glob ⇒ the parse win is small and only startup
matters; a whole-tree scan ⇒ parallelism wins big.
Distribution design (binary-backed, seamless update)
Convert to a pnpm workspace: packages/checksync (main) + packages/@checksync/*
(6 platform packages). Six targets, static musl on Linux (no C deps ⇒ one Linux
package per arch covers glibc and musl):
| npm package | os / cpu | Rust target |
|---|---|---|
@checksync/linux-x64 |
linux / x64 | x86_64-unknown-linux-musl |
@checksync/linux-arm64 |
linux / arm64 | aarch64-unknown-linux-musl |
@checksync/darwin-x64 |
darwin / x64 | x86_64-apple-darwin |
@checksync/darwin-arm64 |
darwin / arm64 | aarch64-apple-darwin |
@checksync/win32-x64 |
win32 / x64 | x86_64-pc-windows-msvc |
@checksync/win32-arm64 |
win32 / arm64 | aarch64-pc-windows-msvc |
- Each platform package contains only its executable and declares
os/cpu; the
main package lists all six as exact-pinnedoptionalDependenciesand keeps zero
runtimedependenciesof its own. bin/checksync.jsresolves@checksync/${process.platform}-${process.arch}via
require.resolve(works across npm hoisting, pnpm's isolated store, and global
installs),spawnSyncs the binary withstdio:"inherit"andargv.slice(2),
re-raises signals, and propagates the exit code. No platform match (unsupported
arch, or--no-optional) ⇒ actionable error, exit 5.- Rejected alternative: postinstall-download (runs code at install, needs network,
no lockfile integrity, breaks--ignore-scripts). checksync.schema.jsonstays in the main package for editor$schema
autocomplete; optionally embed it in the binary behindchecksync --print-schema.
Release pipeline: extend the existing Changesets flow — put all 7 packages in a
Changesets fixed group so they share a version; add a build-binaries GitHub
Actions matrix (Linux both arches via cargo-zigbuild static musl; both macOS arches
on macos-14; both Windows arches on windows-latest); on Release-PR merge, build
all binaries, lay each into its platform package, then publish the 6 platform
packages first and the main package last. Fold in npm provenance / trusted
publishing (OIDC) — closes #2340.
The fidelity contract (byte-exact — what must not change)
The whole test strategy pins these. Getting any wrong silently churns users'
checksums or output ordering.
- Checksum recipe (
src/checksum.ts):adler32(("\n" + join(each content line + "\n")).utf8), reinterpretedas i32, decimal-formatted, leading-
stripped. Golden vectors:
checksum(["\n","\n","a test\n","more test\n","\n"]) == "1043727889"and
checksum(["Some super important content!"]) == "1472197848". - Two variants per marker (
src/parse-file.ts):contentChecksum(content only
→ LOCAL comparisons) andselfChecksum(content + root-relative,
forward-slash-normalized path appended as a final element with no trailing\n
→ REMOTE/self comparisons + migration writes). - Line model = Node
readlinecrlfDelay:Infinity(split on\n, strip
trailing\r, no final empty line when the file ends in newline, lone\rnot a
separator). Replicate exactly — notstr::lines(). Retain each line's original
terminator for #665. - Root detection = nearest ancestor dir (incl. the file's own) containing the
marker; default markerpackage.json. FeedsselfChecksum. - JS key-ordering (highest-churn trap):
targetskeyed by line number →
numeric-ascending →BTreeMap; everything else → insertion order →IndexMap. - Regex classes (
src/marker-parser.ts): JS\w/\sare ASCII-only; Rust
regexis Unicode by default → compile with(?-u). Each line is JS
String.trim()-ed before tag matching. - Output: text/verbose logs are byte-exact (color-stripped) — includes
". "→newline splitting, fixed label widths, 2-spaceconsole.groupindent.
--jsonisJSON.stringify({version, launchString, files}, null, 4)but
per-error field order is inconsistent across factories, so JSON is compared
structurally and Rust picks one canonical order. - Exit codes are public: SUCCESS 0, NO_FILES 1, PARSE_ERRORS 2,
DESYNCHRONIZED_BLOCKS 3, UNKNOWN_ARGS 4, CATASTROPHIC 5, BAD_CONFIG 6, BAD_CACHE 7. - Launch string reproduced across the shim boundary (shim passes launcher env so
the binary rendersnpx checksync/pnpm checksynccorrectly).
Phased plan (each phase gates green against the conformance oracle on ubuntu/macOS/windows)
- Phase −1 — Validate the performance thesis (see "Validate first" above).
- Phase 0 — Conformance oracle (no Rust): a committed harness that runs the
real current CLI as a subprocess over every__examples__dir × 4 scenarios
(default+--output-cache; check-only;--json;--update-tags --dry-run;
migrate_all→--migrate all), plus CLI-surface cases and single-file inputs
(seed for #2320), capturing stdout/stderr/exit/--json/resulting file bytes as
normalized golden fixtures. Also emit a checksum vector table (ASCII/CRLF/Unicode/
emoji/empty). Uses real process output, not the JestStringLoggersnapshot. - Phase 1 — Core: checksum + line model + parser (
checksum.ts, line model,
marker-parser.ts,types.ts). Verify vs golden vectors + parser suite + checksum
table. - Phase 2 — Filesystem: walk, glob, ignore, symlinks, root (
get-files.ts,
ignore-*,get-normalized-path-info.ts, path/root utils,ancesdir). Crates:
ignore,globset,std::fs::canonicalize,dunce. - Phase 3 — Cache assembly + error generation + output/exit codes:
get-markers-from-files.ts(two-pass, aliases),generate-errors-for-file.ts,
errors.ts,determine-migration.ts,output-sink.ts, formatters. Crates:
owo-colors,serde_json,rayon. Gate: 21 × {check-only, json} green on 3
OSes. Fold in the GitHub Actions formatter (#2096) here. - Phase 4 — Autofix + migration writes + cache:
fix-file.ts,
output-cache/load-cache,load-migration-config.ts. #665 lands here as a
deliberate divergence (preserve the file's EOL) behind its own CRLF fixture. - Phase 5 — CLI surface + config loading:
parse-args.ts(yargs→clap),
option resolution,load-configuration-file.ts(schema→serde),help.ts,cli.ts.
Add--root(#2315) and--print-config(for the API shim). - Phase 6 — Packaging + CI + release: shim, 6 platform packages, cross-compile
matrix, lockstep versioning, provenance. Verify tarball install +npx checksync+
the--no-optionalfallback on each OS. - Phase 7 — Cutover: run the Rust binary over a large real corpus + the examples
and diff vs Node; publishchecksync@11.0.0(MAJOR changeset with migration notes).
Keep a10.xmaintenance branch.
Open issues folded in
- Free wins: #665 (autofix preserves the file's line ending — the TODO in
fix-file.ts), #2315 (--root), #1261 (obsolete — the ESM JSON-schema dep is
deleted in favor of serde), #2054 (obsolete — Phase 0 already writes one golden
file per scenario), #2340 (npm provenance / trusted publishing). - Perf & CI output: #2055 (parallel walk/parse addresses most of the "slow"
motivation with no cache; then a config-agnostic, portable per-file parse artifact
keyed by content hash that subsumes today's--output-cache/--use-cache), #2096
(GitHub Actions::error file=…,line=…::output). - Design-for-later (shape now, build later): #2320 (reverse-direction validation
of linked files — a targeted extension of the existing two-pass referenced-files
loader), #887 (cross-repo sync tags — the #2055 parse artifact is the interchange
file), #2037 (URL verification — kept behind acli-only network trait socore
stays pure/offline/deterministic).
Verification
- Golden checksum vectors reproduce exactly, plus a Phase-0 corpus table.
- Differential conformance: Rust binary's stdout/stderr/exit/
--json(and autofix
file bytes) match the Phase-0 golden fixtures for all 21__examples__× 4
scenarios on ubuntu/macOS/windows — byte-exact for text (color-stripped),
structural for JSON. This is the merge gate for phases 3–5. - Real-corpus diff before cutover: run both Node and Rust over a large repo and diff
every reported error and every autofix byte. - Packaging smoke test: install the packed tarball into a scratch project per OS;
confirmnpx checksyncresolves + runs the right binary and the--no-optional
fallback errors cleanly.
Risks / honest caveats
- The load-bearing risk is checksum/output drift; mitigated by the conformance oracle
and differential testing, but must be treated as the top priority throughout. - Non-performance trade-off a benchmark won't capture: you take on a second language
and a cross-platform binary release pipeline forever, in exchange for erasing
real tech debt (#1261, #2054) and unblocking features. Weigh that against the
measured speedup. - yargs → clap has quirks to replicate (
;/space-split multi-values,
ignoreFiles=false, tri-state booleans,?help alias) — needs a small compat
layer.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the existing CLI entry points and the files named in the plan, especially src/checksum.ts, src/parse-file.ts, and the examples fixtures. First measure current startup and full-repository timings, then build the small Rust walk-hash proof of concept described in Phase −1. Done means applying the stated go/no-go thresholds and recording whether the rewrite should proceed.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- github-actions, node.js, rust, typescript
- Domain
- build-system, ci-cd, cli, developer-experience, performance, release, tooling
- Issue type
- Refactor
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100