no-deep-import.js
4.72 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
/**
* 规则 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)
}
},
}
},
}