Linting and formatting
Three tools run from one check command, after two graph scripts. This page answers which
rules are on, how strict they are, and when a mistake earns a custom rule instead of a
review comment.
Split the checks into two gates
Section titled “Split the checks into two gates”Impact: HIGH fast lint, Effect checks for free
oxlintruns without type information, so it is fast, runs on every file and never needs a builtdist/. The Effect language service runs insidetsc --noEmit: thetypescript@7binary, patched byeffect-tsgo patchinprepare(see TypeScript config).- The Effect diagnostics are
error, configured in the roottsconfig.json. That turns decided rules into checks with no extra code: no globalDate, timers, random orconsole, no leaking requirements. - The one command:
node scripts/workspace-graph.ts && node scripts/circular.ts && oxfmt --check && oxlint && tsc --noEmit. When it runs belongs to CI pipelines. - Accepted cost: two suppression syntaxes, one per gate. A clone that skipped
prepare, or an editor on its bundled TypeScript, silently misses every Effect diagnostic. Point the editor at the workspace TypeScript.
❌ Incorrect — diagnostics that cannot fail the build are only advice:
"plugins": [{ "name": "@effect/language-service", "ignoreEffectErrorsInTscExitCode": true // the gate is now off}]✅ Correct — the typecheck fails on the Effect rules we decided:
// tsconfig.json (root) — "$schema": "./node_modules/@effect/tsgo/schema.json""plugins": [{ "name": "@effect/language-service", "namespaceImportPackages": ["effect", "@effect/*"], "diagnosticSeverity": { "globalDate": "error", "globalDateInEffect": "error", "globalTimers": "error", "globalTimersInEffect": "error", "globalRandom": "error", "globalRandomInEffect": "error", "globalConsole": "error", "globalConsoleInEffect": "error", "leakingRequirements": "error", "missingEffectServiceDependency": "error", "unsafeEffectTypeAssertion": "error", "anyUnknownInErrorContext": "error" }}]Source: notes/11-repo-operations/linting-and-formatting.md · Decision 1, amended
Use error only; every suppression gives a reason
Section titled “Use error only; every suppression gives a reason”Impact: MEDIUM warnings are never looked at again
- A rule is either worth failing
check, or it is off. A warning that fails nothing gets ignored. reportUnusedDisableDirectives: "error"makes a fix remove its own suppression, andunicorn/no-abusive-eslint-disableforbids a bare disable with no rule name.- A new rule that lands on existing violations gets a per-file ceiling, never a directory override. Lower the number as the file is fixed and delete the entry at zero.
- Accepted cost: whether a reason is a real reason (
-- neededis not) is still judged in review.
❌ Incorrect — a staging level and a bare disable:
// .oxlintrc.json: "app/no-effect-die": "warn" // "bump to error later"
// oxlint-disable-next-linereturn yield* Effect.die(new StoredRuleMissing({ ruleId }))✅ Correct — one rule, one reason, and a ceiling for old code:
// oxlint-disable-next-line app/no-effect-die -- invariant: row inserted earlier in this txreturn yield* Effect.die(new StoredRuleMissing({ ruleId }))
overrides: [{ files: ["src/billing/old.test.ts"], rules: { "app/no-manual-effect-runtime-in-tests": ["error", { maxOccurrences: 4 }] } }]Source: notes/11-repo-operations/linting-and-formatting.md · Decision 2
Write a custom rule only for silent failures
Section titled “Write a custom rule only for silent failures”Impact: HIGH catches what review reliably misses
- A mistake earns a custom rule when it compiles and fails silently: a doubled log line, a
swallowed error, an escalated principal, a leaked secret, a second connection pool, a read of
the host clock. There are nineteen such
app/*rules, each protecting one decided note. - A mistake that is visible in the diff stays in
AGENTS.mdand review. Bad names are visible, so the naming rules were demoted. Barrel files andimport { X } from "effect"are review-only too. - Every rule is AST-only and exempts tests unless stated.
app/no-chained-type-assertion,app/no-id-assertionandapp/no-host-timeapply to tests as well. Each matcher is partial, and the rest stays review. - Accepted cost: nineteen rules of plugin code to own, on a JS plugin API that still moves.
❌ Incorrect — a rule for something the reviewer can already see:
"rules": { "app/no-banned-names": "error", // `OrderManager` is visible in the diff "app/no-live-layer-names": "error" // so is `MainLive`}✅ Correct — rules for mistakes that pass the compiler and the reviewer:
"rules": { "app/no-effect-die": "error", // Effect.die / Effect.orDie need a reasoned disable "app/no-log-and-propagate": "error", // tapError + log = a double log "app/no-bare-ignore": "error", // Effect.ignore without { log } "app/no-principal-in-layer": "error", // CurrentPrincipal provided as a layer "app/no-redacted-value-leak": "error", // Redacted.value in a log or message "app/exhaustive-row-mapper": "error", // fromRow must destructure every column "app/no-id-assertion": "error", // `x as RuleId`; decode or newId instead "app/no-host-time": "error" // Date.parse, DateTime.nowUnsafe, now() in sql // … nineteen in total}Source: notes/11-repo-operations/linting-and-formatting.md · Decision 3, amended
Enforce import boundaries with built-in rules
Section titled “Enforce import boundaries with built-in rules”Impact: HIGH decided boundaries stop relying on memory
- Platform code only in platform entry files (
bin.ts, a Worker’sisolate.ts, the alchemy stack file, the migrate entry).effect/sql*only in repos,infra/andpackages/db.effect/http-api*only inhttp.ts, contracts and entry files. A purepolicy.ts. A feature’s repo and policy private to it.testing.tsand fixtures only from tests. - Foundation (
infra/,identity/,orgs/,authz/) never imports a domain feature, andinfra/is a leaf. These areno-restricted-importsoverrides, notapp/*rules. - Use
patterns, notpaths, so an index and its subpaths are both caught. The globs belong to coupling and cohesion. Change them there first.
❌ Incorrect — one feature reaching into another’s private files:
import { RulesRepo } from "../rules/rules-repo.ts"✅ Correct — the boundary is a lint error with a message that says why:
{ "files": ["apps/server/src/**/*.ts"], "rules": { "no-restricted-imports": ["error", { "patterns": [{ "group": ["../*/*-repo.ts", "../*/policy.ts"], "message": "Another feature's repo and policy are private. Call its service (coupling-and-cohesion §1)." }]}]}}Source: notes/11-repo-operations/linting-and-formatting.md · Decision 3, amended
Keep the plugin as a tested workspace package
Section titled “Keep the plugin as a tested workspace package”Impact: MEDIUM false positives are caught before your PR
- Every rule has a
*.test.tswith valid and invalid cases. It pins down what the rule catches and what it must leave alone, such as tests being exempt. - Every rule’s message names the note it protects, so the reason travels with the error.
- Accepted cost: one more workspace package. It is a dev-only leaf that no app depends on.
❌ Incorrect — one untested script, rationale buried in a header comment:
scripts/oxlint-plugins/app.mjs // 19 matchers, no tests, no types✅ Correct — a package with one rule and one test per file:
packages/oxlint-plugin/ package.json "@app/oxlint-plugin", dependency @oxlint/plugins index.ts definePlugin({ meta: { name: "app" }, rules }) rules/no-effect-die.ts meta.docs.description names the note it protects rules/no-effect-die.test.ts valid / invalid casesSource: notes/11-repo-operations/linting-and-formatting.md · Decision 4
Format with oxfmt defaults and a format-only hook
Section titled “Format with oxfmt defaults and a format-only hook”Impact: LOW no style debates, no format noise in review
- oxfmt with its defaults. The config only lists ignores. Migrations are excluded because they stay plain SQL, read exactly as they run.
- The pre-commit hook formats staged files and does nothing else: no lint, no typecheck. It is a
committed
.githooks/pre-commitscript, found throughcore.hooksPath, whichpreparesets. It formats and re-stages fully staged files only, and skips a partially staged file with a message (see local development setup). - The hook is a convenience;
oxfmt --checkincheckis the guarantee. Accepted cost: oxfmt is 0.x, so bump it in its own commit. The hook can be skipped with--no-verify.
❌ Incorrect — a hook that makes every commit slow:
# pre-commitoxfmt <staged files> && oxlint && tsc --noEmit✅ Correct — defaults plus ignores, and a hook that only formats:
{ "ignorePatterns": ["dist", "**/migrations/*.sql"] }Source: notes/11-repo-operations/linting-and-formatting.md · Decision 5, amended
Deferred
Section titled “Deferred”app/no-banned-names,app/no-live-layer-names,app/group-middleware-last— trigger: the same mistake is caught in review a second time; that re-promotes that one rule.- A lint check for barrels or barrel imports — trigger: review catches the same barrel or barrel-import mistake a second time.
app/effect-fn-named(anEffect.fnwithout a name, or awithSpaninside one) — trigger: the first unnamedEffect.fnor doubled span found in our code by review or in a trace.