@epxing/cli 0.5.0-beta.0 → 0.5.0-beta.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,255 @@
1
+ #!/usr/bin/env node
2
+ // 将参数占位符中的动态属性读取迁移为公开路径读取;业务表达式只报告。
3
+ const fs = require('node:fs')
4
+ const path = require('node:path')
5
+ const { createRequire } = require('node:module')
6
+
7
+ function dependencies (project) {
8
+ const requireProject = createRequire(path.join(project, 'package.json'))
9
+ try {
10
+ return {
11
+ parser: requireProject('@babel/parser'),
12
+ traverse: requireProject('@babel/traverse').default,
13
+ sfc: requireProject('@vue/compiler-sfc')
14
+ }
15
+ } catch (error) {
16
+ throw new Error('需要目标工程已安装的 @babel/parser、@babel/traverse 和 @vue/compiler-sfc;未自动安装依赖。' + error.message)
17
+ }
18
+ }
19
+
20
+ function nameOf (node) {
21
+ return node?.name || node?.value
22
+ }
23
+
24
+ function componentObject (nodePath) {
25
+ if (!nodePath?.isObjectExpression()) return false
26
+ const parent = nodePath.parentPath
27
+ return parent?.isExportDefaultDeclaration() ||
28
+ (parent?.isCallExpression() && nameOf(parent.node.callee) === 'defineComponent' &&
29
+ parent.node.arguments[0] === nodePath.node)
30
+ }
31
+
32
+ function thisOwner (nodePath) {
33
+ let fn = nodePath.getFunctionParent()
34
+ while (fn?.isArrowFunctionExpression()) fn = fn.getFunctionParent()
35
+ if (!fn || (!fn.isObjectMethod() && !fn.isFunctionExpression())) return null
36
+ let member = fn.isObjectMethod() ? fn : fn.parentPath
37
+ if (!member?.isObjectMethod() && !member?.isObjectProperty()) return null
38
+ // 普通嵌套函数会改变 this,不能仅因位于组件文件中就自动处理。
39
+ let object = member.parentPath
40
+ for (let level = 0; level < 4 && object?.isObjectExpression(); level++) {
41
+ if (componentObject(object)) {
42
+ return ['methods', 'computed', 'watch', 'created', 'mounted', 'render',
43
+ 'beforeCreate', 'beforeMount', 'updated', 'beforeUpdate',
44
+ 'unmounted', 'beforeUnmount', 'data', 'setup'].includes(nameOf(member.node.key))
45
+ ? (nameOf(member.node.key) === 'setup' ? null : fn)
46
+ : null
47
+ }
48
+ member = object.parentPath
49
+ if (!member?.isObjectProperty()) return null
50
+ object = member.parentPath
51
+ }
52
+ return null
53
+ }
54
+
55
+ function placeholderRegex (node) {
56
+ return node?.type === 'RegExpLiteral' &&
57
+ /(?:\\\$\\\[|#\\\[|#\\\{)/.test(node.pattern)
58
+ }
59
+
60
+ function replaceCall (node) {
61
+ return node?.type === 'CallExpression' && node.callee.type === 'MemberExpression' &&
62
+ !node.callee.computed && node.callee.property.name === 'replace' &&
63
+ placeholderRegex(node.arguments[0])
64
+ }
65
+
66
+ function placeholderKey (argPath) {
67
+ if (!argPath.isIdentifier()) return false
68
+ const binding = argPath.scope.getBinding(argPath.node.name)
69
+ if (!binding || !binding.constant) return false
70
+ const fn = binding.scope.path
71
+ if (binding.kind === 'param' && fn.isFunction() &&
72
+ fn.node.params[1]?.type === 'Identifier' && fn.node.params[1].name === argPath.node.name &&
73
+ replaceCall(fn.parentPath.node) && fn.parentPath.node.arguments[1] === fn.node) return true
74
+ if (!binding.path.isVariableDeclarator() || !replaceCall(binding.path.node.init)) return false
75
+ const callback = binding.path.node.init.arguments[1]
76
+ if (callback?.type === 'StringLiteral') return callback.value === '$1'
77
+ if (!callback || !['ArrowFunctionExpression', 'FunctionExpression'].includes(callback.type)) return false
78
+ const capture = callback.params[1]
79
+ if (capture?.type !== 'Identifier') return false
80
+ const body = callback.body.type === 'BlockStatement'
81
+ ? (callback.body.body.length === 1 && callback.body.body[0].type === 'ReturnStatement'
82
+ ? callback.body.body[0].argument : null)
83
+ : callback.body
84
+ return body?.type === 'Identifier' && body.name === capture.name
85
+ }
86
+
87
+ function contextFor (callPath, root) {
88
+ if (root === 'this') return thisOwner(callPath) ? { root } : null
89
+ const binding = callPath.scope.getBinding(root)
90
+ if (binding) {
91
+ if (!binding.constant || !binding.path.isVariableDeclarator() ||
92
+ binding.path.node.init?.type !== 'ThisExpression' || !thisOwner(binding.path)) return null
93
+ return { root }
94
+ }
95
+ // 只在已证实属于组件的词法 this 中修复旧代码缺少的常见别名。
96
+ return ['that', '_this'].includes(root) && thisOwner(callPath) ? { root: 'this' } : null
97
+ }
98
+
99
+ function analyzeCode (code, { filename, lang = '', offset = 0, source = code }, deps) {
100
+ const plugins = []
101
+ if (/tsx?$/.test(lang) || /\.[cm]?tsx?$/.test(filename)) plugins.push('typescript')
102
+ if (lang !== 'ts' && !/\.[cm]?ts$/.test(filename)) plugins.push('jsx')
103
+ const ast = deps.parser.parse(code, { sourceType: 'unambiguous', plugins })
104
+ const edits = []
105
+ const findings = []
106
+ deps.traverse(ast, {
107
+ CallExpression (callPath) {
108
+ const node = callPath.node
109
+ if (node.callee.type !== 'Identifier' || node.callee.name !== 'eval') return
110
+ // 同名业务函数不是全局 eval。
111
+ if (callPath.scope.getBinding('eval')) return
112
+ const finding = {
113
+ file: filename,
114
+ line: source.slice(0, offset + node.start).split('\n').length,
115
+ kind: 'review',
116
+ reason: '表达式、配置解析或未知路径来源,保留原代码并人工核对上下文。',
117
+ before: code.slice(node.start, node.end)
118
+ }
119
+ const value = node.arguments[0]
120
+ if (node.arguments.length === 1 && value?.type === 'TemplateLiteral' &&
121
+ value.expressions.length === 1 && value.quasis[1].value.cooked === '') {
122
+ const prefix = value.quasis[0].value.cooked
123
+ const match = /^(this|[A-Za-z_$][\w$]*)(\.[A-Za-z_$][\w$]*)*\.$/.exec(prefix)
124
+ const exprPath = callPath.get('arguments.0.expressions.0')
125
+ if (match && placeholderKey(exprPath)) {
126
+ const context = contextFor(callPath, match[1])
127
+ if (context) {
128
+ const object = context.root + prefix.slice(match[1].length, -1)
129
+ const key = code.slice(value.expressions[0].start, value.expressions[0].end)
130
+ const after = context.root + '.$_.get(' + object + ', ' + key + ')'
131
+ edits.push({ start: offset + node.start, end: offset + node.end, text: after })
132
+ Object.assign(finding, { kind: 'automatic', after, reason: '参数占位符路径读取,上下文已确认。' })
133
+ } else {
134
+ finding.reason = '参数占位符命中,但别名或 this 上下文无法确认。'
135
+ }
136
+ }
137
+ }
138
+ findings.push(finding)
139
+ }
140
+ })
141
+ return { edits, findings }
142
+ }
143
+
144
+ function analyzeFile (filename, source, deps) {
145
+ if (!filename.endsWith('.vue')) return analyzeCode(source, { filename }, deps)
146
+ const parsed = deps.sfc.parse(source, { filename })
147
+ if (parsed.errors.length) throw new Error(parsed.errors.map(e => e.message || e).join('; '))
148
+ const result = { edits: [], findings: [] }
149
+ for (const block of [parsed.descriptor.script, parsed.descriptor.scriptSetup]) {
150
+ if (!block) continue
151
+ if (block.src) {
152
+ result.findings.push({ file: filename, line: block.loc.start.line, kind: 'review', reason: '外部 script src 需核对是否在本次 src 扫描范围内。' })
153
+ continue
154
+ }
155
+ const part = analyzeCode(block.content, {
156
+ filename, lang: block.lang || '', offset: block.loc.start.offset, source
157
+ }, deps)
158
+ result.edits.push(...part.edits)
159
+ result.findings.push(...part.findings)
160
+ }
161
+ const template = parsed.descriptor.template
162
+ for (const match of (template?.content || '').matchAll(/\beval\s*\(/g)) {
163
+ result.findings.push({ file: filename, line: template.loc.start.line + template.content.slice(0, match.index).split('\n').length - 1, kind: 'review', reason: '模板内 eval 需人工核对模板上下文。' })
164
+ }
165
+ return result
166
+ }
167
+
168
+ function sourceFiles (directory) {
169
+ const result = []
170
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
171
+ if (entry.isSymbolicLink()) continue
172
+ const file = path.join(directory, entry.name)
173
+ if (entry.isDirectory() && !['node_modules', '.git', '.cache', 'dist', 'lib'].includes(entry.name)) result.push(...sourceFiles(file))
174
+ else if (entry.isFile() && /\.(vue|[cm]?js|jsx|[cm]?ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) result.push(file)
175
+ }
176
+ return result
177
+ }
178
+
179
+ function migrate ({ project, apply = false, reportFile }, deps = dependencies(project)) {
180
+ project = path.resolve(project)
181
+ const src = path.join(project, 'src')
182
+ if (!fs.existsSync(src)) throw new Error('目标必须是包含 src 的前端工程:' + project)
183
+ const files = sourceFiles(src)
184
+ const pending = []
185
+ const findings = []
186
+ const errors = []
187
+ for (const file of files) {
188
+ const source = fs.readFileSync(file, 'utf8')
189
+ try {
190
+ const result = analyzeFile(file, source, deps)
191
+ findings.push(...result.findings)
192
+ if (result.edits.length) {
193
+ let changed = source
194
+ for (const edit of result.edits.sort((a, b) => b.start - a.start)) changed = changed.slice(0, edit.start) + edit.text + changed.slice(edit.end)
195
+ pending.push({ file, source, changed })
196
+ }
197
+ } catch (error) {
198
+ errors.push({ file, message: error.message })
199
+ }
200
+ }
201
+ const report = {
202
+ version: 1, project, mode: apply ? 'apply' : 'preview', filesScanned: files.length,
203
+ automatic: findings.filter(f => f.kind === 'automatic').length,
204
+ review: findings.filter(f => f.kind === 'review').length,
205
+ filesChanged: [], findings, errors
206
+ }
207
+ // 先完成全部解析;任一文件失败时不修改源码。
208
+ if (apply && !errors.length && pending.length) {
209
+ const backupRoot = path.join(project, '.cache', 'eval-path-backups')
210
+ fs.mkdirSync(backupRoot, { recursive: true })
211
+ const backup = fs.mkdtempSync(path.join(backupRoot, 'run-'))
212
+ for (const entry of pending) {
213
+ const target = path.join(backup, path.relative(project, entry.file))
214
+ fs.mkdirSync(path.dirname(target), { recursive: true })
215
+ fs.writeFileSync(target, entry.source)
216
+ }
217
+ // 防止扫描到写入之间覆盖其它人的新改动。
218
+ for (const entry of pending) if (fs.readFileSync(entry.file, 'utf8') !== entry.source) throw new Error('扫描后文件已变化,未写入源码:' + entry.file)
219
+ for (const entry of pending) fs.writeFileSync(entry.file, entry.changed)
220
+ report.backup = backup
221
+ report.filesChanged = pending.map(entry => entry.file)
222
+ }
223
+ report.exitCode = errors.length ? 1 : ((report.review || (!apply && report.automatic)) ? 2 : 0)
224
+ if (reportFile) {
225
+ fs.mkdirSync(path.dirname(path.resolve(reportFile)), { recursive: true })
226
+ fs.writeFileSync(reportFile, JSON.stringify(report, null, 2) + '\n')
227
+ }
228
+ return report
229
+ }
230
+
231
+ if (require.main === module) {
232
+ try {
233
+ const args = process.argv.slice(2)
234
+ if (args.includes('--help')) {
235
+ console.log('用法:node fix-eval-paths.cjs <前端工程> [--apply] [--report <JSON文件>]\n默认只读预览;--apply 先备份后改写。退出码:0 已处理,2 有待修复/待复核项,1 解析或依赖失败。')
236
+ } else {
237
+ const options = { project: null, apply: false }
238
+ for (let i = 0; i < args.length; i++) {
239
+ if (args[i] === '--apply') options.apply = true
240
+ else if (args[i] === '--report' && args[i + 1] && !args[i + 1].startsWith('--')) options.reportFile = args[++i]
241
+ else if (!args[i].startsWith('--') && !options.project) options.project = path.resolve(args[i])
242
+ else throw new Error('未知或不完整参数:' + args[i])
243
+ }
244
+ if (!options.project) throw new Error('请指定前端工程;使用 --help 查看用法。')
245
+ const report = migrate(options)
246
+ console.log(JSON.stringify(report, null, 2))
247
+ process.exitCode = report.exitCode
248
+ }
249
+ } catch (error) {
250
+ console.error(error.message)
251
+ process.exitCode = 1
252
+ }
253
+ }
254
+
255
+ module.exports = { dependencies, analyzeCode, analyzeFile, migrate }