no-deep-import.js 4.72 KB
/**
 * 规则 A — 包间禁深导入(架构红线 §6.4)。
 *
 * 任何 `@drama/*` 或 `@app/*` 的导入只能命中包公共面(裸包名或带 `package.json#exports`
 * 子路径条目)。禁止深入包内部源码,如 `@drama/llm/src/internal/x.js`。
 *
 * 判定:导入说明符匹配 `@scope/name/<more>` 即视为深导入并报错。仅识别 `@drama` / `@app`
 * 两个受控 scope,第三方 scope(如 `@koa/router`)不受此规则管辖。
 */

import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'

const CONTROLLED_SCOPES = new Set(['@drama', '@app'])

/**
 * 读取受控包的 `package.json`。从被 lint 的文件出发解析包入口,再向上寻找 name 匹配的
 * package.json。解析失败一律返回 null(回退到"按段数判深导入"的保守行为)。
 * @param {string} pkg `@scope/name`
 * @param {string} fromFile 被 lint 文件的绝对路径
 * @returns {Record<string, any>|null}
 */
function readPackageJson(pkg, fromFile) {
  try {
    const require = createRequire(fromFile)
    let dir = dirname(require.resolve(pkg))
    for (let depth = 0; depth < 8; depth += 1) {
      try {
        const json = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
        if (json.name === pkg) return json
      } catch {
        // 继续向上
      }
      const parent = dirname(dir)
      if (parent === dir) break
      dir = parent
    }
  } catch {
    // 解析不出包入口:回退保守判定。
  }
  return null
}

/**
 * 子路径是否命中 `package.json#exports` 声明的公共面(含 `*` 通配)。
 * @param {unknown} exports
 * @param {string} subpath 形如 `./vg-token-store`
 */
function subpathIsExported(exports, subpath) {
  if (!exports || typeof exports !== 'object') return false
  for (const key of Object.keys(exports)) {
    if (typeof key !== 'string' || !key.startsWith('./')) continue
    if (key === subpath) return true
    const star = key.indexOf('*')
    if (star >= 0) {
      const pre = key.slice(0, star)
      const post = key.slice(star + 1)
      if (subpath.startsWith(pre) && subpath.endsWith(post) && subpath.length >= pre.length + post.length) return true
    }
  }
  return false
}

/**
 * @param {string} source 导入说明符。
 * @param {string} fromFile 被 lint 文件路径。
 * @returns {boolean} 是否为受控 scope 下的深导入(且未被 exports 声明为公共面)。
 */
function isControlledDeepImport(source, fromFile) {
  if (typeof source !== 'string' || source[0] !== '@') return false
  const parts = source.split('/')
  // 受控包公共面只有两段:@scope/name。三段及以上才可能是深导入。
  if (parts.length <= 2) return false
  if (!CONTROLLED_SCOPES.has(parts[0])) return false
  // `package.json#exports` 声明的子路径是公共面,不算深导入。
  const pkg = `${parts[0]}/${parts[1]}`
  const subpath = `.${source.slice(pkg.length)}`
  const packageJson = readPackageJson(pkg, fromFile)
  if (packageJson && subpathIsExported(packageJson.exports, subpath)) return false
  return true
}

/** @type {import('eslint').Rule.RuleModule} */
export const noDeepImport = {
  meta: {
    type: 'problem',
    docs: {
      description:
        '禁止深入 @drama/* 或 @app/* 包内部源码,只能从包的 index 公共面导入。',
    },
    schema: [],
    messages: {
      deepImport:
        '禁止深导入 "{{source}}"。请从包公共面 "{{pkg}}" 导入(每个包唯一公共面是其 index.js)。',
    },
  },
  create(context) {
    const fromFile = context.filename || context.getFilename?.() || ''
    /** @param {string} source @param {import('estree').Node} node */
    function check(source, node) {
      if (isControlledDeepImport(source, fromFile)) {
        const parts = source.split('/')
        context.report({
          node,
          messageId: 'deepImport',
          data: { source, pkg: `${parts[0]}/${parts[1]}` },
        })
      }
    }
    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)
        }
      },
    }
  },
}