@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 +21 -0
- package/README.md +128 -0
- package/package.json +53 -0
- package/src/cli.ts +160 -0
- package/src/index.ts +20 -0
- package/src/paths.ts +179 -0
- package/src/plugin.ts +33 -0
- package/src/reasons.ts +48 -0
- package/src/rules.ts +577 -0
- package/src/suppress.ts +62 -0
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
|
+
}
|
package/src/suppress.ts
ADDED
|
@@ -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
|
+
}
|