Skip to main content
This document outlines the development workflow and code quality standards for Soundcheck.

TypeScript configuration

Strict mode enabled

The project uses strict TypeScript configuration:

Benefits

  • Prevents Production Crashes - Catches type mismatches before deployment
  • Better IntelliSense - More accurate code completion
  • Safer Refactoring - TypeScript catches breaking changes

Development scripts

Daily development

Pre-build checks


Git hooks

Husky installs two hooks when you run npm ci at the repo root. Both run only the checks for the packages you touched. Both print their slowest steps when they take 10 seconds or more (set SOUNDCHECK_HOOK_TIMINGS_MIN to change the limit).

Pre-commit (.husky/pre-commit)

Pre-commit works on staged files only:
  1. Fast gates: the comment ban, analytics taxonomy, feature flag registry, and the node:test suites beside any staged repo script. The gates/registry.json script gates only read the tree, so they run in parallel and print their output in table order (SOUNDCHECK_HOOK_JOBS=1 runs them one at a time).
  2. Lint and format: oxlint on each package’s staged files. oxfmt checks each package’s staged files in one run and restages what it formats. Mobile files go through lint-staged, which covers the paths the root lint and format scripts cover.
  3. Related tests for web, api-events, and mcp-ts. vitest run runs the tests that import the staged files, nearest first and at most 40 per package, without coverage. The packages run in parallel after every package’s lint and format. Then scripts/check-weak-tests.mjs --staged prints a WEAK line for each new test case whose only expectations cannot fail for a defect (toBeDefined, toHaveBeenCalled, not.toThrow, and similar). It is advisory and never blocks the commit.
  4. Scanners and file linters: SQL timezone and LIKE escape, Prisma validate, whitespace, shellcheck, actionlint, Markdown tables, and docs links.
Coverage is not a gate. No hook or CI check fails on a coverage percentage; npm run test:coverage in a package still writes a report when you want one. Write tests for the behavior you change, not to reach a number. scripts/related-test-targets.mjs decides which tests run. It walks the package’s import graph itself (scripts/related-test-graph.mjs, about a second) instead of calling vitest related, which transforms every test file’s imports before it starts. For a widely imported helper that took two minutes or more. Type-only imports do not count. When more than 40 tests import the staged files, the hook runs the 40 closest: fewest import hops first, then the nearest folders. It prints how many it left to CI, which runs every suite on the PR. SOUNDCHECK_PRECOMMIT_TEST_CAP=<n> changes the cap (0 runs them all). It starts from these files:
  • A staged source file is passed as-is.
  • A data file, such as migration.sql, schema.prisma, JSON, a fixture, or a snapshot, becomes the source files that name it or quote its folder.
  • A deleted module becomes the files that import it.
  • These need the full suite, which pre-commit leaves to CI: package.json, a lockfile, a tsconfig, a vitest config, a loaded .env file, src/test/, __mocks__/, or a nested data file that no source names. The hook prints the reason and runs no tests for that package. SOUNDCHECK_PRECOMMIT_FULL_TESTS=1 runs the suite locally instead.
During a merge, a file counts only when the merge result differs from both parents, so merging dev does not re-test everything dev changed.

Pre-push (.husky/pre-push)

Pre-push plans the pushed range with scripts/affected/affected.mjs, then runs whole-package type checks, lint, Knip, and the SQL scanners for the affected packages. It installs and runs code generation first, then runs every check as its own parallel job, so the push takes about as long as its slowest check. After those pass, it measures coverage for unit-gated source in the push (see above). When the plan asks for a full unit suite, for example because a lockfile, auth, or money path changed, pre-push leaves that suite to CI, which runs each affected package’s full suite on the PR. E2E, integration, and OpenAPI parity also run in CI.

Parallel jobs

Both hooks run their slow checks as background jobs and print each job’s output in order once it finishes. Code generation runs once before the jobs start, and the jobs call npm with --ignore-scripts. Otherwise api-events and checkai would both rebuild mcp-ts, and web would regenerate the client mobile reads, at the same time. Set SOUNDCHECK_HOOK_JOBS=1 to run the jobs one at a time, for example on a laptop with little memory. Only a few hooks per clone run their jobs at a time: one per 18 GB of memory, at least one (two on a 36 GB Mac). On Linux the count uses the lowest cgroup memory limit on the hook’s own cgroup or any parent (a container or systemd slice limit) when that is lower than the machine’s memory. Parallel agent sessions each commit from their own worktree, and a single api-events typecheck takes about 7 GB, so several hooks at once can fill memory and swap. A hook that finds every slot busy prints which worktrees hold them and its place in line, and waits. Waiting hooks get a slot in the order they arrived. Pre-commit test runs and pre-push type checks, full suites, and knip take a slot; a push that only runs lint or the SQL scanners does not wait. A slot is a kernel file lock (taken through perl), so a hook that dies frees its slot at once; after 30 minutes a waiting hook stops waiting and runs anyway. Without perl the hooks run without the limit. SOUNDCHECK_HOOK_SLOTS=<n> lets n hooks run at once (0 turns the wait off), and SOUNDCHECK_HOOK_SLOT_WAIT=<seconds> changes the 30-minute limit. Vitest runs in the hooks use at most 4 workers; set SOUNDCHECK_VITEST_MAX_WORKERS to change that. The heavy package scripts wait for the same slots outside the hooks. They run through scripts/check-slot.mjs, which takes a slot for the length of the command: Scripts that only chain these (checks, check-all, type-check:all) wait once for each wrapped step. An agent that runs npm run typecheck therefore queues behind other worktrees’ hooks and type checks instead of running alongside them. A hook that already holds a slot exports SOUNDCHECK_HOOK_SLOT_HELD=1, so the package scripts it runs do not wait for a second slot. The wrapper does not queue in CI, on Windows, without perl, or with SOUNDCHECK_HOOK_SLOTS=0. A direct npx tsc or npx vitest skips the queue, so run checks through the package scripts.

Measured times

Measured on an Apple Silicon laptop with warm caches, on 2026-09-29: Parallel jobs, measured the same way against the serial hooks above: In the merge, 197 of the 332 seconds went to formatting one file at a time, which the batched oxfmt run replaces. Related tests are now the largest step in a normal commit, followed by web and api-events type checks at pre-push. The target is under 2 minutes for a typical commit and under 5 to 8 minutes for a typical push. These cases still take longer:
  • A cold install. The first commit or push after a lockfile change runs npm ci in each touched package (about 20 seconds to 2 minutes).
  • A staged package.json. Pre-commit syncs the lockfile with npm install, which also reruns code generation (5 to 35 seconds).
  • Changes in several packages run each package’s steps in turn.

When a hook is slow

  • Read the timing summary at the end of the hook output. It names the slow step.
  • Commit changes to web, api-events, and mcp-ts separately when they do not depend on each other.
  • Keep node_modules real and current. In a worktree, run npm run worktree:setup once, not a symlink.
  • SOUNDCHECK_PRECOMMIT_FULL_TESTS=1 forces the full suite at pre-commit. SOUNDCHECK_PREPUSH_FULL=1 runs the full unit suites at pre-push. Use them when you want local proof before CI.
  • Do not use git commit --no-verify or git push --no-verify. The hooks run the same gates as CI, so a bypass moves the failure to the PR.

Dependency cooling (supply chain)

New dependency versions wait before install/build so automated scanners have time to catch compromised publishes. Stable Cargo has no install-time equivalent (RFC 3923 min-publish-age is nightly-only). Compiling a crate runs its build.rs and proc macros, so the Rust module uses an editor-agnostic rustc-wrapper (infrastructure-services/gcp-platform-signals/.cargo/rustc-guard.mjs) plus CI (scripts/check-cargo-release-age.mjs). Ops detail and emergency bypass: AGENTS.md. Recurring trap: Pitfalls §13. One-off npm bypass (emergency only):

oxlint rules

TypeScript rules

React rules

General quality


Common issues and fixes

Cause: Import/export mismatch between components and contextsFix: Ensure all interfaces are imported from the same source:
Cause: Calling hooks conditionally
Fix: Include all dependencies or disable the rule:

Testing environments

Development vs production

  1. Local Development: npm run dev
  2. Pre-commit: Automatic checks via git hooks
  3. Pre-build: npm run check-all
  4. Local Production Build: npx eas build --local
  5. TestFlight: Deploy and test

Migration strategy

Current state

  • TypeScript: Strict mode enabled
  • oxlint: Warnings for most rules (not blocking)
  • oxfmt: Formatting enforced by npm run format:check
  • Git Hooks: Enabled but permissive

Future improvements

  1. Phase 1: Fix critical warnings (unused variables, React hooks)
  2. Phase 2: Replace any types with specific interfaces
  3. Phase 3: Enable stricter oxlint rules as errors
  4. Phase 4: Add unit tests with type checking

VS Code integration

  • TypeScript and JavaScript Language Features
  • Oxc / oxlint
  • Expo Tools

Auto-configuration

The .vscode/ folder contains:
  • settings.json: Auto-format on save, lint integration
  • extensions.json: Recommended extensions for the team

Goals

Short term

  • Prevent type-related crashes in production
  • Catch import/export issues before deployment
  • Enforce React best practices

Long term

  • Achieve 100% TypeScript coverage
  • Zero oxlint warnings
  • Comprehensive test coverage with type safety

Need help?

  1. Check this document for common solutions
  2. Run npm run check-all to see all issues
  3. Use npm run lint:fix to auto-fix simple issues
  4. Temporarily disable rules with lint comments if needed
  5. Ask the team for help with complex type issues
These strict checks prevent bugs that would otherwise crash the app in production.