no-conn-outside-root.js 6.1 KB
/**
 * 规则 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
}