Monorepo: apps/backend (Express + GraphQL + Objection/Knex), apps/frontend
(React + Vite + Apollo), shared code in packages/. Local setup (Docker, DB
seeding, dev server) is documented in CONTRIBUTING.md.
Run pnpm run static-checks. It is the same turbo task CI runs
(check-types, check-format, lint, knip), so it is the only thing that
tells you CI will pass. Fix everything it reports, including unused code found
by knip.
While iterating, use the fast local checks on what you touched:
oxfmt <target>, tsc --noEmit, oxlint <target>.
Formatting is oxfmt, configured
once in .oxfmtrc.json at the repository root — there is no per-workspace config
and no per-workspace check-format script. It formats the whole monorepo in a
couple of seconds, so pnpm run check-format at the root is the only command you
need. Keep it that way: per-workspace scripts leave root-level sources like
tests/ and vitest/ unchecked, because no workspace owns them. It also owns
import sorting and Tailwind class sorting, which used to be Prettier plugins.
Linting is oxlint, configured once
in .oxlintrc.json at the repository root — there is no per-workspace config and
no per-workspace lint script. It lints the whole monorepo in well under a
second, so pnpm run lint at the root is the only command you need. The rule set
is oxlint's correctness category across the typescript, unicorn, oxc,
react and vitest plugins, plus the deviations listed in rules /
overrides. Two project-specific guards live there:
no-restricted-importskeepssonnerout of the frontend (import from@/ui/Toasterinstead).argos/no-module-relative-paths— a local plugin intools/oxlint-plugin-argos.js, wired up throughjsPlugins— rejectsimport.meta.dirname/__dirnamein the bundled backend. Oxlint has nono-restricted-syntaxequivalent, so the rule is hand-written.
Suppress with // oxlint-disable-next-line <rule>. Oxlint still honours
eslint-disable comments, but do not write new ones.
Conventional Commits, with one
rule that decides the type: feat and fix are for what a user would
notice — a capability they gain, a bug they hit. Judge it from their side of
the screen, not from how much code moved or how hard it was to find. Everything
else is chore, however substantial: refactors, tests, tooling, docs,
dependency bumps, and internal fixes no user could ever observe. Do not use
style — the formatter owns formatting, so the type has nothing left to
describe.
A comment has to say something the code cannot. A constraint coming from elsewhere in the system, a race, a workaround, a decision whose better-looking alternative is wrong — those earn their lines, and the codebase is full of them on purpose. Everything else is noise a reader wades through and a maintainer has to keep true.
So do not write:
- a restatement of the line below it —
// Review the next buildover a button that reads "Review next build"; - a defence of what is not in the code. An approach considered and dropped belongs in the pull request, where it is read once, not in the file, where it is read forever;
- an explanation of a name that is already clear, or a banner announcing a section the structure already shows.
Comment the part that is actually surprising, not the whole block around it. When the explanation keeps growing, the code usually wants a named function instead.
- Never use
!(non-null assertion). Useinvariantfor required values. - Program by assertion, not by defense: when a value is expected to be present
(a context provided by an ancestor, a required param, an invariant of the call
site), assert it with
invariant(or a hook built on it, e.g.useNonNullable/useProjectPermission) and use it directly. Do not paper over the expectation with optional chaining or?? <fallback>— that silently degrades instead of surfacing a broken assumption. Reserve?./??for values that are genuinely optional. - Avoid
astype assertions; prefer proper typing, type guards, orsatisfies.as constis fine.
Schema lives in apps/backend/db/, managed with Knex migrations + Objection
models. To add or change a column:
pnpm run --filter @argos/backend db:migrate:make <short-description>, then writeasyncup/downin the generated file. Useknex.raw(...)for raw constraint changes, withexport const config = { transaction: false }when needed.pnpm run --filter @argos/backend db:dump— migrates and regenerates the committeddb/structure.sql. Commit the migration andstructure.sql.- Update the Objection model in
src/database/models/: add the column tojsonSchema.properties(nullable →{ type: ["string", "null"] }, dates are ISO strings) and the typed class field.createdAt/updatedAtcome from the baseModel. - Reset the test DB before running the Playwright tests:
NODE_ENV=test pnpm run --filter @argos/backend db:reset.pnpm test:integrationneeds nothing — it rebuilds its own databases wheneverstructure.sqlchanges.
Idempotent inserts: never gate an insert on a prior existence check — two
concurrent requests can both pass it and race on the primary key. Insert
atomically with onConflict([...]).ignore() (or .merge(...)), and read the
.returning(...) rows when you need to know whether the row was actually
created (empty array = conflict ignored, e.g. don't send the notification).
- The schema is the source of truth. After editing any
typeDefsinapps/backend/src/graphql/definitions/, runpnpm run codegenfrom the repo root — it writes the backend resolver types (src/graphql/__generated__/,schema.gql) and the frontend types (apps/frontend/src/gql/). Import generated enums/types instead of usingany, and add mappers in/codegen.tswhen needed. - Keep resolvers thin, put business logic in services, and use DataLoader to avoid N+1 queries.
- Expose per-object permissions as an enum field (
permissions: [XxxPermission!]!) computed fromctx.auth?.user. Put the logic in a pure helper (seesrc/comment/permissions.ts) so the same function backs both thepermissionsresolver and the guard inside the mutation. Always enforce the check in the mutation — never trust the client.
Mutations: only use useMutation when you actually render from its result
tuple (loading / data / error). It re-renders the component while the
request is in flight even if you never read that state. Otherwise call
useApolloClient().mutate({ mutation, variables, ... }) directly.
Dialogs: build them with Modal + Dialog from @/ui. Modal owns a
pending state (ModalActionContext) that blocks dismissal while an action is in
flight — never track that with a local loading boolean.
- Form dialogs: wrap the body in
<Form>with a<FormSubmit>.Formflags the modal pending during submit and routes server errors throughhandleFormError. Close on success withuseOverlayTriggerState().close(). - Action dialogs (confirm/delete/revoke): drive the mutation with
useModalAction()to get the same pending-blocks-dismiss behavior. Userole="alertdialog",variant="destructive"for destructive actions, andtoast.success(...)on completion.
If possible, put some screenshots or videos using /argos-upload skill.
Two-stage async pipeline in apps/backend/src/notification/:
sendNotification({ type, data, recipients }) → notificationWorkflowJob
(applies opt-outs) → notificationMessageJob (renders and sends the email).
To add a type, model it on an existing handler (e.g.
handlers/review_submitted.tsx) and register it in handlers/index.ts — the
NotificationWorkflowType union and typed sendNotification props derive from
that array, and no migration is needed (data is a JSON column). recipients
is an array of user ids you compute; the system never discovers them.
The TipTap extension list is duplicated and must stay in sync between
apps/backend/src/comment/schema.ts (drives validateCommentJson and email
HTML rendering) and apps/frontend/src/ui/Editor/. A node the client produces
but the backend doesn't register is rejected with Invalid comment body.
Never trust the client for anything derived from content (e.g. mentioned
users) — re-parse the stored JSON server-side and check permissions (see
src/comment/mentions.ts). A mention node persists only the account id,
never the label; labels are resolved at render time on both sides.
Coverage is not the goal. Every test is code someone has to keep working, so it has to earn its place by protecting something the project actually depends on.
- Test what matters to the whole project, not edge cases. Billing, permissions and auth, the build lifecycle, review and comment flows, notifications — the paths where a regression reaches users or corrupts data. A test that pins one component's internal state buys little and costs maintenance forever.
- The more end-to-end, the better. A Playwright spec exercises the real server, the real database and the real client together, which is where bugs actually live; the same assertion one layer down proves much less. When a behavior can be reached from the UI, test it there and skip the unit test.
- Avoid mocking. A mock asserts what you assumed the collaborator does, so the test passes while production breaks. If a behavior can only be reached by mocking, move the test further out — or accept that it does not need one.
- Low-level utilities are the exception worth being exhaustive about. Pure
helpers with many input shapes — parsers, validators, comparators, formatters
— are cheap to cover case by case, and that is exactly where a table-driven
*.test.tspays off. - Not every fix needs a test. A flake, a one-off rendering detail or an ordering tweak is usually fixed and left at that. Add a guard only when the behavior is worth defending on its own.
-
Tests live next to the code.
*.e2e.test.tsrequires Redis/Postgres,*.test.tsotherwise. -
pnpm test:unit(no infra) /pnpm test:integration(Postgres + Redis). -
Use
apps/backend/src/database/testing/factory.tsfor fixtures, andtest.extend(prefer one per file, creating models inside fixtures). -
Keep fixtures small, reusable, independent, and split by concern so tests run in parallel. Avoid large shared setups.
-
Test API endpoints with
createTestHandlerApp+supertest. -
test:integrationruns in parallel, and every test truncates the whole database, so each worker owns atest_<workerId>database, a Redis database and a queue prefix — set up byvitest/vitest.e2e.global-setup.mts(seeapps/backend/src/database/testing/workers.ts). Nothing to create by hand: worker databases are built fromdb/structure.sqland rebuilt whenever it changes. Thetestdatabase is left alone, it belongs to Playwright. -
If
db:resetreportsdatabase "test" is being accessed by other users:psql -h 127.0.0.1 -U postgres -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'test' AND pid <> pg_backend_pid();"
Specs live in tests/*.spec.ts and run against the built app.
- Run with
NODE_ENV=test pnpm test:e2e. This is required: the test process (which seeds the DB) and the web server must both resolve to thetestPostgres DB. Without it the seeds write todevelopmentwhile the server readstest, and pages render "Page not found".- Single test:
NODE_ENV=test pnpm test:e2e --project=chromium -g "<title>".
- Single test:
- Build first (
pnpm build), and rebuild the frontend after changing it — the server servesapps/frontend/dist. Specs import seeds fromapps/backend/srcdirectly, so seed changes need no backend rebuild. Sharedpackages/*must be built too or module resolution fails. - The
setupproject truncates the DB; each test seeds its own data via theloggedTestfixtures (real session cookie), so keep seeded data isolated. - Take screenshots with the
screenshot()helper intests/util.ts. Content markeddata-visual-test="transparent"(e.g.<Time>) is neutralized automatically; use the helper'sreplacementsfor other unstable text. - Seeds live in
apps/backend/src/database/seeds.ts. Add a focused scenario function rather than bendingcreateBuildScenario, which many baselines depend on. Use a uniquekeyPrefix(e.g. the project id) forFilekeys, and reuse existing image keys (dummy-*,diff-*) so images load fromfiles.argos-ci.com/test/<s3Id>. - A guard test — when the behavior warrants one, see What to test — should fail without the fix. Verify by temporarily reverting the fix, rebuilding, and re-running before trusting it.
createBuildScenario: build 6 = changes detected (2 failures, 6 changed, 8 added, 2 removed, 2 unchanged), build 7 = accepted, build 8 = rejected. All on branchmain, bucket namedefault.- An active Stripe subscription needs
provider: "stripe"plusstripeSubscriptionIdandsubscriberId(ausers.id, not an account id) — thecheck_stripe_fieldsconstraint. - "Previous approvals" (
branchApprovedDiffs) needs an earlier build on the same bucket name + branch with conclusionchanges-detected, an approved non-dismissedBuildReviewfor the viewer, matchingScreenshotDiffReviewrows, and matchingfingerprinton both builds' diffs (seed diffs have no fileIds, so fingerprints are the only link). - IconButtons don't expose accessible names from tooltips — locate them via the
lucide icon class, e.g.
button:has(.lucide-thumbs-up).
docker compose up -d(Postgres on 5432, Redis on 6380).NODE_ENV=test pnpm run --filter @argos/backend db:migrate:latest.pnpm build(orpnpm turbo run build --filter=@argos/frontendfor a frontend-only change).NODE_ENV=test pnpm run --filter @argos/backend start-web— serves the built frontend on http://localhost:3000. Always start backend processes throughbin/start.sh(whatstart-web/start-workeruse); it loads Sentry instrumentation before the modules it patches.
For interactive development, pnpm run dev runs the watchers instead.