# Contributing to LiteShip Thanks for considering a contribution. LiteShip is pre-1.0 and intentionally greenfield (we'd rather break things now than later), so most of the guidance here is about keeping the gauntlet honest, not gatekeeping. Ontology for prose and docs: [GLOSSARY.md](./GLOSSARY.md). The git remote and directory name may still read `czap`; `@czap/*` on npm is the package line. ## How this project is governed LiteShip is **maintainer-led and doctrine-protected**. Open source means you can inspect, fork, learn from, build on, and contribute to it — it does not mean direction is crowdsourced. Contributions are welcome; the steering wheel is not up for grabs. - **Open a PR directly** for: bug fixes, tests, docs corrections, small compatibility patches. - **Open an issue / RFC first** for: new features, public-API changes, architecture or runtime-model changes, new dependencies, or naming changes. Large PRs without prior discussion are usually closed. - **A technically-correct patch may still be declined** if it doesn't fit the project's doctrine, performance model, maintenance budget, or conceptual shape. That's not arbitrary — it means it *violates a written invariant*. The invariants are codified, not vibes: the [ADRs](./docs/adr/), the source-of-truth principle (every identity computed from its source, not a proxy beside it), the **plumb-completeness gate** (a built-not-plumbed primitive fails CI), the zero-advisory floor, "split don't bump caps," and every-behavior-change-ships-its-falsifying-test (the SQLite/DO-178B bar). Read [ARCHITECTURE.md](./ARCHITECTURE.md) and the relevant ADR as the contract a PR must not silently break. - **AI-generated PRs** must be read and understood by the submitter — the same bar the maintainer holds with their own agents. No vibe-dump-and-flee. The `docs/` chain is **sacred**: match house voice, never restructure autonomously, keep `docs:check` green. `main` is protected (PR + the gauntlet's required checks; no force-push, no deletions; release `v*` tags are protected). ## Quick start (development) ```bash # Clone + install git clone https://github.com/freebatteryfactory/LiteShip.git cd LiteShip pnpm install # First-run shake-down: rig-check + build + test pnpm shakedown # Or step through it manually (one command per line — see Shell paste traps below): pnpm run doctor pnpm run build pnpm test # Full release-grade gate (~22min) — rig-check is enforced at entry pnpm run gauntlet:full ``` ### Shell paste traps (zsh) Interactive zsh does **not** treat `#` as a comment unless you run `setopt interactivecomments`. Pasting a command with an inline comment — especially one containing parentheses like `(libx264):` — can fail with `zsh: no matches found` before the command runs. **Paste one command per line.** Put expectations in prose below the block, not on the same line as the command. Required versions: Node.js 22+, pnpm 10+. The repo runs on Windows + Linux, PowerShell + bash. WebKit/Firefox/Chromium tests run on the system Playwright install (`pnpm exec playwright install` if needed). ### System dependencies (match CI) CI (`truth-linux` in `.github/workflows/ci.yml`) installs **ffmpeg with libx264** before `gauntlet:full`. Scene-render integration tests and the smoke render use the same encoder path as production — a bare `ffmpeg` binary without `libx264` (common on Fedora `ffmpeg-free`) will skip those tests via `czap doctor` / the shared render probe, not hang for minutes. | Dependency | Install | | --- | --- | | ffmpeg + libx264 (Ubuntu/Debian, CI) | `sudo apt-get install -y ffmpeg` | | ffmpeg + libx264 (Fedora/Nobara) | `sudo dnf swap ffmpeg-free ffmpeg --allowerasing` (RPM Fusion) | | Playwright browsers (e2e / coverage:browser) | `pnpm exec playwright install chromium chromium-headless-shell` | | Everything at once | Reopen in Dev Container (`.devcontainer/`) — post-create installs the full CI stack | Preflight: `pnpm run doctor` (or `czap doctor`) reports `ffmpeg (libx264)` and `Playwright` as `ok` or `warn` with the exact fix command, so missing system deps surface before `pnpm run gauntlet:full` (which runs `test:e2e`) rather than mid-run. ## Dev-experience shortcuts Discoverable verbs at the workspace root: ```bash pnpm shakedown # rig-check + build + test (first-run aggregate) pnpm run doctor # preflight rig-check, emits JSON receipt + TTY summary pnpm dev # vitest in watch mode (the inner loop) pnpm run clean # dry-dock: wipe dist/, coverage/, reports/, .tsbuildinfo pnpm scripts # categorized index of every dev script pnpm run glossary # look up a LiteShip / CZAP term (e.g. `pnpm run glossary boundary`) pnpm fix # prettier --write + eslint --fix ``` The CLI mirrors the same surface once built: ```bash czap help # usage czap doctor # preflight rig-check czap version # czap + Node + pnpm versions czap glossary cast # ontology lookup czap describe # AI-facing schema (also: --format=mcp) ``` `pnpm install` runs a postinstall banner with the same hints. Suppress it in CI with `CI=1` (already standard) or `CZAP_QUIET_INSTALL=1`. ## The gauntlet, your release gate `pnpm run gauntlet:full` is the contract: the full shake-down cruise. It runs the full 39-phase sequence (the canonical ordered list + count is `packages/cli/src/gauntlet-phases.ts`, pinned by `tests/unit/devops/gauntlet-profile.test.ts` — never hand-counted): - rig-check (`doctor --preflight --ci` — env probes hard-fail before build) - build, capsule:compile, typecheck, lint, docs:check, invariants, audit:floor - the full vitest test surface (unit + component + property + integration) - Vite, Astro, Tailwind integration smokes - Playwright e2e + 10x stress + 10x stream-stress + 5x flake harness - red-team regression suite - benchmarks + bench gate + rolling-median trend gate + bench reality - per-package publish smoke - node + browser coverage + cross-runtime merge with statementMap dedup - runtime-seams report, codebase audit, satellite scan - feedback integrity verification (artifact fingerprint chain) - runtime gate, plumb gate (every published package classified runtime/tooling/deferred in `scripts/plumb-registry.ts` + no new unwired capsule — a built-not-plumbed primitive fails here), capsule verify - `flex:verify` 10/10 acceptance across 7 rating dimensions **Bench trend gate (`bench:trend`):** it reads `benchmarks/history.jsonl` (one JSON line per `bench:gate` run) and only enforces drift once there are three distinct historical fingerprints. Until then it prints a `[ceremonial-skip]` tagged message and exits zero (fresh clones are not trapped). The gauntlet runs `bench:trend` with `BENCH_TREND_STRICT=1` so regression failures are enforced when history exists. The gauntlet exits cleanly with `flex:verify PASSED — project is 10/10 by every rating dimension`, or it fails closed. Not a stylistic gate; a correctness gate. PRs need to be green here before merge. **Slow machine?** Timing-sensitive phases (test timeouts, spawn deadlines) flake when sibling workloads load the box. Set `CZAP_TEST_TIMEOUT_SCALE=` (e.g. `CZAP_TEST_TIMEOUT_SCALE=3 pnpm run gauntlet:full`) to multiply every vitest budget without changing gate semantics — CI never sets it, so the cloud `truth-linux` job stays the arbiter. Explicit per-test timeouts must go through `scaledTimeout` (vitest.shared.ts); a raw literal silently *lowers* the budget under `--coverage` and is rejected by `tests/unit/meta/test-timeout-policy.test.ts`. Bench-gate thresholds are load-sensitive by nature and are not covered by the scale knob — re-run those on an idle box or trust CI. For Windows users: PowerShell's `>` redirect writes UTF-16 LE; use `Out-File -Encoding utf8` or run `chcp 65001` first to keep gauntlet logs readable. **Rust kernels** (`crates/czap-compute`): the WASM/TS parity suite (`tests/unit/core/wasm-parity.test.ts`) self-skips when the wasm32 artifact is absent, so a Rust toolchain is NOT required locally. To run it: `rustup target add wasm32-unknown-unknown`, then `cargo build --release --target wasm32-unknown-unknown` inside the crate. CI's `rust-wasm-parity` job always builds from source and runs the full parity gate (`cargo test` + property suite against the fresh artifact). ## PR conventions - One concern per PR. Tightly-coupled changes (e.g. moving a sub-path export and updating consumers) can ship as one commit; otherwise split. - Commit message style is conventional-ish: - `fix(): ...` for bug fixes - `feat(): ...` for new features - `chore(): ...` for tooling/housekeeping - `docs(): ...` for doc-only changes - `refactor(): ...` for non-behavioral cleanup - The gauntlet is the final gate; don't skip pre-commit hooks (`--no-verify` is reserved for emergencies only). ## Code style - TypeScript strict mode, ESM only (`.js` extensions in import specifiers per Node ESM rules) - No default exports; named exports only - Branded types via Effect's Brand module (`Brand.Branded`) - Namespace-object pattern for module facades: ```ts export const Boundary = { make: _make, evaluate: _evaluate }; export declare namespace Boundary { export type Shape = BoundaryShape; } ``` - Tests in `tests/unit/`, `tests/integration/`, `tests/component/`, `tests/property/`, `tests/smoke/`, `tests/regression/`, `tests/bench/`, `tests/e2e/`, `tests/browser/`, `tests/generated/` - vitest is the runner everywhere except `tests/e2e/` (Playwright) and `tests/browser/` (vitest browser-mode via Playwright) - Property-based tests via fast-check - Imports from sibling packages use `@czap/*` aliases (resolved via `vitest.shared.ts` for tests, `Config.toTestAliases` for the runner) ## Testing lanes - `pnpm test`: full node/jsdom surface (~75s) - `pnpm test path/to/foo.test.ts`: single file, fastest feedback when you're chasing one failure - `pnpm run test:e2e`: Playwright e2e (~6s) - `pnpm run test:flake`: repeated runs of runtime-sensitive tests to catch flakes - `pnpm run test:redteam`: security regressions - `pnpm run bench`: full benchmark sweep with directive-overhead pairs and rolling-median trend gate - `pnpm run coverage:merge`: node + browser coverage merge with statementMap-divergence dedup The gauntlet runs all of these in sequence. Most PRs only need the fast loop (`pnpm test`) until they touch something the larger lanes cover. ## Writing a regression test ### File-location convention | Directory | What lives there | | --------- | ---------------- | | `tests/unit/` | Per-module correctness; one file per logical surface | | `tests/property/` | Invariant proofs via fast-check | | `tests/regression/` | Named bugs that must never re-appear | | `tests/integration/` | Cross-package wiring | | `tests/component/` | Isolated component rendering | When you fix a bug, the regression test goes in `tests/regression/`. Name the file after the surface, not the ticket: `boundary-threshold.test.ts`, not `issue-412.test.ts`. ### The vitest pattern ```ts import { describe, test, expect } from 'vitest'; import { Boundary } from '@czap/core'; describe('Boundary.evaluate threshold edge', () => { test('exact threshold value resolves to the upper state', () => { const b = Boundary.make({ input: 'viewport.width', at: [[0, 'mobile'], [768, 'tablet']] as const, }); expect(Boundary.evaluate(b, 768)).toBe('tablet'); }); }); ``` Add `// @vitest-environment jsdom` at the top of any file that touches the DOM (`parseHTML`, `document.createElement`, style reads, aria attributes). Without it, vitest runs under Node and `document` is undefined. ### The fast-check property-test pattern Use `fc.assert(fc.property(...))` to state what must hold for *all* valid inputs, not just the ones you thought of: ```ts import fc from 'fast-check'; test('evaluate never returns a state name outside the boundary definition', () => { fc.assert( fc.property( fc.uniqueArray(fc.integer({ min: 0, max: 10000 }), { minLength: 2, maxLength: 6 }), fc.integer({ min: 0, max: 10000 }), (rawThresholds, value) => { const sorted = rawThresholds.sort((a, b) => a - b); const pairs = sorted.map((t, i) => [t, `s${i}`] as const); const b = Boundary.make({ input: 'x', at: pairs as never }); const validStates = pairs.map(([, name]) => name); return validStates.includes(Boundary.evaluate(b, value) as (typeof validStates)[number]); }, ), ); }); ``` fast-check runs 100 trials by default and shrinks failing inputs automatically. Put property tests in `tests/property/` when the invariant is general; put them in `tests/regression/` when they pin a specific bug. ### Worked example **Bug:** after a refactor of `boundary.ts`, `Boundary.evaluate` was using `>` instead of `>=` on threshold comparisons, so `value === threshold` resolved to the state *below* the crossing rather than the state *at or above* it. **Regression test** (`tests/regression/boundary-threshold.test.ts`): ```ts import { describe, test, expect } from 'vitest'; import { Boundary } from '@czap/core'; describe('regression: Boundary.evaluate exact-threshold resolution', () => { const b = Boundary.make({ input: 'viewport.width', at: [[0, 'mobile'], [768, 'tablet'], [1280, 'desktop']] as const, }); test('value exactly at threshold resolves to the upper state, not the lower', () => { expect(Boundary.evaluate(b, 768)).toBe('tablet'); expect(Boundary.evaluate(b, 1280)).toBe('desktop'); }); test('value one below threshold stays in the lower state', () => { expect(Boundary.evaluate(b, 767)).toBe('mobile'); expect(Boundary.evaluate(b, 1279)).toBe('tablet'); }); }); ``` **Fix sketch:** the conditional in `packages/core/src/boundary.ts` near the threshold loop changed from `value > threshold` back to `value >= threshold`: one character, confirmed by the test turning green. The test now lives permanently in `tests/regression/` and runs on every `pnpm test` invocation. ### Gauntlet integration Worth noting: `pnpm test` is phase 9 of `pnpm run gauntlet:full` (after rig-check, build, validate, and audit:floor) and covers the full vitest surface including `tests/regression/`; see [STATUS.md](./STATUS.md) for the complete phase list. ## Architecture changes Architectural decisions live in [`docs/adr/`](./docs/adr). New ADRs follow [`docs/adr/_template.md`](./docs/adr/_template.md). If you're proposing a change that: - alters a public package surface, - changes a runtime contract (capsule kind, receipt envelope, plan IR), - adds a new compile target or runtime adapter, - adds a value to a publicly-exported type union (e.g. extending `MotionTier`, `UIQualityTier`, `OutputTarget`), - shifts the trust boundary or security posture, …draft an ADR alongside the code change. The Architecture rating dimension in `flex:verify` checks the canonical ADR set is present. **Extending a public type union** (e.g. adding `'chaotic'` to `MotionTier`) ripples wider than it looks because exhaustive `Record` tables and any `satisfies` checks downstream stop compiling until every branch is updated. The blast-radius checklist: 1. Update the canonical declaration in `packages/_spine/*.d.ts` first (per ADR-0010). 2. Update the runtime definition in the owning `packages/*/src/*.ts` file (e.g. `packages/core/src/ui-quality.ts` for `MotionTier`). 3. Find every `Record` in the repo and add the new arm. Common offenders: `TIER_TARGETS`, `DEVICE_CAPABILITY_SCORES`, capability-mapping functions. 4. Update any test that asserts an exhaustive list of values (typed as `T[]`, not `satisfies T[]` — TypeScript widens the manual list). 5. Document the semantic position in the ladder (or orthogonality) in the owning file's JSDoc. The framework does not currently dedupe these declarations across `_spine` and the runtime module; the duplication is intentional under ADR-0010 (spine is the canonical type source; runtime modules carry the value-side). If you find a third copy somewhere, that's a bug — file an issue. ## Issues vs feature requests - **Bug**: open a GitHub issue with the gauntlet phase, command, exit code, and log tail (the relevant ~50 lines, not the whole 22-min log). - **Feature**: open a discussion or issue with what you're trying to do, what LiteShip doesn't currently let you do, and what shape an answer might have. - **Security**: read [SECURITY.md](./SECURITY.md) and follow the private reporting path. ## Code of Conduct This project follows the [Contributor Covenant](./CODE_OF_CONDUCT.md). Be direct, be kind, ship things that work.