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 runnpm 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:
- Fast gates: the comment ban, analytics taxonomy, feature flag registry, and the node:test suites beside any staged repo script. The
gates/registry.jsonscript gates only read the tree, so they run in parallel and print their output in table order (SOUNDCHECK_HOOK_JOBS=1runs them one at a time). - 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 rootlintandformatscripts cover. - Related tests for web, api-events, and mcp-ts.
vitest runruns 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. Thenscripts/check-weak-tests.mjs --stagedprints aWEAKline 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. - Scanners and file linters: SQL timezone and LIKE escape, Prisma validate, whitespace, shellcheck, actionlint, Markdown tables, and docs links.
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.envfile,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=1runs the suite locally instead.
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 ciin each touched package (about 20 seconds to 2 minutes). - A staged
package.json. Pre-commit syncs the lockfile withnpm 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_modulesreal and current. In a worktree, runnpm run worktree:setuponce, not a symlink. SOUNDCHECK_PRECOMMIT_FULL_TESTS=1forces the full suite at pre-commit.SOUNDCHECK_PREPUSH_FULL=1runs the full unit suites at pre-push. Use them when you want local proof before CI.- Do not use
git commit --no-verifyorgit 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
Element type is invalid
Element type is invalid
Cause: Import/export mismatch between components and contextsFix: Ensure all interfaces are imported from the same source:
Rules of Hooks Violations
Rules of Hooks Violations
Cause: Calling hooks conditionally
Missing Dependencies in useEffect
Missing Dependencies in useEffect
Fix: Include all dependencies or disable the rule:
Testing environments
Development vs production
Recommended testing flow
- Local Development:
npm run dev - Pre-commit: Automatic checks via git hooks
- Pre-build:
npm run check-all - Local Production Build:
npx eas build --local - 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
- Phase 1: Fix critical warnings (unused variables, React hooks)
- Phase 2: Replace
anytypes with specific interfaces - Phase 3: Enable stricter oxlint rules as errors
- Phase 4: Add unit tests with type checking
VS Code integration
Recommended extensions
- 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?
- Check this document for common solutions
- Run
npm run check-allto see all issues - Use
npm run lint:fixto auto-fix simple issues - Temporarily disable rules with lint comments if needed
- Ask the team for help with complex type issues
These strict checks prevent bugs that would otherwise crash the app in production.