@avelonjs/bailiff 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Yannelli
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # @avelonjs/bailiff
2
+
3
+ `@avelonjs/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.
4
+
5
+ A template is advice. A linter is a decision.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ bun add -d @avelonjs/bailiff eslint
11
+ ```
12
+
13
+ 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.
14
+
15
+ ```ts
16
+ import bailiff from '@avelonjs/bailiff'
17
+
18
+ export default [bailiff.configs.recommended]
19
+ ```
20
+
21
+ ## Basic Usage
22
+
23
+ ```ts
24
+ import { runBailiff, explain } from '@avelonjs/bailiff'
25
+
26
+ explain('no-orphan-query')
27
+
28
+ const result = await runBailiff({ cwd: process.cwd(), fix: false })
29
+ if (result.exitCode !== 0) {
30
+ throw new Error(result.output)
31
+ }
32
+ ```
33
+
34
+ ```sh
35
+ reeve bailiff
36
+ reeve bailiff --fix
37
+ reeve bailiff --explain no-orphan-query
38
+ ```
39
+
40
+ `--fix` applies autofixes only. `--explain` prints the one-sentence reason for a rule and exits. A non-TTY stdout never opens a prompt.
41
+
42
+ A default scan of `.` skips `node_modules`, `.next`, `dist`, `archive/`, and `spikes/`. Archive and throwaway spikes are not the product linter corpus. `@avelonjs/core` and `@avelonjs/orm` tests may import `bun:test`; runtime sources in those packages still may not import `bun:*`.
43
+
44
+ ## Rules
45
+
46
+ 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.
47
+
48
+ ```ts
49
+ // bailiff-disable-next-line no-raw-outside-drivers -- pgvector similarity, no IR support yet
50
+ const rows = await DB.raw().rpc('match_documents', { embedding })
51
+ ```
52
+
53
+ | Rule | Enforces | Level |
54
+ | -------------------------- | ------------------------------------------------------------------------------------------------ | ----- |
55
+ | `no-vendor-import` | No vendor SDK imported outside a driver package or `avelon.config.ts` | error |
56
+ | `no-cross-layer` | Views cannot import Models. Controllers cannot import Controllers. Models cannot import `Http/`. | error |
57
+ | `no-orphan-query` | Database access only in Models, Actions, Errands, and seeds | error |
58
+ | `no-unwarded` | `Scrivener.unwarded()` only in `app/Errands/` and `database/seeds/` | error |
59
+ | `require-ward` | Every model has a ward | error |
60
+ | `no-raw-outside-drivers` | `driver.raw()` only in `app/Drivers/` or behind a reasoned disable | error |
61
+ | `no-hand-edited-generated` | Generated paths keep the generator banner | error |
62
+ | `no-bun-in-core` | `@avelonjs/core` and `@avelonjs/orm` may not import `bun:*` | error |
63
+ | `no-model-across-boundary` | `view()` receives a serializer, not a model instance | error |
64
+ | `require-request` | A write action validates through a Request | error |
65
+ | `no-fake-transaction` | Sequential writes wrapped in try/catch are not a transaction | error |
66
+ | `relation-depth` | `.with()` chains cannot exceed driver `maxRelationDepth` | error |
67
+ | `no-lib-dumping` | There is no `lib/` | error |
68
+ | `no-not-supported-error` | Capability gaps are typed away, never thrown | error |
69
+ | `attribute-schema-match` | Model fillable attributes match `database/types.ts` | error |
70
+ | `controller-thinness` | A controller action past ten statements suggests an Action | warn |
71
+ | `no-any-public` | `any` in an exported signature | error |
72
+ | `require-disable-reason` | Every disable names a rule and a reason after `--` | error |
73
+
74
+ `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.
75
+
76
+ ## Escape Hatches
77
+
78
+ 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.
79
+
80
+ ```ts
81
+ // bailiff-disable-next-line no-unwarded -- backfill historical rows before wards existed
82
+ const rows = await Scrivener.unwarded(Post).query().get()
83
+ ```
84
+
85
+ ## Method Reference
86
+
87
+ | Method / export | Signature | Description |
88
+ | ---------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
89
+ | `plugin` | `ESLint.Plugin` | ESLint plugin object with `rules` and `configs`. |
90
+ | `default` | `typeof plugin` | Default export of the ESLint plugin. |
91
+ | `plugin.configs.recommended` | `Linter.Config` | Flat config that enables every Bailiff rule at its default severity. |
92
+ | `recommended` | `Linter.Config` | Same recommended flat config, exported as a stable binding. |
93
+ | `rules` | `Record<RuleName, Rule.RuleModule>` | Rule implementations keyed without the plugin prefix. |
94
+ | `reasons` | `Readonly<Record<RuleName, string>>` | One-sentence reason for each rule. |
95
+ | `ruleNames` | `RuleName[]` | Rule identifiers in documented order. |
96
+ | `explain` | `(rule: string) => string \| undefined` | Returns the reason for a rule, or undefined when the name is unknown. |
97
+ | `listRules` | `() => Readonly<Record<RuleName, 'error' \| 'warn'>>` | Default severities, including `controller-thinness` as warn. |
98
+ | `runBailiff` | `(options?: BailiffRunOptions) => Promise<BailiffRunResult>` | CLI wrapper used by `reeve bailiff`. |
99
+ | `parseBailiffArgs` | `(argv: readonly string[]) => BailiffRunOptions` | Parses `--fix`, `--explain`, and file operands. |
100
+ | `defaultIgnores` | `readonly string[]` | Globs skipped by a default scan: `node_modules`, `.next`, `dist`, `archive/`, `spikes/`. |
101
+ | `classifyFile` | `(filePath: string) => FileLayer` | Maps a path onto the architecture layer the rules enforce. |
102
+ | `isGeneratedPath` | `(filePath: string) => boolean` | True for `app/(web)/`, `app/(api)/`, `framework/routing/*.generated.ts`, and `database/types.ts`. |
103
+ | `isVendorSpecifier` | `(specifier: string) => boolean` | True for known vendor SDKs such as `@supabase/supabase-js`. |
104
+ | `posixPath` | `(filePath: string) => string` | Normalizes separators so fixtures and Windows paths classify alike. |
105
+ | `GENERATED_BANNER` | `string` | Substring generated files must keep in a leading comment. |
106
+ | `RuleName` | `type` | Union of shipped rule identifiers. |
107
+ | `FileLayer` | `type` | Architecture layer names used by path classification. |
108
+ | `BailiffRunOptions` | `interface` | `cwd`, `fix`, `explain`, `files`, and optional ESLint override. |
109
+ | `BailiffRunResult` | `interface` | `exitCode`, messages, formatted `output`, and counts. |
110
+ | `BailiffMessage` | `interface` | One finding: file, rule, severity, message, line, column. |
111
+
112
+ ## Testing
113
+
114
+ Point `runBailiff` at a temp directory and assert `exitCode` plus `output`. Fixture files use conventional application paths so layer classification matches a real app.
115
+
116
+ ```ts
117
+ import { runBailiff } from '@avelonjs/bailiff'
118
+
119
+ const result = await runBailiff({
120
+ cwd: projectRoot,
121
+ files: ['app/Http/Controllers/PostController.ts'],
122
+ })
123
+ ```
124
+
125
+ ```sh
126
+ bun test
127
+ bun run typecheck
128
+ ```
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@avelonjs/bailiff",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "ESLint plugin that enforces Avelon architecture rules.",
6
+ "license": "MIT",
7
+ "author": "Ryan Yannelli <ryanyannelli@gmail.com>",
8
+ "homepage": "https://github.com/yannelli/avelon",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/yannelli/avelon.git",
12
+ "directory": "packages/bailiff"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/yannelli/avelon/issues"
16
+ },
17
+ "keywords": [
18
+ "avelon",
19
+ "typescript",
20
+ "eslint",
21
+ "linter"
22
+ ],
23
+ "type": "module",
24
+ "publishConfig": {
25
+ "access": "public"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "exports": {
33
+ ".": "./src/index.ts"
34
+ },
35
+ "scripts": {
36
+ "test": "bun test",
37
+ "typecheck": "tsc --noEmit"
38
+ },
39
+ "dependencies": {
40
+ "@typescript-eslint/parser": "8.46.2",
41
+ "eslint": "9.39.1"
42
+ },
43
+ "peerDependencies": {
44
+ "eslint": "^9.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@types/bun": "1.3.14",
48
+ "typescript": "5.9.3"
49
+ },
50
+ "engines": {
51
+ "bun": ">=1.3.14"
52
+ }
53
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { ESLint, type Linter } from 'eslint'
2
+ import tsParser from '@typescript-eslint/parser'
3
+
4
+ import { plugin, recommended } from './plugin'
5
+ import { explain, reasons, ruleNames, type RuleName } from './reasons'
6
+
7
+ /** Globs skipped by a default `reeve bailiff` scan of the working tree. */
8
+ export const defaultIgnores = [
9
+ '**/node_modules/**',
10
+ '**/.next/**',
11
+ '**/dist/**',
12
+ '**/archive/**',
13
+ '**/spikes/**',
14
+ ] as const
15
+
16
+ /** Options accepted by the Bailiff CLI wrapper. */
17
+ export interface BailiffRunOptions {
18
+ /** Project root. Defaults to `process.cwd()`. */
19
+ cwd?: string
20
+ /** Apply ESLint autofixes. */
21
+ fix?: boolean
22
+ /** Print a rule reason and exit without linting. */
23
+ explain?: string
24
+ /** File globs. Defaults to `.` with `defaultIgnores` applied. */
25
+ files?: readonly string[]
26
+ /** Extra ESLint config merged after the recommended config. */
27
+ override?: Linter.Config
28
+ }
29
+
30
+ /** One reported Bailiff finding. */
31
+ export interface BailiffMessage {
32
+ filePath: string
33
+ ruleId: string | null
34
+ severity: 0 | 1 | 2
35
+ message: string
36
+ line: number
37
+ column: number
38
+ }
39
+
40
+ /** Result of a Bailiff run, including the process exit code. */
41
+ export interface BailiffRunResult {
42
+ exitCode: number
43
+ explanation?: string
44
+ messages: BailiffMessage[]
45
+ output: string
46
+ errorCount: number
47
+ warningCount: number
48
+ }
49
+
50
+ function formatMessages(messages: BailiffMessage[]): string {
51
+ if (messages.length === 0) return 'Bailiff is clean.\n'
52
+ return `${messages
53
+ .map(
54
+ (message) =>
55
+ `${message.filePath}:${message.line}:${message.column} ${message.ruleId ?? 'bailiff'} ${message.message}`,
56
+ )
57
+ .join('\n')}\n`
58
+ }
59
+
60
+ /** Lists every shipped rule and its default severity. */
61
+ export function listRules(): Readonly<Record<RuleName, 'error' | 'warn'>> {
62
+ return Object.fromEntries(
63
+ ruleNames.map((name) => [name, name === 'controller-thinness' ? 'warn' : 'error']),
64
+ ) as Record<RuleName, 'error' | 'warn'>
65
+ }
66
+
67
+ /**
68
+ * Runs Bailiff. `--explain` prints a reason. `--fix` applies autofixes. A non-TTY caller never
69
+ * sees a prompt; this wrapper is the implementation behind `reeve bailiff`.
70
+ */
71
+ export async function runBailiff(options: BailiffRunOptions = {}): Promise<BailiffRunResult> {
72
+ if (options.explain !== undefined) {
73
+ const reason = explain(options.explain)
74
+ if (reason === undefined) {
75
+ const output = `Unknown Bailiff rule '${options.explain}'. Known rules: ${ruleNames.join(', ')}.\n`
76
+ return { exitCode: 1, messages: [], output, errorCount: 1, warningCount: 0 }
77
+ }
78
+ const output = `${options.explain}: ${reason}\n`
79
+ return {
80
+ exitCode: 0,
81
+ explanation: reason,
82
+ messages: [],
83
+ output,
84
+ errorCount: 0,
85
+ warningCount: 0,
86
+ }
87
+ }
88
+
89
+ const cwd = options.cwd ?? process.cwd()
90
+ const eslint = new ESLint({
91
+ cwd,
92
+ overrideConfigFile: true,
93
+ fix: options.fix ?? false,
94
+ overrideConfig: [
95
+ {
96
+ ignores: [...defaultIgnores],
97
+ },
98
+ {
99
+ files: ['**/*.{ts,tsx,mts,cts,js,jsx}'],
100
+ languageOptions: {
101
+ parser: tsParser,
102
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
103
+ },
104
+ },
105
+ recommended,
106
+ ...(options.override === undefined ? [] : [options.override]),
107
+ ],
108
+ })
109
+
110
+ const targets = options.files === undefined ? ['.'] : [...options.files]
111
+ const results = await eslint.lintFiles(targets)
112
+ if (options.fix) await ESLint.outputFixes(results)
113
+
114
+ const messages: BailiffMessage[] = results.flatMap((result) =>
115
+ result.messages.map((message) => ({
116
+ filePath: result.filePath,
117
+ ruleId: message.ruleId,
118
+ severity: message.severity,
119
+ message: message.message,
120
+ line: message.line,
121
+ column: message.column,
122
+ })),
123
+ )
124
+ const errorCount = results.reduce((sum, result) => sum + result.errorCount, 0)
125
+ const warningCount = results.reduce((sum, result) => sum + result.warningCount, 0)
126
+ return {
127
+ exitCode: errorCount > 0 ? 1 : 0,
128
+ messages,
129
+ output: formatMessages(messages),
130
+ errorCount,
131
+ warningCount,
132
+ }
133
+ }
134
+
135
+ /** Parses `reeve bailiff` argv after the command name has been stripped. */
136
+ export function parseBailiffArgs(argv: readonly string[]): BailiffRunOptions {
137
+ const files: string[] = []
138
+ const options: BailiffRunOptions = { files }
139
+ for (let i = 0; i < argv.length; i += 1) {
140
+ const arg = argv[i]
141
+ if (arg === '--fix') {
142
+ options.fix = true
143
+ continue
144
+ }
145
+ if (arg === '--explain') {
146
+ options.explain = argv[i + 1]
147
+ i += 1
148
+ continue
149
+ }
150
+ if (arg?.startsWith('--explain=')) {
151
+ options.explain = arg.slice('--explain='.length)
152
+ continue
153
+ }
154
+ if (arg !== undefined && !arg.startsWith('-')) files.push(arg)
155
+ }
156
+ if (files.length === 0) delete options.files
157
+ return options
158
+ }
159
+
160
+ export { reasons, ruleNames }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export { default, plugin, recommended } from './plugin'
2
+ export { rules } from './rules'
3
+ export { reasons, ruleNames, explain, type RuleName } from './reasons'
4
+ export {
5
+ runBailiff,
6
+ parseBailiffArgs,
7
+ listRules,
8
+ defaultIgnores,
9
+ type BailiffRunOptions,
10
+ type BailiffRunResult,
11
+ type BailiffMessage,
12
+ } from './cli'
13
+ export {
14
+ classifyFile,
15
+ isGeneratedPath,
16
+ isVendorSpecifier,
17
+ posixPath,
18
+ GENERATED_BANNER,
19
+ type FileLayer,
20
+ } from './paths'
package/src/paths.ts ADDED
@@ -0,0 +1,179 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, posix, relative, sep } from 'node:path'
3
+
4
+ /** Layer a source file belongs to, used by architecture rules. */
5
+ export type FileLayer =
6
+ | 'model'
7
+ | 'view'
8
+ | 'controller'
9
+ | 'action'
10
+ | 'errand'
11
+ | 'seed'
12
+ | 'page'
13
+ | 'component'
14
+ | 'driver'
15
+ | 'config'
16
+ | 'core'
17
+ | 'orm'
18
+ | 'generated'
19
+ | 'ward'
20
+ | 'lib'
21
+ | 'request'
22
+ | 'other'
23
+
24
+ const GENERATED_MARKERS = [
25
+ '/app/(web)/',
26
+ '/app/(api)/',
27
+ '/framework/routing/',
28
+ '/database/types.ts',
29
+ ]
30
+
31
+ const VENDOR_PACKAGES = [
32
+ '@supabase/supabase-js',
33
+ '@supabase/ssr',
34
+ '@supabase/auth-js',
35
+ 'postgres',
36
+ 'pg',
37
+ 'resend',
38
+ 'stripe',
39
+ 'openai',
40
+ 'next-auth',
41
+ '@auth/core',
42
+ '@clerk/nextjs',
43
+ '@clerk/backend',
44
+ 'hono',
45
+ ]
46
+
47
+ /** Normalizes a file path to posix so fixtures and Windows paths classify the same way. */
48
+ export function posixPath(filePath: string): string {
49
+ return filePath.split(sep).join(posix.sep)
50
+ }
51
+
52
+ /** Returns true when `filePath` is framework-generated and must not be hand-edited. */
53
+ export function isGeneratedPath(filePath: string): boolean {
54
+ const normalized = posixPath(filePath)
55
+ if (GENERATED_MARKERS.some((marker) => normalized.includes(marker))) return true
56
+ return /\/framework\/routing\/.*\.generated\.ts$/.test(normalized)
57
+ }
58
+
59
+ /** Returns true when the specifier is a known vendor SDK. */
60
+ export function isVendorSpecifier(specifier: string): boolean {
61
+ if (specifier.startsWith('@supabase/')) return true
62
+ if (specifier.startsWith('@aws-sdk/')) return true
63
+ if (specifier.startsWith('@neondatabase/')) return true
64
+ return VENDOR_PACKAGES.includes(specifier)
65
+ }
66
+
67
+ /** Classifies a source file into the architecture layer Bailiff enforces. */
68
+ export function classifyFile(filePath: string): FileLayer {
69
+ const normalized = posixPath(filePath)
70
+ if (/(?:^|\/)avelon\.config\.(ts|js|mts|cts)$/.test(normalized)) return 'config'
71
+ if (normalized.includes('/app/(web)/') || normalized.includes('/app/(api)/')) return 'page'
72
+ if (/\/page\.tsx$/.test(normalized) || /\/route\.ts$/.test(normalized)) return 'page'
73
+ if (isGeneratedPath(normalized)) return 'generated'
74
+ if (normalized.includes('/packages/core/') || /(?:^|\/)packages\/core\//.test(normalized)) {
75
+ return 'core'
76
+ }
77
+ if (normalized.includes('/packages/orm/')) return 'orm'
78
+ if (
79
+ normalized.includes('/packages/supabase/') ||
80
+ normalized.includes('/packages/postgres/') ||
81
+ normalized.includes('/packages/resend/') ||
82
+ normalized.includes('/app/Drivers/')
83
+ ) {
84
+ return 'driver'
85
+ }
86
+ if (normalized.includes('/app/Models/')) return 'model'
87
+ if (normalized.includes('/app/Http/Views/')) return 'view'
88
+ if (normalized.includes('/app/Http/Controllers/')) return 'controller'
89
+ if (normalized.includes('/app/Http/Requests/')) return 'request'
90
+ if (normalized.includes('/app/Actions/')) return 'action'
91
+ if (normalized.includes('/app/Errands/')) return 'errand'
92
+ if (normalized.includes('/database/seeds/')) return 'seed'
93
+ if (normalized.includes('/app/Wards/')) return 'ward'
94
+ if (/(?:^|\/)lib\//.test(normalized) || normalized.includes('/src/lib/')) return 'lib'
95
+ if (normalized.includes('/resources/views/') || /\/components\//.test(normalized)) {
96
+ return 'component'
97
+ }
98
+ if (normalized.endsWith('.tsx')) return 'component'
99
+ return 'other'
100
+ }
101
+
102
+ /** Layers where unwarded queries and raw database work are allowed. */
103
+ export function allowsUnwarded(layer: FileLayer): boolean {
104
+ return layer === 'errand' || layer === 'seed'
105
+ }
106
+
107
+ /** Layers where application code may execute queries. */
108
+ export function allowsQuery(layer: FileLayer): boolean {
109
+ return layer === 'model' || layer === 'action' || layer === 'errand' || layer === 'seed'
110
+ }
111
+
112
+ /** Layers where vendor SDKs may be imported. */
113
+ export function allowsVendor(layer: FileLayer): boolean {
114
+ return layer === 'driver' || layer === 'config'
115
+ }
116
+
117
+ /** Returns true when the path is a test file rather than runtime source. */
118
+ export function isTestFile(filePath: string): boolean {
119
+ const normalized = posixPath(filePath)
120
+ if (/(?:^|\/)tests?\//.test(normalized)) return true
121
+ return /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/.test(normalized)
122
+ }
123
+
124
+ /** Returns true when the file must not import bun:*. Tests may import bun:test. */
125
+ export function bansBun(layer: FileLayer, filePath: string): boolean {
126
+ if (isTestFile(filePath)) return false
127
+ return layer === 'core' || layer === 'orm'
128
+ }
129
+
130
+ /** Resolves `database/types.ts` walking up from the linted file. */
131
+ export function findSchemaTypes(filePath: string, cwd = process.cwd()): string | undefined {
132
+ let directory = dirname(filePath)
133
+ const root = posixPath(cwd)
134
+ for (let i = 0; i < 12; i += 1) {
135
+ const candidate = join(directory, 'database', 'types.ts')
136
+ if (existsSync(candidate)) return candidate
137
+ const parent = dirname(directory)
138
+ if (parent === directory) break
139
+ if (posixPath(relative(root, directory)).startsWith('..')) break
140
+ directory = parent
141
+ }
142
+ const fallback = join(cwd, 'database', 'types.ts')
143
+ return existsSync(fallback) ? fallback : undefined
144
+ }
145
+
146
+ /** Reads generated schema column names for a table when `database/types.ts` exists. */
147
+ export function schemaColumns(filePath: string, table: string, cwd?: string): string[] | undefined {
148
+ const typesPath = findSchemaTypes(filePath, cwd)
149
+ if (typesPath === undefined) return undefined
150
+ const source = readFileSync(typesPath, 'utf8')
151
+ const escaped = table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
152
+ const block = new RegExp(`${escaped}\\s*:\\s*\\{([^}]+)\\}`, 'm').exec(source)
153
+ if (block === null) return undefined
154
+ const columns: string[] = []
155
+ const body = block[1]
156
+ if (body === undefined) return undefined
157
+ for (const match of body.matchAll(/^\s*([A-Za-z_][\w]*)\s*:/gm)) {
158
+ const name = match[1]
159
+ if (name !== undefined) columns.push(name)
160
+ }
161
+ return columns
162
+ }
163
+
164
+ /** Returns true when a matching ward file exists for a model class. */
165
+ export function wardExists(filePath: string, modelName: string): boolean {
166
+ const normalized = posixPath(filePath)
167
+ const marker = '/app/Models/'
168
+ const index = normalized.lastIndexOf(marker)
169
+ if (index === -1) return true
170
+ const appRoot = filePath.slice(0, filePath.lastIndexOf(`${sep}Models${sep}`))
171
+ const candidates = [
172
+ join(appRoot, 'Wards', `${modelName}Ward.ts`),
173
+ join(dirname(dirname(filePath)), 'Wards', `${modelName}Ward.ts`),
174
+ ]
175
+ return candidates.some((candidate) => existsSync(candidate))
176
+ }
177
+
178
+ /** Banner generated files must keep as their first non-empty comment. */
179
+ export const GENERATED_BANNER = 'Generated by'
package/src/plugin.ts ADDED
@@ -0,0 +1,33 @@
1
+ import type { ESLint, Linter } from 'eslint'
2
+
3
+ import { ruleNames } from './reasons'
4
+ import { rules } from './rules'
5
+
6
+ const plugin = {
7
+ meta: {
8
+ name: '@avelonjs/bailiff',
9
+ version: '0.0.0',
10
+ },
11
+ rules,
12
+ configs: {} as Record<string, Linter.Config>,
13
+ } satisfies ESLint.Plugin & { configs: Record<string, Linter.Config> }
14
+
15
+ const recommendedRules: Linter.RulesRecord = Object.fromEntries(
16
+ ruleNames.map((name) => [
17
+ `@avelonjs/bailiff/${name}`,
18
+ name === 'controller-thinness' ? 'warn' : 'error',
19
+ ]),
20
+ )
21
+
22
+ /** Flat ESLint config that enables every Bailiff rule at its default severity. */
23
+ export const recommended: Linter.Config = {
24
+ name: '@avelonjs/bailiff/recommended',
25
+ plugins: { '@avelonjs/bailiff': plugin },
26
+ rules: recommendedRules,
27
+ }
28
+
29
+ plugin.configs.recommended = recommended
30
+
31
+ /** ESLint plugin plus the recommended config that turns every rule on. */
32
+ export default plugin
33
+ export { plugin }
package/src/reasons.ts ADDED
@@ -0,0 +1,48 @@
1
+ /** One-sentence reasons. A rule you cannot explain in one sentence does not belong. */
2
+ export const reasons = {
3
+ 'no-vendor-import':
4
+ 'One vendor SDK import in application code makes the driver system decorative.',
5
+ 'no-cross-layer':
6
+ 'Views importing models, controllers importing controllers, or models importing Http/ collapse the directory map.',
7
+ 'no-orphan-query':
8
+ 'Database access belongs in models, actions, errands, and seeds, never in a page or component.',
9
+ 'no-unwarded': 'Unwarded queries outside errands and seeds are how row access leaks.',
10
+ 'require-ward': 'A model without a ward is a table anybody can read.',
11
+ 'no-raw-outside-drivers':
12
+ 'driver.raw() is the non-portable escape hatch and may live only in app/Drivers/.',
13
+ 'no-hand-edited-generated':
14
+ 'A hand-edit to generated output disappears at the next sync and takes the upgrade path with it.',
15
+ 'no-bun-in-core':
16
+ '@avelonjs/core and @avelonjs/orm must stay runtime-portable, so bun:* imports are banned there.',
17
+ 'no-model-across-boundary':
18
+ 'A model instance passed to view() becomes a serialization failure at the adapter boundary.',
19
+ 'require-request':
20
+ 'Write actions validate through a Request so invalid input never reaches a model.',
21
+ 'no-fake-transaction':
22
+ 'Sequential writes wrapped in try/catch and named like a transaction are not atomic.',
23
+ 'relation-depth':
24
+ '.with() chains past the configured driver maxRelationDepth will fail at the vendor, not at types.',
25
+ 'no-lib-dumping':
26
+ 'There is no lib/; a file has a home in the application tree or it does not exist.',
27
+ 'no-not-supported-error':
28
+ 'Throwing on a missing capability is a design failure; the capability map should have removed the method.',
29
+ 'attribute-schema-match':
30
+ 'Model fillable attributes must match database/types.ts so a column rename is a type error.',
31
+ 'controller-thinness':
32
+ 'A controller action past a handful of statements belongs in app/Actions/.',
33
+ 'no-any-public':
34
+ 'any in an exported signature is banned; unknown plus a narrowing function is the move.',
35
+ 'require-disable-reason':
36
+ 'A disable without a reason is how the linter gets turned off wholesale.',
37
+ } as const
38
+
39
+ /** Rule identifiers Bailiff ships. */
40
+ export type RuleName = keyof typeof reasons
41
+
42
+ /** Stable rule names in documented order. */
43
+ export const ruleNames = Object.keys(reasons) as RuleName[]
44
+
45
+ /** Returns the one-sentence reason for a rule, or undefined when the name is unknown. */
46
+ export function explain(rule: string): string | undefined {
47
+ return reasons[rule as RuleName]
48
+ }
package/src/rules.ts ADDED
@@ -0,0 +1,577 @@
1
+ import type { Rule } from 'eslint'
2
+
3
+ interface Node {
4
+ type: string
5
+ }
6
+
7
+ interface Comment {
8
+ value: string
9
+ loc?: { end: { line: number } } | null
10
+ }
11
+
12
+ import {
13
+ GENERATED_BANNER,
14
+ allowsQuery,
15
+ allowsUnwarded,
16
+ allowsVendor,
17
+ bansBun,
18
+ classifyFile,
19
+ isGeneratedPath,
20
+ isVendorSpecifier,
21
+ schemaColumns,
22
+ wardExists,
23
+ type FileLayer,
24
+ } from './paths'
25
+ import { reasons, type RuleName } from './reasons'
26
+ import { disableComments, report } from './suppress'
27
+
28
+ interface CallLike {
29
+ type: string
30
+ callee?: {
31
+ type: string
32
+ name?: string
33
+ property?: { type: string; name?: string }
34
+ object?: { type: string; name?: string; property?: { name?: string } }
35
+ }
36
+ arguments?: unknown[]
37
+ }
38
+
39
+ interface ImportLike {
40
+ type: string
41
+ source?: { type: string; value?: string | null }
42
+ }
43
+
44
+ function specifierOf(node: ImportLike): string | undefined {
45
+ if (node.source?.type !== 'Literal') return undefined
46
+ return typeof node.source.value === 'string' ? node.source.value : undefined
47
+ }
48
+
49
+ function callName(node: CallLike): string {
50
+ const callee = node.callee
51
+ if (callee === undefined) return ''
52
+ if (callee.type === 'Identifier') return callee.name ?? ''
53
+ if (callee.type === 'MemberExpression') {
54
+ const object =
55
+ callee.object?.type === 'Identifier'
56
+ ? (callee.object.name ?? '')
57
+ : (callee.object?.property?.name ?? '')
58
+ const property = callee.property?.type === 'Identifier' ? (callee.property.name ?? '') : ''
59
+ return `${object}.${property}`
60
+ }
61
+ return ''
62
+ }
63
+
64
+ function isWriteAction(name: string): boolean {
65
+ return name === 'store' || name === 'update' || name === 'destroy'
66
+ }
67
+
68
+ function statementCount(node: { body?: { type?: string; body?: unknown[] } }): number {
69
+ const body = node.body
70
+ if (body?.type === 'BlockStatement' && Array.isArray(body.body)) return body.body.length
71
+ return 1
72
+ }
73
+
74
+ function relationDepth(value: unknown, current = 1): number {
75
+ if (typeof value !== 'object' || value === null) return current
76
+ const record = value as {
77
+ type?: string
78
+ properties?: Array<{ key?: { name?: string }; value?: unknown }>
79
+ }
80
+ if (record.type !== 'ObjectExpression' || record.properties === undefined) return current
81
+ const nested = record.properties.find((property) => property.key?.name === 'relations')
82
+ if (nested === undefined) return current
83
+ const list = nested.value as { type?: string; elements?: unknown[] }
84
+ if (
85
+ list.type !== 'ArrayExpression' ||
86
+ list.elements === undefined ||
87
+ list.elements.length === 0
88
+ ) {
89
+ return current
90
+ }
91
+ return Math.max(...list.elements.map((element) => relationDepth(element, current + 1)))
92
+ }
93
+
94
+ function looksLikeTransactionName(name: string): boolean {
95
+ return /transaction/i.test(name)
96
+ }
97
+
98
+ function hasTryCatch(node: Node): boolean {
99
+ let found = false
100
+ walk(node, (child) => {
101
+ if ((child as { type?: string }).type === 'TryStatement') found = true
102
+ })
103
+ return found
104
+ }
105
+
106
+ const SKIP_WALK_KEYS = new Set([
107
+ 'parent',
108
+ 'loc',
109
+ 'range',
110
+ 'tokens',
111
+ 'comments',
112
+ 'leadingComments',
113
+ 'trailingComments',
114
+ ])
115
+
116
+ function walk(node: unknown, visit: (node: Node) => void, seen = new Set<unknown>()): void {
117
+ if (typeof node !== 'object' || node === null) return
118
+ if (seen.has(node)) return
119
+ seen.add(node)
120
+ const current = node as Node & { type?: string }
121
+ if (typeof current.type === 'string') visit(current)
122
+ for (const [key, value] of Object.entries(current)) {
123
+ if (SKIP_WALK_KEYS.has(key)) continue
124
+ if (Array.isArray(value)) {
125
+ for (const entry of value) walk(entry, visit, seen)
126
+ } else {
127
+ walk(value, visit, seen)
128
+ }
129
+ }
130
+ }
131
+
132
+ function hasSequentialWrites(node: Node): boolean {
133
+ const writes: string[] = []
134
+ walk(node, (child) => {
135
+ const name = callName(child as CallLike)
136
+ if (
137
+ /\.(create|update|insert|save|delete)$/.test(name) ||
138
+ name === 'create' ||
139
+ name === 'save'
140
+ ) {
141
+ writes.push(name)
142
+ }
143
+ })
144
+ return writes.length >= 2
145
+ }
146
+
147
+ function exportedAnyNodes(node: Node): Node[] {
148
+ const hits: Node[] = []
149
+ walk(node, (child) => {
150
+ if ((child as { type?: string }).type === 'TSAnyKeyword') hits.push(child)
151
+ })
152
+ return hits
153
+ }
154
+
155
+ function ruleModule(
156
+ name: RuleName,
157
+ create: (context: Rule.RuleContext, layer: FileLayer) => Rule.RuleListener,
158
+ ): Rule.RuleModule {
159
+ return {
160
+ meta: {
161
+ type: name === 'controller-thinness' ? 'suggestion' : 'problem',
162
+ docs: { description: reasons[name] },
163
+ schema: [],
164
+ messages: { default: '{{detail}}' },
165
+ ...(name === 'no-any-public' ? { fixable: 'code' as const } : {}),
166
+ },
167
+ create(context) {
168
+ const layer = classifyFile(context.filename)
169
+ return create(context, layer)
170
+ },
171
+ }
172
+ }
173
+
174
+ const noVendorImport = ruleModule('no-vendor-import', (context, layer) => ({
175
+ ImportDeclaration(node) {
176
+ const specifier = specifierOf(node as ImportLike)
177
+ if (specifier === undefined || !isVendorSpecifier(specifier) || allowsVendor(layer)) return
178
+ report(
179
+ context,
180
+ 'no-vendor-import',
181
+ node as Rule.Node,
182
+ `Vendor SDK '${specifier}' may only be imported from a driver package or avelon.config.ts.`,
183
+ )
184
+ },
185
+ }))
186
+
187
+ const noCrossLayer = ruleModule('no-cross-layer', (context, layer) => ({
188
+ ImportDeclaration(node) {
189
+ const specifier = specifierOf(node as ImportLike) ?? ''
190
+ if (layer === 'view' && specifier.includes('/Models/')) {
191
+ report(
192
+ context,
193
+ 'no-cross-layer',
194
+ node as Rule.Node,
195
+ 'Views cannot import Models. Serialize through app/Http/Views/.',
196
+ )
197
+ }
198
+ if (
199
+ layer === 'controller' &&
200
+ specifier.includes('/Http/Controllers/') &&
201
+ !specifier.endsWith('/controller')
202
+ ) {
203
+ report(
204
+ context,
205
+ 'no-cross-layer',
206
+ node as Rule.Node,
207
+ 'Controllers cannot import other controllers.',
208
+ )
209
+ }
210
+ if (layer === 'model' && specifier.includes('/Http/')) {
211
+ report(
212
+ context,
213
+ 'no-cross-layer',
214
+ node as Rule.Node,
215
+ 'Models cannot import anything under Http/.',
216
+ )
217
+ }
218
+ },
219
+ }))
220
+
221
+ const noOrphanQuery = ruleModule('no-orphan-query', (context, layer) => ({
222
+ ImportDeclaration(node) {
223
+ if (allowsQuery(layer)) return
224
+ if (layer !== 'page' && layer !== 'component' && layer !== 'view') return
225
+ const specifier = specifierOf(node as ImportLike) ?? ''
226
+ if (specifier === '@avelonjs/orm' || specifier.includes('/Models/')) {
227
+ report(
228
+ context,
229
+ 'no-orphan-query',
230
+ node as Rule.Node,
231
+ 'Database access is not allowed in a component or page. Load data in a controller or action.',
232
+ )
233
+ }
234
+ },
235
+ MemberExpression(node) {
236
+ if (allowsQuery(layer) || (layer !== 'page' && layer !== 'component')) return
237
+ const expression = node as {
238
+ object?: { type?: string; name?: string }
239
+ property?: { type?: string; name?: string }
240
+ }
241
+ if (expression.object?.type === 'Identifier' && expression.object.name === 'DB') {
242
+ report(
243
+ context,
244
+ 'no-orphan-query',
245
+ node as Rule.Node,
246
+ 'Database access is not allowed in a component or page.',
247
+ )
248
+ }
249
+ },
250
+ }))
251
+
252
+ const noUnwarded = ruleModule('no-unwarded', (context, layer) => ({
253
+ CallExpression(node) {
254
+ if (allowsUnwarded(layer)) return
255
+ const name = callName(node as CallLike)
256
+ if (name === 'unwarded' || name.endsWith('.unwarded') || name === 'Scrivener.unwarded') {
257
+ report(
258
+ context,
259
+ 'no-unwarded',
260
+ node as Rule.Node,
261
+ 'Scrivener.unwarded() is allowed only in app/Errands/ and database/seeds/.',
262
+ )
263
+ }
264
+ },
265
+ }))
266
+
267
+ const requireWard = ruleModule('require-ward', (context, layer) => ({
268
+ ClassDeclaration(node) {
269
+ if (layer !== 'model') return
270
+ const id = (node as { id?: { name?: string } }).id?.name
271
+ if (id === undefined || id === 'Model') return
272
+ if (wardExists(context.filename, id)) return
273
+ report(
274
+ context,
275
+ 'require-ward',
276
+ node as Rule.Node,
277
+ `Model ${id} is missing ${id}Ward. Every model has a ward.`,
278
+ )
279
+ },
280
+ }))
281
+
282
+ const noRawOutsideDrivers = ruleModule('no-raw-outside-drivers', (context, layer) => ({
283
+ CallExpression(node) {
284
+ if (allowsVendor(layer)) return
285
+ const name = callName(node as CallLike)
286
+ if (name === 'raw' || name.endsWith('.raw')) {
287
+ report(
288
+ context,
289
+ 'no-raw-outside-drivers',
290
+ node as Rule.Node,
291
+ 'driver.raw() is allowed only in app/Drivers/ or avelon.config.ts.',
292
+ )
293
+ }
294
+ },
295
+ }))
296
+
297
+ const noHandEditedGenerated = ruleModule('no-hand-edited-generated', (context) => ({
298
+ Program(node) {
299
+ if (!isGeneratedPath(context.filename)) return
300
+ const comments = context.sourceCode.getAllComments()
301
+ const header = comments
302
+ .slice(0, 3)
303
+ .map((comment: Comment) => comment.value)
304
+ .join('\n')
305
+ if (header.includes(GENERATED_BANNER)) return
306
+ report(
307
+ context,
308
+ 'no-hand-edited-generated',
309
+ node as Rule.Node,
310
+ 'Generated files must keep the generator banner. Hand-edits are discarded on the next sync.',
311
+ )
312
+ },
313
+ }))
314
+
315
+ const noBunInCore = ruleModule('no-bun-in-core', (context, layer) => ({
316
+ ImportDeclaration(node) {
317
+ if (!bansBun(layer, context.filename)) return
318
+ const specifier = specifierOf(node as ImportLike) ?? ''
319
+ if (specifier === 'bun' || specifier.startsWith('bun:')) {
320
+ report(
321
+ context,
322
+ 'no-bun-in-core',
323
+ node as Rule.Node,
324
+ '@avelonjs/core and @avelonjs/orm may not import bun:*.',
325
+ )
326
+ }
327
+ },
328
+ }))
329
+
330
+ const noModelAcrossBoundary = ruleModule('no-model-across-boundary', (context) => ({
331
+ CallExpression(node) {
332
+ const callee = (node as CallLike).callee
333
+ if (callee?.type !== 'Identifier' || callee.name !== 'view') return
334
+ const args = (node as { arguments?: Array<{ type?: string; properties?: unknown[] }> })
335
+ .arguments
336
+ const props = args?.[1]
337
+ if (props?.type !== 'ObjectExpression' || !Array.isArray(props.properties)) return
338
+ for (const property of props.properties as Array<{
339
+ type?: string
340
+ value?: {
341
+ type?: string
342
+ name?: string
343
+ callee?: { type?: string; property?: { name?: string } }
344
+ }
345
+ }>) {
346
+ if (property.type !== 'Property') continue
347
+ const value = property.value
348
+ if (value?.type === 'Identifier') {
349
+ report(
350
+ context,
351
+ 'no-model-across-boundary',
352
+ node as Rule.Node,
353
+ `Pass a serializer to view(), not the raw value '${value.name}'.`,
354
+ )
355
+ }
356
+ if (
357
+ value?.type === 'CallExpression' &&
358
+ value.callee?.type === 'MemberExpression' &&
359
+ value.callee.property?.name !== 'make' &&
360
+ value.callee.property?.name !== 'collection'
361
+ ) {
362
+ report(
363
+ context,
364
+ 'no-model-across-boundary',
365
+ node as Rule.Node,
366
+ 'Pass a serializer to view(), not a model instance.',
367
+ )
368
+ }
369
+ }
370
+ },
371
+ }))
372
+
373
+ const requireRequest = ruleModule('require-request', (context, layer) => ({
374
+ MethodDefinition(node) {
375
+ if (layer !== 'controller') return
376
+ const key = (node as { key?: { type?: string; name?: string } }).key
377
+ const name = key?.type === 'Identifier' ? key.name : undefined
378
+ if (name === undefined || !isWriteAction(name)) return
379
+ const value = (node as { value?: Node }).value
380
+ if (value === undefined) return
381
+ let validated = false
382
+ walk(value, (child) => {
383
+ const called = callName(child as CallLike)
384
+ if (called.endsWith('.validate') || called === 'validateRequest') validated = true
385
+ })
386
+ if (validated) return
387
+ report(
388
+ context,
389
+ 'require-request',
390
+ node as Rule.Node,
391
+ `Write action ${name}() must validate through a Request.`,
392
+ )
393
+ },
394
+ }))
395
+
396
+ const noFakeTransaction = ruleModule('no-fake-transaction', (context) => ({
397
+ FunctionDeclaration(node) {
398
+ const name = (node as { id?: { name?: string } }).id?.name ?? ''
399
+ if (!looksLikeTransactionName(name)) return
400
+ if (!hasTryCatch(node as Node) || !hasSequentialWrites(node as Node)) return
401
+ report(
402
+ context,
403
+ 'no-fake-transaction',
404
+ node as Rule.Node,
405
+ 'Sequential writes wrapped in try/catch are not a transaction. Use DB.transaction or a driver rpc.',
406
+ )
407
+ },
408
+ MethodDefinition(node) {
409
+ const name = (node as { key?: { name?: string } }).key?.name ?? ''
410
+ if (!looksLikeTransactionName(name)) return
411
+ const value = (node as { value?: Node }).value
412
+ if (value === undefined || !hasTryCatch(value) || !hasSequentialWrites(value)) return
413
+ report(
414
+ context,
415
+ 'no-fake-transaction',
416
+ node as Rule.Node,
417
+ 'Sequential writes wrapped in try/catch are not a transaction. Use DB.transaction or a driver rpc.',
418
+ )
419
+ },
420
+ }))
421
+
422
+ const relationDepthRule = ruleModule('relation-depth', (context) => ({
423
+ CallExpression(node) {
424
+ const name = callName(node as CallLike)
425
+ if (!name.endsWith('.with') && name !== 'with') return
426
+ const args = (node as { arguments?: unknown[] }).arguments ?? []
427
+ const depth = Math.max(1, ...args.map((argument) => relationDepth(argument, 1)))
428
+ const max = 2
429
+ if (depth <= max) return
430
+ report(
431
+ context,
432
+ 'relation-depth',
433
+ node as Rule.Node,
434
+ `.with() depth ${depth} exceeds the configured driver maxRelationDepth of ${max}.`,
435
+ )
436
+ },
437
+ }))
438
+
439
+ const noLibDumping = ruleModule('no-lib-dumping', (context, layer) => ({
440
+ Program(node) {
441
+ if (layer !== 'lib') return
442
+ report(
443
+ context,
444
+ 'no-lib-dumping',
445
+ node as Rule.Node,
446
+ 'There is no lib/. Put this file in app/, config/, database/, or resources/.',
447
+ )
448
+ },
449
+ }))
450
+
451
+ const noNotSupportedError = ruleModule('no-not-supported-error', (context) => ({
452
+ Identifier(node) {
453
+ if ((node as { name?: string }).name !== 'NotSupportedError') return
454
+ report(
455
+ context,
456
+ 'no-not-supported-error',
457
+ node as Rule.Node,
458
+ 'NotSupportedError is banned. Narrow the capability map instead of throwing.',
459
+ )
460
+ },
461
+ }))
462
+
463
+ const attributeSchemaMatch = ruleModule('attribute-schema-match', (context, layer) => ({
464
+ ClassDeclaration(node) {
465
+ if (layer !== 'model') return
466
+ const className = (node as { id?: { name?: string } }).id?.name
467
+ if (className === undefined) return
468
+ const body = (node as { body?: { body?: unknown[] } }).body?.body ?? []
469
+ let table = ''
470
+ let fillable: string[] = []
471
+ for (const member of body as Array<{
472
+ key?: { name?: string }
473
+ value?: { type?: string; value?: unknown; elements?: Array<{ value?: unknown }> }
474
+ }>) {
475
+ if (member.key?.name === 'table' && typeof member.value?.value === 'string') {
476
+ table = member.value.value
477
+ }
478
+ if (member.key?.name === 'fillable' && Array.isArray(member.value?.elements)) {
479
+ fillable = member.value.elements.flatMap((element) =>
480
+ typeof element.value === 'string' ? [element.value] : [],
481
+ )
482
+ }
483
+ }
484
+ if (table.length === 0 || fillable.length === 0) return
485
+ const columns = schemaColumns(context.filename, table, context.cwd)
486
+ if (columns === undefined) return
487
+ const unknown = fillable.filter((column) => !columns.includes(column))
488
+ if (unknown.length === 0) return
489
+ report(
490
+ context,
491
+ 'attribute-schema-match',
492
+ node as Rule.Node,
493
+ `Model ${className} fillable columns [${unknown.join(', ')}] are missing from database/types.ts table '${table}'.`,
494
+ )
495
+ },
496
+ }))
497
+
498
+ const controllerThinness = ruleModule('controller-thinness', (context, layer) => ({
499
+ MethodDefinition(node) {
500
+ if (layer !== 'controller') return
501
+ const name = (node as { key?: { name?: string } }).key?.name ?? 'action'
502
+ const value = (node as { value?: { body?: { type?: string; body?: unknown[] } } }).value
503
+ if (value === undefined) return
504
+ const count = statementCount(value)
505
+ if (count <= 10) return
506
+ report(
507
+ context,
508
+ 'controller-thinness',
509
+ node as Rule.Node,
510
+ `Controller action ${name}() has ${count} statements. Move the work to app/Actions/.`,
511
+ )
512
+ },
513
+ }))
514
+
515
+ const noAnyPublic = ruleModule('no-any-public', (context) => ({
516
+ ExportNamedDeclaration(node) {
517
+ for (const hit of exportedAnyNodes(node as Node)) {
518
+ report(
519
+ context,
520
+ 'no-any-public',
521
+ hit as Rule.Node,
522
+ 'any is not allowed in an exported signature. Use unknown.',
523
+ (fixer) => fixer.replaceText(hit as Rule.Node, 'unknown'),
524
+ )
525
+ }
526
+ },
527
+ ExportDefaultDeclaration(node) {
528
+ for (const hit of exportedAnyNodes(node as Node)) {
529
+ report(
530
+ context,
531
+ 'no-any-public',
532
+ hit as Rule.Node,
533
+ 'any is not allowed in an exported signature. Use unknown.',
534
+ (fixer) => fixer.replaceText(hit as Rule.Node, 'unknown'),
535
+ )
536
+ }
537
+ },
538
+ }))
539
+
540
+ const requireDisableReason = ruleModule('require-disable-reason', (context) => ({
541
+ Program(node) {
542
+ for (const comment of disableComments(context)) {
543
+ if (comment.reason !== undefined && comment.rule !== undefined) continue
544
+ const locNode = context.sourceCode
545
+ .getAllComments()
546
+ .find((entry) => entry.value.trim() === comment.raw)
547
+ report(
548
+ context,
549
+ 'require-disable-reason',
550
+ (locNode ?? node) as Rule.Node,
551
+ 'bailiff-disable-next-line requires a rule name and a reason after --.',
552
+ )
553
+ }
554
+ },
555
+ }))
556
+
557
+ /** ESLint rule map keyed without the plugin prefix. */
558
+ export const rules: Record<RuleName, Rule.RuleModule> = {
559
+ 'no-vendor-import': noVendorImport,
560
+ 'no-cross-layer': noCrossLayer,
561
+ 'no-orphan-query': noOrphanQuery,
562
+ 'no-unwarded': noUnwarded,
563
+ 'require-ward': requireWard,
564
+ 'no-raw-outside-drivers': noRawOutsideDrivers,
565
+ 'no-hand-edited-generated': noHandEditedGenerated,
566
+ 'no-bun-in-core': noBunInCore,
567
+ 'no-model-across-boundary': noModelAcrossBoundary,
568
+ 'require-request': requireRequest,
569
+ 'no-fake-transaction': noFakeTransaction,
570
+ 'relation-depth': relationDepthRule,
571
+ 'no-lib-dumping': noLibDumping,
572
+ 'no-not-supported-error': noNotSupportedError,
573
+ 'attribute-schema-match': attributeSchemaMatch,
574
+ 'controller-thinness': controllerThinness,
575
+ 'no-any-public': noAnyPublic,
576
+ 'require-disable-reason': requireDisableReason,
577
+ }
@@ -0,0 +1,62 @@
1
+ import type { Rule } from 'eslint'
2
+
3
+ const DISABLE_PATTERN =
4
+ /(?:bailiff-disable-next-line|eslint-disable-next-line)(?:\s+((?:@avelonjs\/bailiff\/)?[\w-]+))?(?:\s+--\s*(.+))?/
5
+
6
+ export interface DisableComment {
7
+ line: number
8
+ rule?: string
9
+ reason?: string
10
+ raw: string
11
+ }
12
+
13
+ /** Parses Bailiff and ESLint next-line disable comments. */
14
+ export function parseDisable(text: string): Omit<DisableComment, 'line' | 'raw'> | undefined {
15
+ const match = DISABLE_PATTERN.exec(text)
16
+ if (match === null) return undefined
17
+ const rule = match[1]?.replace('@avelonjs/bailiff/', '')
18
+ const reason = match[2]?.trim()
19
+ return { rule, reason: reason === '' ? undefined : reason }
20
+ }
21
+
22
+ /** Collects disable comments from the current ESLint source. */
23
+ export function disableComments(context: Rule.RuleContext): DisableComment[] {
24
+ return context.sourceCode.getAllComments().flatMap((comment) => {
25
+ const parsed = parseDisable(comment.value)
26
+ if (parsed === undefined) return []
27
+ const line = comment.loc?.end.line
28
+ if (line === undefined) return []
29
+ return [{ line, raw: comment.value.trim(), ...parsed }]
30
+ })
31
+ }
32
+
33
+ /** Returns true when a node is suppressed by a reasoned disable comment on the previous line. */
34
+ export function isSuppressed(
35
+ context: Rule.RuleContext,
36
+ node: { loc?: { start: { line: number } } | null },
37
+ rule: string,
38
+ ): boolean {
39
+ const line = node.loc?.start.line
40
+ if (line === undefined) return false
41
+ return disableComments(context).some((comment) => {
42
+ if (comment.line !== line - 1 && comment.line !== line) return false
43
+ if (comment.reason === undefined) return false
44
+ return comment.rule === undefined || comment.rule === rule
45
+ })
46
+ }
47
+
48
+ /** Reports a violation unless a reasoned disable comment suppresses it. */
49
+ export function report(
50
+ context: Rule.RuleContext,
51
+ rule: string,
52
+ node: Rule.Node,
53
+ message: string,
54
+ fix?: (fixer: Rule.RuleFixer) => Rule.Fix | Rule.Fix[] | null,
55
+ ): void {
56
+ if (isSuppressed(context, node, rule)) return
57
+ context.report({
58
+ node,
59
+ message,
60
+ ...(fix === undefined ? {} : { fix }),
61
+ })
62
+ }