/** * 规则 C — 连接只在 composition root(架构红线 §6.5 / 解药纪律 1)。 * * 除 connection 豁免文件外,任何文件禁止: * 1. 在模块顶层 `new ()` 创建外部连接(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 }