@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 })
| Rule | Enforces | Level |
|---|---|---|
no-vendor-import | No vendor SDK imported outside a driver package or avelon.config.ts | error |
no-cross-layer | Views cannot import Models. Controllers cannot import Controllers. Models cannot import Http/. | error |
no-orphan-query | Database access only in Models, Actions, Errands, and seeds | error |
no-unwarded | Scrivener.unwarded() only in app/Errands/ and database/seeds/ | error |
require-ward | Every model has a ward | error |
no-raw-outside-drivers | driver.raw() only in app/Drivers/ or behind a reasoned disable | error |
no-hand-edited-generated | Generated paths keep the generator banner | error |
no-bun-in-core | @avelon/core and @avelon/orm may not import bun:* | error |
no-model-across-boundary | view() receives a serializer, not a model instance | error |
require-request | A write action validates through a Request | error |
no-fake-transaction | Sequential writes wrapped in try/catch are not a transaction | error |
relation-depth | .with() chains cannot exceed driver maxRelationDepth | error |
no-lib-dumping | There is no lib/ | error |
no-not-supported-error | Capability gaps are typed away, never thrown | error |
attribute-schema-match | Model fillable attributes match database/types.ts | error |
controller-thinness | A controller action past ten statements suggests an Action | warn |
no-any-public | any in an exported signature | error |
require-disable-reason | Every 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 / export | Signature | Description | |
|---|---|---|---|
plugin | ESLint.Plugin | ESLint plugin object with rules and configs. | |
default | typeof plugin | Default export of the ESLint plugin. | |
plugin.configs.recommended | Linter.Config | Flat config that enables every Bailiff rule at its default severity. | |
recommended | Linter.Config | Same recommended flat config, exported as a stable binding. | |
rules | Record<RuleName, Rule.RuleModule> | Rule implementations keyed without the plugin prefix. | |
reasons | Readonly<Record<RuleName, string>> | One-sentence reason for each rule. | |
ruleNames | RuleName[] | 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[]) => BailiffRunOptions | Parses --fix, --explain, and file operands. | |
defaultIgnores | readonly string[] | Globs skipped by a default scan: node_modules, .next, dist, archive/, spikes/. | |
classifyFile | (filePath: string) => FileLayer | Maps a path onto the architecture layer the rules enforce. | |
isGeneratedPath | (filePath: string) => boolean | True for app/(web)/, app/(api)/, framework/routing/*.generated.ts, and database/types.ts. | |
isVendorSpecifier | (specifier: string) => boolean | True for known vendor SDKs such as @supabase/supabase-js. | |
posixPath | (filePath: string) => string | Normalizes separators so fixtures and Windows paths classify alike. | |
GENERATED_BANNER | string | Substring generated files must keep in a leading comment. | |
RuleName | type | Union of shipped rule identifiers. | |
FileLayer | type | Architecture layer names used by path classification. | |
BailiffRunOptions | interface | cwd, fix, explain, files, and optional ESLint override. | |
BailiffRunResult | interface | exitCode, messages, formatted output, and counts. | |
BailiffMessage | interface | One 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