Skip to content

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.

Impact: HIGH fast lint, Effect checks for free

  • oxlint runs without type information, so it is fast, runs on every file and never needs a built dist/. The Effect language service runs inside tsc --noEmit: the typescript@7 binary, patched by effect-tsgo patch in prepare (see TypeScript config).
  • The Effect diagnostics are error, configured in the root tsconfig.json. That turns decided rules into checks with no extra code: no global Date, timers, random or console, 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:

tsconfig.json
"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, and unicorn/no-abusive-eslint-disable forbids 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 (-- needed is 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-line
return 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 tx
return 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.md and review. Bad names are visible, so the naming rules were demoted. Barrel files and import { 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-assertion and app/no-host-time apply 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’s isolate.ts, the alchemy stack file, the migrate entry). effect/sql* only in repos, infra/ and packages/db. effect/http-api* only in http.ts, contracts and entry files. A pure policy.ts. A feature’s repo and policy private to it. testing.ts and fixtures only from tests.
  • Foundation (infra/, identity/, orgs/, authz/) never imports a domain feature, and infra/ is a leaf. These are no-restricted-imports overrides, not app/* rules.
  • Use patterns, not paths, 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:

apps/server/src/billing/service.ts
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.ts with 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 cases

Source: 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-commit script, found through core.hooksPath, which prepare sets. 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 --check in check is 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:

Terminal window
# pre-commit
oxfmt <staged files> && oxlint && tsc --noEmit

✅ Correct — defaults plus ignores, and a hook that only formats:

.oxfmtrc.json
{ "ignorePatterns": ["dist", "**/migrations/*.sql"] }

Source: notes/11-repo-operations/linting-and-formatting.md · Decision 5, amended

  • 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 (an Effect.fn without a name, or a withSpan inside one) — trigger: the first unnamed Effect.fn or doubled span found in our code by review or in a trace.