AGENTS.md
Guidance for anyone — human or AI — contributing to @crmackey/shapefile-wasm.
Read this before making changes. Much of the code here looks over-careful until you know which sharp edge of the shapefile format it is working around, and the fastest way to introduce a subtle data-corruption bug is to "simplify" one of those places.
What this project is
A Rust core compiled to WebAssembly that converts between GeoJSON and ESRI Shapefiles, with a TypeScript layer for zipping, projections and browser integration.
rust/ Rust core → WebAssembly. Geometry and attribute conversion only.
src/ TypeScript. Zip, projections, browser helpers, public API.
test/ vitest suites (the Rust has its own #[cfg(test)] modules).
scripts/ Build helpers. Plain .mjs, no framework.
docs/ VitePress site: hand-written guide + generated API references.The division matters. The Rust core knows nothing about zip files, projections, or browsers — it converts bytes. Anything else belongs in src/. This keeps the wasm binary small and leaves the core usable as a plain Rust crate. Do not reach for a Rust crate to do something TypeScript can do.
Before you start
pnpm install # both workspace packages, and the git hooks
pnpm run build # wasm → inline → TypeScript
pnpm test # Rust tests, build, TypeScript testsYou need Rust 1.85+ with the wasm32-unknown-unknown target, wasm-pack, pnpm, and Node 18+.
This is a pnpm workspace: the library is the root, demo/ is a member linking it with workspace:*. Do not add a lockfile to demo/ — there is one for the workspace. New dependencies that want to run install scripts must be approved in pnpm-workspace.yaml; check what the script does before approving it.
See Development for the full setup.
Ground rules
1. Do not break the format invariants
These are not style preferences. Each one exists because getting it wrong produces a file that opens fine in one GIS and silently misbehaves in another.
.dbfcolumn widths are measured from the data, never guessed. Thedbasecrate crops any value that overruns its column without erroring, so a guessed width turns-123456789.125into a different number. If you touchrust/schema.rs, keep the measuring pass.- Every record supplies every column.
dbasetreats a missing field as a hard error, not a null. Ragged input is padded before it reaches the writer. - Features with no geometry are skipped, and counted. The
.shpand.dbfare paired by position. Writing an attribute row with no shape shifts every later pairing — corruption that surfaces months later, in someone else's tool. - Polygon holes are re-nested by containment, not by order. A shapefile stores rings flat. Pairing them by index is the obvious approach and it is wrong; see
rings_to_geometryinrust/read.rs. - Ring winding is converted in both directions. Shapefiles wind exteriors clockwise, RFC 7946 counter-clockwise.
- Text is truncated on character boundaries.
dbasecrops bytes, which would split a multi-byte character and produce invalid UTF-8.
2. Guard the panic paths
panic = "abort" in the release profile means a Rust panic reaches JavaScript as a bare RuntimeError: unreachable with no message. The underlying crates panic on several inputs — a polyline part with under two points, an empty ring list — so those are checked ahead of time and returned as proper errors.
If you call a new shapefile or dbase constructor, read its source for assert! and guard it. A panic that escapes to a user is a bug.
3. Explain why, not what
The comment density here is deliberate. A comment restating the code is noise; a comment recording which format quirk forced the code is the reason the next person does not undo it.
// Good: records the constraint.
// `dbase` crops overlong values silently, so the width has to come from the
// data rather than a guess.
// Useless: restates the code.
// Set the width to the max length.4. Every public API gets a docstring
TypeDoc runs with notDocumented validation and CI treats warnings as failure. Public TypeScript needs @param, @returns, @throws and at least one @example; public Rust needs /// with # Arguments and # Errors. These are also what editors show on hover, so write them for someone mid-task.
Note that TypeDoc reads the first line after @example as the example's title, so put a short description there, not the opening code fence.
5. Prefer a round-trip test
test/roundtrip.test.ts writes bytes and reads them straight back. Without binary fixtures from other software, that is the only real evidence that what we emit is what a reader understands.
Make fixtures adversarial. A hole-nesting test passes even when the logic is wrong if both polygons sit in the same place — so the fixture puts them 100 units apart and asserts each hole came back on its own square.
6. Keep the dependency tree small
The published package has one runtime dependency (fflate). That is a feature. A new runtime dependency needs a real justification; a new dev dependency should earn its place too.
This is also why the git hooks are forty lines of shell rather than husky and commitlint.
Commits and releases
Conventional Commits are enforced by .githooks/commit-msg, installed automatically by pnpm install. CI also checks pull request titles, because a squash merge turns the title into the commit message on main.
feat: add multi-layer archives
fix(read): attach holes to the containing ring
docs: explain the nested layout
feat!: drop the trimStrings optionTypes: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test. A ! or a BREAKING CHANGE: footer marks a breaking change.
Releases are automated. release-please reads these commits, opens a release PR that bumps package.json, Cargo.toml and CHANGELOG.md, and on merge tags a GitHub Release — which triggers the npm publish. So:
fix:→ patch,feat:→ minor,!→ major (minor while pre-1.0)- Never hand-edit a version or
CHANGELOG.md; release-please owns both - The commit message is the changelog entry — write it for a reader
Security
This package parses untrusted input and publishes to npm. Both matter.
- The crate declares
#![forbid(unsafe_code)]. Keep it. - Every GitHub Action is pinned to a commit SHA, not a tag. Dependabot updates them. Do not "tidy" a SHA back to
@v4. - Workflows declare least-privilege
permissions. Only publish and Pages can mint an OIDC token, and neither runs on pull requests. - npm publishing uses Trusted Publishing — there is no long-lived npm token. Do not add one.
package.jsonuses afilesallowlist. Checkpnpm pack --dry-runafter changing what the build emits.
See SECURITY.md.
Checks to run
CI runs all of these; running them first saves a round trip.
cargo test # Rust unit tests
cargo fmt --check # formatting
cargo clippy --all-targets -- -D warnings
pnpm run build # wasm + TypeScript
pnpm run typecheck # tsc --noEmit
pnpm run check:versions # package.json vs Cargo.toml
pnpm run test:ts # vitest
pnpm run docs:api # TypeDoc; warnings are failures
pnpm pack --dry-run # what would shipTypeScript is strict, including exactOptionalPropertyTypes — so build option objects by omitting absent keys rather than assigning undefined. See resolveOptions in src/layers.ts.
Common tasks
Adding a geometry type
rust/input.rs— theGeometryandFamilyvariantsrust/geometry.rs— the conversionrust/lib.rs— an arm inwrite_geometry's matchrust/read.rs— the reverse, inshape_to_geometry- A round-trip test
The match in write_geometry is exhaustive over (Family, Dimension), so the compiler points at what is missing.
Adding a write option
rust/lib.rs— theOptionsstruct,#[serde(rename_all = "camelCase")]src/types.ts— theWriteOptionsinterface, with a docstringsrc/write.ts— pass it throughsrc/layers.ts— add it toresolveOptionsso batches honour it- Tests, and a mention in the guide
Adding EPSG codes to the bundled table
Edit the CODES list in scripts/fetch-projections.mjs and run pnpm run projections. The result is committed on purpose — the published package must never depend on epsg.io being reachable.
Changing the public API
Update llms.txt at the repository root too. It is hand-written, ships in the npm tarball, and is what AI tooling reads to learn the API surface and the format constraints. A stale one produces confidently wrong generated code.
Changing what the package ships
Update the files array in package.json, then verify with pnpm pack --dry-run. The files allowlist takes precedence over .gitignore, so gitignored build output still ships — that is intended for dist/ and pkg/.
Things that will surprise you
docs/reference/andsrc/generated/are generated and gitignored.src/generated/projections.tsis the exception: it is committed on purpose.Cargo.tomlhas two load-bearing blocks. Thewasm-optfeature flags (the bundledwasm-optpredates the features Rust emits) and thetimedependency (dbasestamps the.dbfheader with today's date, which reachesSystemTime::now()— unimplemented on wasm, and it aborts the module). Removing either breaks the build with an error pointing somewhere else.src/generated/bindings.jsis copied, not compiled.tscwill not emit for it, soscripts/copy-runtime.mjsplaces it indist/by hand.- The wasm binary is embedded as base64 in the main entry point.
/slimexists for consumers who would rather serve the.wasmthemselves.
Notes for AI agents
- Verify, do not assume. Read the actual signature in
~/.cargo/registry/…/shapefile-0.9.0/src/before calling into it. Several APIs in these crates differ from what a plausible guess would produce —build_with_destrather thanbind_to,finalize()returning()rather than the cursor. - Run the checks. Do not report work as done on the strength of it looking right.
cargo testandpnpm run test:tsare fast. - Watch for truncated output. A
curlpiped throughheadcan cut off the status line and lead you to the wrong conclusion about an API's behaviour. - Do not weaken a test to make it pass. If a test fails, decide whether the code or the expectation is wrong, and say which.
- Leave the comments. They encode format constraints that are not recoverable from reading the code.