no-subdomain-deep-import.js 3.79 KB
/**
 * 规则 B — 包内子域间禁深导入(架构红线 §6.6)。
 *
 * 含多个子域目录的业务包(如 `@drama/bundle` 的 `drama/ script/ character/ scene/ bundle/`,
 * 或 `@drama/assets` 的 `character/ scene/ prop/ effect/ sfx/ expression-pack/ repo/`)之间,
 * 只能走各自子域的 `index.js`。把「禁深导入」递归进包内。
 *
 * 判定:当前文件位于包 `src/<subA>/...`,相对导入解析到 `src/<subB>/...`(subB ≠ subA)时,
 * 只有命中 `src/<subB>/index.js`(或目录形式 `../<subB>` / `../<subB>/`)才放行;命中
 * `src/<subB>/internal/...` 等更深路径即报错。同一子域内(subB === subA)相对导入不受限。
 *
 * 用 `path.posix` 归一化,避免平台分隔符差异。
 */

import path from 'node:path'

/**
 * 取 `<root>/src/<subdomain>/...` 中的 src 之后第一段(子域名)与是否恰好是该子域 index。
 * @param {string} absFile 归一化为 posix 的绝对路径。
 * @returns {{ subdomain: string, isIndex: boolean } | null}
 */
function subdomainOf(absFile) {
  const parts = absFile.split('/')
  const srcIdx = parts.lastIndexOf('src')
  if (srcIdx === -1 || srcIdx + 1 >= parts.length) return null
  const after = parts.slice(srcIdx + 1)
  // src 直属文件(如 src/index.js)不属于任何子域。
  if (after.length < 2) return null
  const subdomain = after[0]
  const isIndex = after.length === 2 && after[1] === 'index.js'
  return { subdomain, isIndex }
}

/** @type {import('eslint').Rule.RuleModule} */
export const noSubdomainDeepImport = {
  meta: {
    type: 'problem',
    docs: {
      description:
        '包内子域之间只能从对方子域的 index.js 导入,禁止深入其 internal/ 等内部文件。',
    },
    schema: [],
    messages: {
      subdomainDeep:
        '禁止跨子域深导入 "{{source}}"。子域 "{{from}}" 引用子域 "{{to}}" 时只能导入 "../{{to}}"(其 index.js),不能深入其内部文件。',
    },
  },
  create(context) {
    const filename = context.filename || context.getFilename()
    if (!filename || filename === '<input>' || filename === '<text>') return {}
    const absFile = filename.split(path.sep).join('/')
    const maybeHere = subdomainOf(absFile)
    if (!maybeHere) return {}
    const here = maybeHere
    const fileDir = path.posix.dirname(absFile)

    /** @param {string} source @param {import('estree').Node} node */
    function check(source, node) {
      if (typeof source !== 'string') return
      // 只看相对导入;包名导入归规则 A。
      if (source[0] !== '.') return
      const resolved = path.posix.normalize(path.posix.join(fileDir, source))
      const target = subdomainOf(resolved)
      if (!target) return
      // 同一子域内随意;跨子域只允许命中对方 index。
      if (target.subdomain === here.subdomain) return
      if (target.isIndex) return
      context.report({
        node,
        messageId: 'subdomainDeep',
        data: { source, from: here.subdomain, to: target.subdomain },
      })
    }

    return {
      ImportDeclaration(node) {
        check(String(node.source.value), node.source)
      },
      ExportNamedDeclaration(node) {
        if (node.source) check(String(node.source.value), node.source)
      },
      ExportAllDeclaration(node) {
        if (node.source) check(String(node.source.value), node.source)
      },
      ImportExpression(node) {
        if (node.source && node.source.type === 'Literal') {
          check(String(node.source.value), node.source)
        }
      },
      CallExpression(node) {
        if (node.callee.type !== 'Identifier' || node.callee.name !== 'require') return
        const source = node.arguments[0]
        if (source && source.type === 'Literal') {
          check(String(source.value), source)
        }
      },
    }
  },
}