Skip to content

Contributing

Abdulkader Safi edited this page Jul 16, 2026 · 2 revisions

Contributing

Setup

git clone https://github.com/Abdulkader-Safi/s-img.git
cd s-img
npm install
npm run check   # tsc strict + guards + tests. The one to run before a commit.

Node 22.18+. Bun is optional, and only needed for the parity check.

The commands

npm run check          # everything CI runs. Run this before pushing.
npm run build          # tsc to dist/, ESM
npm test               # node --test
npm run test:bun       # the Bun half of the Node/Bun parity claim
npm run check:guards   # the two boundary rules
npm run check:size     # the core bundle against its 150 KB budget
npm run bench:preview  # the preview path's numbers, on a generated 12MP JPEG
npm run example        # the runnable tour

CI runs check, build, the Bun smoke test and the size gate on every PR. All four must be green.

How the work is organised

features/index.md is the plan: one file per feature, ordered by milestone so every dependency lands before the thing that needs it. Each feature file states what it does, the edge cases, and the acceptance criteria — written before the code.

One feature, one branch, one PR.

When the spec and reality disagree, one of them changes on purpose. Sometimes the spec wins and the code is wrong. Sometimes the implementation turns out to be right and the spec gets corrected. What does not happen is a silent deviation — if the code does something the spec did not say, the spec says so afterwards, with the reason.

Testing

TDD, and it is not decorative: write the test, watch it fail, then write the code. A test that has never failed is a test you have no evidence about. Several bugs in this repo were found because a test that "obviously" worked did not fail when the code was deliberately broken.

Fixtures come from ImageMagick

Not from this library's encoder. Testing a decoder against your own encoder proves they agree with each other, which is not the same as either being correct — they can be wrong together, symmetrically, forever.

test/fixtures/*/generate.sh regenerates them. Where a fixture asserts something ImageMagick cannot write (EXIF orientation on a file with no EXIF, say), it is hand-built and then validated by an independent reader — ImageMagick or exifr — rather than trusted.

Mutation testing

The primary quality tool here, and the standard for a PR. It has found a real bug on essentially every branch — including in tests that were already passing, which is the point.

The workflow is manual and crude and works: break the code on purpose, run the tests, see if anything notices.

# change `<=` to `<`, delete a line, swap a `+` for a `-`, then:
npm test

If nothing fails, the test suite has a hole. Either kill the mutant with a new test, or — if it genuinely changes no behaviour — prove it is equivalent and document it in place, next to the code, with the reason. A survivor nobody explained and a survivor that is fine look identical six months later.

Real examples from this repo:

  • A reduced IDCT's cosine table was wrong and 15 passing tests did not care, because "the preview roughly resembles the photo" is not a statement about arithmetic.
  • encodePng never failed on a malformed image: a 2-byte buffer for a 4×4 produced a valid 72-byte PNG of garbage.
  • The size check did not catch the exact regression it was written to catch.

Detector self-checks

If a test asserts that something is absent — a count is zero, no metadata survived, the WASM is not in the bundle — it needs a companion test proving the detector works at all. Otherwise it passes just as happily against a broken harness, forever.

The guards

npm run check:guards enforces two rules mechanically:

  1. No node: imports under src/core/. The core is portable; io/ is the only place that touches a filesystem.
  2. No any in the emitted .d.ts.

Each guard has a test that plants a violation and watches the guard fail. A guard nobody has watched fail is a comment — and this one did pass silently on its first attempt, because stripping string literals deleted the import specifiers it was hunting for.

The size budget

150 KB min+gzip for the core, enforced in CI. Currently 23.2 KB.

Adding a runtime dependency is a real decision, not a shrug. Zero deps is the premise, not a preference: the artifact has to stay small enough for Obsidian Sync to carry, and every dependency is a transitive tree and a size surprise. Reach for node: builtins first — node:zlib instead of a bundled inflate saves ~45 KB, a third of the whole budget.

Adding a codec

  1. src/core/codecs/yourformat.ts, exporting probe, decode, encode.
  2. A row in the CODECS table in dispatch.ts, listing the options it accepts.
  3. Magic bytes in formats.ts.
  4. Fixtures generated by ImageMagick, with a generate.sh.
  5. Tests: round-trip, the odd dimensions, the malformed input, the edge cases.
  6. Mutation-test it.
  7. Check npm run check:size — you have a budget.

The codec never imports a transform, and never imports node: anything.

Commits and PRs

Explain why, not what — the diff already says what. If a decision was non-obvious, or a spec was deviated from, or mutation testing found something, that belongs in the message. Someone reading it in a year has the same questions you have now.

Do not add a Co-Authored-By trailer for AI tooling.

Clone this wiki locally