no-conn-outside-root.js
6.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
/**
* 规则 C — 连接只在 composition root(架构红线 §6.5 / 解药纪律 1)。
*
* 除 connection 豁免文件外,任何文件禁止:
* 1. 在模块顶层 `new <ConnectionClient>()` 创建外部连接(DB/Redis/OSS/厂商 client)。
* 2. 在模块顶层调用建连接工厂函数 `knex(cfg)` / `createConnection(...)` / `createPool(...)`
* (含 `mysql.createConnection(...)` 这类成员调用)——本仓实际的建连接写法。
*
* 连接类与连接函数都用**精确名单**匹配,不用前缀/后缀:
* - `new S3Client()` 命中(在名单内)、`new ApiClient()` 不命中(避免误伤业务 client)。
* - 顶层 `const db = knex(cfg)` 命中;函数体内的 `knex('表名')`(查询构建,全仓几百处)
* 因不在模块顶层而放行。名单只在此文件维护,引入新连接厂商时在此扩列。
*
* 豁免路径以 `options.connExemptFiles` 传入(绝对路径/后缀/目录前缀数组),本规则
* 不持有路径常量。以 `/` 结尾的条目按目录前缀匹配(整目录豁免),其余按绝对路径全等/后缀匹配。
*
* 能力边界(写死,防后人误以为拦得全):本规则只按 AST 形态拦顶层名单内
* `new X()` / `knex(cfg)`。别名或函数包裹的逃逸——`const k = knex; k(cfg)`、
* `const run = () => new Knex(); run()`、把连接透传出去——静态形态已变,规则**不管辖**,靠依赖
* 注入纪律和 review 兜底。规则是红线的下限,不是全部。
*/
import path from 'node:path'
/** 精确的连接类名单(new X())。只精确匹配,`ApiClient` 等业务类不误伤。 */
const DEFAULT_CONNECTION_CLASS_NAMES = [
'Knex',
'Redis',
'IORedis',
'Sequelize',
'Pool', // pg.Pool / new Pool()
'Client', // pg.Client / new Client()
'S3Client', // @aws-sdk/client-s3
'MongoClient', // mongodb
'OSS', // ali-oss
'S3', // aws-sdk v2
]
/** 精确的建连接工厂函数名单(knex(cfg) / mysql.createConnection(...))。 */
const CONNECTION_FN_NAMES = ['knex', 'createConnection', 'createPool']
/**
* @param {string} name
* @param {string[]} names 精确名单。
* @returns {boolean}
*/
function matchesExact(name, names) {
return names.includes(name)
}
/**
* 取被调用者/被 new 者的名字:裸标识符 `knex` → `knex`;成员 `mysql.createConnection` → 属性名
* `createConnection`(`new pg.Pool()` → `Pool`)。其余形态返回 null。
* @param {import('estree').Node} callee
* @returns {string|null}
*/
function calleeName(callee) {
if (!callee) return null
if (callee.type === 'Identifier') return callee.name
if (callee.type === 'MemberExpression' && callee.property.type === 'Identifier') {
return callee.property.name
}
return null
}
/** @type {import('eslint').Rule.RuleModule} */
export const noConnOutsideRoot = {
meta: {
type: 'problem',
docs: {
description:
'除 composition root 外,禁止顶层创建外部连接 client。',
},
schema: [
{
type: 'object',
properties: {
connExemptFiles: { type: 'array', items: { type: 'string' } },
connectionClassNames: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
],
messages: {
conn: '禁止在此顶层创建连接 "new {{name}}()"。连接只能在 composition root({{root}})诞生,再注入业务包。',
connCall: '禁止在此顶层调用建连接工厂 "{{name}}(...)"。连接只能在 composition root({{root}})诞生,再注入业务包。',
},
},
create(context) {
/** @type {{ connExemptFiles?: string[], connectionClassNames?: string[] }} */
const opts = context.options[0] || {}
const connExempt = (opts.connExemptFiles || []).map((r) =>
r.split(path.sep).join('/'),
)
const classNames = opts.connectionClassNames || DEFAULT_CONNECTION_CLASS_NAMES
const filename = context.filename || context.getFilename()
const absFile = (filename || '').split(path.sep).join('/')
// 以 `/` 结尾 → 目录前缀豁免(整目录);否则绝对路径全等/后缀豁免(单文件)。
const matches = (entries) => entries.some((r) =>
r.endsWith('/') ? absFile.includes(r) : absFile === r || absFile.endsWith(r),
)
const isConnectionRoot = matches(connExempt)
if (isConnectionRoot) return {}
const rootLabel = connExempt[0] || 'apps/server/src/main.js'
return {
NewExpression(node) {
// 仅管模块顶层的连接创建。
const name = calleeName(node.callee)
if (!name || !matchesExact(name, classNames)) return
if (isConnectionRoot || !isModuleTopLevel(node, context)) return
context.report({ node, messageId: 'conn', data: { name, root: rootLabel } })
},
CallExpression(node) {
// 顶层建连接工厂调用:knex(cfg) / mysql.createConnection(...) / createPool(...)。
// 函数体内的 knex('表名') 是查询构建(全仓几百处),因非模块顶层而放行。
const name = calleeName(node.callee)
if (!name || !matchesExact(name, CONNECTION_FN_NAMES)) return
if (isConnectionRoot || !isModuleTopLevel(node, context)) return
context.report({ node, messageId: 'connCall', data: { name, root: rootLabel } })
},
}
},
}
/**
* 判断节点是否处于模块顶层(其祖先链中不含函数)。顶层连接是「import 即连」的病根,
* 函数体内的 `new`(工厂里按注入参数建连接)不在此规则管辖——那由依赖注入纪律保证。
* @param {import('estree').Node} node
* @param {import('eslint').Rule.RuleContext} context
* @returns {boolean}
*/
function isModuleTopLevel(node, context) {
const sc = context.sourceCode || context.getSourceCode()
const ancestors =
typeof sc.getAncestors === 'function'
? sc.getAncestors(node)
: /** @type {any} */ (context).getAncestors()
for (const a of ancestors) {
if (
a.type === 'FunctionDeclaration' ||
a.type === 'FunctionExpression' ||
a.type === 'ArrowFunctionExpression' ||
a.type === 'MethodDefinition'
) {
return false
}
}
return true
}