AvelonDocs

@avelon

bailiff

@avelon/bailiff

@avelon/bailiff is the architecture linter. It is an ESLint plugin plus a CLI wrapper so violations appear as editor squiggles and as reeve bailiff in CI. Reach for it when you want Avelon's directory map, driver boundary, and generated-file rule enforced rather than requested in a contributing guide.

A template is advice. A linter is a decision.

Installation

bun add -d @avelon/bailiff eslint

Point ESLint at the recommended config. Every rule is on by default; you may downgrade one in avelon.config.ts, and reeve doctor prints the drift.

import bailiff from '@avelon/bailiff'

export default [bailiff.configs.recommended]

Basic Usage

import { runBailiff, explain } from '@avelon/bailiff'

explain('no-orphan-query')

const result = await runBailiff({ cwd: process.cwd(), fix: false })
if (result.exitCode !== 0) {
  throw new Error(result.output)
}
reeve bailiff
reeve bailiff --fix
reeve bailiff --explain no-orphan-query

--fix applies autofixes only. --explain prints the one-sentence reason for a rule and exits. A non-TTY stdout never opens a prompt.

A default scan of . skips node_modules, .next, dist, archive/, and spikes/. Archive and throwaway spikes are not the product linter corpus. @avelon/core and @avelon/orm tests may import bun:test; runtime sources in those packages still may not import bun:*.

Rules

Every rule ships with a documented reason and an autofix or a disable-with-reason escape hatch. A bare // bailiff-disable-next-line with no reason is itself an error.

// bailiff-disable-next-line no-raw-outside-drivers -- pgvector similarity, no IR support yet
const rows = await DB.raw().rpc('match_documents', { embedding })
RuleEnforcesLevel
no-vendor-importNo vendor SDK imported outside a driver package or avelon.config.tserror
no-cross-layerViews cannot import Models. Controllers cannot import Controllers. Models cannot import Http/.error
no-orphan-queryDatabase access only in Models, Actions, Errands, and seedserror
no-unwardedScrivener.unwarded() only in app/Errands/ and database/seeds/error
require-wardEvery model has a warderror
no-raw-outside-driversdriver.raw() only in app/Drivers/ or behind a reasoned disableerror
no-hand-edited-generatedGenerated paths keep the generator bannererror
no-bun-in-core@avelon/core and @avelon/orm may not import bun:*error
no-model-across-boundaryview() receives a serializer, not a model instanceerror
require-requestA write action validates through a Requesterror
no-fake-transactionSequential writes wrapped in try/catch are not a transactionerror
relation-depth.with() chains cannot exceed driver maxRelationDeptherror
no-lib-dumpingThere is no lib/error
no-not-supported-errorCapability gaps are typed away, never thrownerror
attribute-schema-matchModel fillable attributes match database/types.tserror
controller-thinnessA controller action past ten statements suggests an Actionwarn
no-any-publicany in an exported signatureerror
require-disable-reasonEvery disable names a rule and a reason after --error

no-any-public autofixes any to unknown. The other error rules fail the build; you suppress one only with a reason that becomes a searchable list of everywhere the framework was not good enough.

Escape Hatches

Every rule can be disabled inline, and every disable requires a reason. A linter with no escape hatch gets disabled wholesale. A disable with a reason is a roadmap.

// bailiff-disable-next-line no-unwarded -- backfill historical rows before wards existed
const rows = await Scrivener.unwarded(Post).query().get()

Method Reference

Method / exportSignatureDescription
pluginESLint.PluginESLint plugin object with rules and configs.
defaulttypeof pluginDefault export of the ESLint plugin.
plugin.configs.recommendedLinter.ConfigFlat config that enables every Bailiff rule at its default severity.
recommendedLinter.ConfigSame recommended flat config, exported as a stable binding.
rulesRecord<RuleName, Rule.RuleModule>Rule implementations keyed without the plugin prefix.
reasonsReadonly<Record<RuleName, string>>One-sentence reason for each rule.
ruleNamesRuleName[]Rule identifiers in documented order.
explain`(rule: string) => string \undefined`Returns the reason for a rule, or undefined when the name is unknown.
listRules`() => Readonly<Record<RuleName, 'error' \'warn'>>`Default severities, including controller-thinness as warn.
runBailiff(options?: BailiffRunOptions) => Promise<BailiffRunResult>CLI wrapper used by reeve bailiff.
parseBailiffArgs(argv: readonly string[]) => BailiffRunOptionsParses --fix, --explain, and file operands.
defaultIgnoresreadonly string[]Globs skipped by a default scan: node_modules, .next, dist, archive/, spikes/.
classifyFile(filePath: string) => FileLayerMaps a path onto the architecture layer the rules enforce.
isGeneratedPath(filePath: string) => booleanTrue for app/(web)/, app/(api)/, framework/routing/*.generated.ts, and database/types.ts.
isVendorSpecifier(specifier: string) => booleanTrue for known vendor SDKs such as @supabase/supabase-js.
posixPath(filePath: string) => stringNormalizes separators so fixtures and Windows paths classify alike.
GENERATED_BANNERstringSubstring generated files must keep in a leading comment.
RuleNametypeUnion of shipped rule identifiers.
FileLayertypeArchitecture layer names used by path classification.
BailiffRunOptionsinterfacecwd, fix, explain, files, and optional ESLint override.
BailiffRunResultinterfaceexitCode, messages, formatted output, and counts.
BailiffMessageinterfaceOne finding: file, rule, severity, message, line, column.

Testing

Point runBailiff at a temp directory and assert exitCode plus output. Fixture files use conventional application paths so layer classification matches a real app.

import { runBailiff } from '@avelon/bailiff'

const result = await runBailiff({
  cwd: projectRoot,
  files: ['app/Http/Controllers/PostController.ts'],
})
bun test
bun run typecheck