Skip to content

Quality Gates

cl-tty-kit ships a small API surface, but the bar for changes stays high. This page is the canonical definition of the repository-local gates a patch must satisfy before it is treated as release-ready.

Functional requirements

  • public APIs remain executable through tests or runnable examples
  • pure subsystems stay pure: screen state, cursor state, rendering, UTF-8, and input decoding must not gain ambient I/O requirements
  • implementation-specific behavior stays isolated to SBCL-only modules such as raw mode and PTY handling (see Compatibility)
  • unsupported implementation paths must signal unsupported-feature instead of silently degrading

Non-functional requirements

  • every repository entrypoint must be deterministic and bounded by an explicit timeout
  • tests and examples must run from a clean source-registry discoverability check plus the repository bootstrap, not only from an already-loaded image
  • public contracts must be human-readable on this site, not hidden only in tests or source comments
  • behavior changes in rendering, input event shape, or exported symbols must be treated as contract changes and documented in the same patch

Verification gate

Run these commands from the project root. Each nix run app applies an OS-level timeout around SBCL, including ASDF/bootstrap loading. The runner also supplies the flake-pinned source registry and a temporary HOME and XDG directory, so host ASDF configuration and caches do not affect the result:

nix run .#test
nix run .#examples
nix run .#source-registry-smoke
nix run .#verify
nix run .#coverage
git diff --check

Expected outcomes:

  • the test suite passes without flaky retries
  • every example loads and its explicit runner completes
  • the source-registry smoke test can discover cl-tty-kit and cl-tty-kit/test, then load and test the system in a fresh SBCL process via the repository bootstrap
  • scripts/verify.lisp succeeds as the maintainer-grade local release gate
  • coverage output is regenerated and inspected for meaningful gaps
  • the working tree contains no whitespace or merge-marker defects

The local gate can be weaker than CI

Development happens on Linux

As of the 2026-08-01 org revision flake.nix declares x86_64-linux alone, so nix develop and nix build have no outputs on macOS at all. A Mac is no longer a supported development machine for this repository.

Even on Linux, a local nix flake check can be weaker than CI wherever the sandbox is disabled (nix config show sandbox): with the host filesystem visible, a test that reaches for an absolute path outside /bin/sh and the Nix store passes locally and fails only in CI. This is not hypothetical: t/pty-test.lisp's SIGTERM case spawned /bin/sleep and did exactly that.

Spawn external programs through /bin/sh and let PATH resolve the rest (:program "/bin/sh" :args '("-c" "exec sleep 5")), and before trusting a local green run for anything touching the filesystem or a subprocess, reproduce CI's environment:

nix build --option sandbox true .#checks.x86_64-linux.default

Macro usage and file organization

defmacro is for genuine compile-time shape: a family of near-identical top-level definitions (define-ansi-function, define-tty-kit-condition/define-formatted-tty-kit-condition, %define-rect-split, %define-osc-color-query) or a binding form that must run its body in a specific dynamic extent (with-terminal-session, with-raw-mode). Converting an ordinary function to a macro "for consistency" is a regression: unlike a function, a macro cannot be passed to funcall/mapcar/apply, cannot be shadowed for a test double, and its expansion is invisible to structural tooling. Duplication whose only variation is a runtime value gets a plain (optionally parameterized) function instead.

Files split by concern, not by line count. A long file whose forms serve one cohesive purpose (a single data table, a single parser, a single solver) is not a splitting candidate merely for being long — the ANSI/screen/PTY module boundaries throughout this site (see API Reference) are what a genuine concern boundary looks like. Coverage percentage follows the same principle as line count: files dominated by top-level data definitions (defparameter tables, defstruct slot defaults) routinely under-report under sb-cover even when fully exercised, because those forms run exactly once at load time and the instrumentation does not mark that as "entered" the way an if/when branch is marked — treat such a report as a tooling artifact, not a gap, once the exported contract is verified elsewhere.

The same artifact shows up one level down, inside ordinary defuns: an &key/&optional default-value init-form (`(defun make-cell (&key (char

\Space) style)) ...)`) is compiled as part of the lambda-list-parsing

prologue rather than the instrumented function body, so sb-cover marks it "not executed" even when the function is called without that argument -- %blank-cell calls (make-cell) on every blank screen cell in this codebase, and char's #\Space default in src/cell.lisp still reports as uncovered. Verify by testing what the function returns when the argument is omitted (already true for make-cell's default, per the test above), not by chasing the source form itself to a covered state -- it cannot reach one.

The same artifact reaches branch coverage too, not only expression coverage, once a validation call goes through DEFINE-SIMPLE-ASSERT/ DEFINE-VALIDATING-ASSERT: src/cursor.lisp's %ASSERT-CURSOR-COORDINATE wraps a single (typep value '(integer 0 *)) check, and t/cursor-test.lisp demonstrably exercises both outcomes -- (make-cursor) for the true branch, (make-cursor :x -1) (asserting CURSOR-PARAMETER-INVALID) for the false branch -- yet sb-cover still reports that typep as a partially-covered branch. Treat a low branch-coverage percentage on a file dominated by these macros the same way: confirm both outcomes have a passing test, not that sb-cover's own percentage reaches 100. For a stronger, artifact-free signal on a specific pure function, prefer mutation testing over chasing the sb-cover percentage -- see t/properties-test.lisp's "clamp's body has no surviving mutant" block, built on cl-weave:run-mutations: it takes CLAMP's own body form, applies cl-weave's arithmetic/comparison/branch mutation operators, and asserts every mutant produces a different result from the real function on a case battery covering both branches and their boundary -- a claim no line-coverage percentage can make, since a mutant and the original take the identical source paths by construction.

Documentation gate

Before merging or releasing:

  • the API Reference matches the exported symbols, which t/package-readme-test.lisp checks mechanically
  • Examples lists every runnable file under examples/
  • externally visible changes are captured in the GitHub Release description when the release is cut — there is no CHANGELOG.md
  • Development and Release Process still describe the current workflow

Change rejection criteria

Reject or rework a patch when it does any of the following:

  • adds backward-compatibility shims instead of clarifying the contract
  • mixes data transformation with terminal side effects in pure modules
  • introduces unbounded waits, hidden global state, or implementation-dependent behavior without an explicit contract
  • expands the public API without tests, examples, and documentation in the same patch

What CI actually runs

The gate above is what .github/workflows/ci.yml enforces on every push and pull request. The check job runs nix flake check on ubuntu-latest. flake.nix declares x86_64-linux alone, so there is no second platform to widen to and no --all-systems; the run covers four checks: default (the hermetic test suite), paredit-lint (the structural-parse gate), formatting (treefmt/nixfmt), and docs (mkdocs build --strict).

Two jobs sit alongside it, each for work the Nix sandbox cannot do. The coverage job uploads the report as a build artifact. The contrib job exercises the opt-in integrations under contrib/ (see Contrib) and needs network access for Quicklisp; it is continue-on-error so that Quicklisp flakiness never blocks a core merge.

See Release Process for how this gate fits into cutting a tagged release.

Production readiness

"Production ready" is not a separate, informal judgment call layered on top of the gates above -- it is what passing all of them, together, means. A release is production ready when every one of these holds simultaneously:

  • API contract: the 1.x line has a semantic-versioning guarantee (see README.md's "API stability" section), checked mechanically against the live package by t/package-introspection-test.lisp
  • Reproducible build: nix build produces the hermetic package from a flake-pinned source registry, with no reliance on host ASDF configuration or caches (see the verification gate above)
  • CI, not just local: .github/workflows/ci.yml runs the same nix flake check on every push, so the declared platform is gated by a machine nobody can forget to run
  • Bounded execution: every entry point (test, example, coverage, benchmark, verify) runs under an explicit OS-level timeout, so a hang is a failed run, not a stuck CI job -- see "Non-functional requirements" above
  • No backward-compatibility debt: the change-rejection criteria above reject shims and dual code paths on sight, so the tree carries no deprecated surface waiting to be a future incident
  • Documented contract: the documentation gate above keeps the API reference, examples list, and release notes mechanically or procedurally tied to the actual exported surface, so "production ready" also means "explainable to a new integrator without reading the source"
  • Security and support policy: the org-wide security policy and support policy apply to this repository, so a vulnerability report has a defined intake path rather than an ad hoc one

None of these is new process -- each is an existing, already-enforced gate. This section exists so "is this production ready" has one page to check against instead of an implicit standard scattered across this site.