tha-cli 0.0.0-stage → 0.1.1
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.
- package/ARCHITECTURE.md +193 -0
- package/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/tha/cli.mjs +116 -0
- package/tha/commands/adopt.mjs +119 -0
- package/tha/commands/check.mjs +226 -0
- package/tha/commands/diff.mjs +70 -0
- package/tha/commands/init.mjs +262 -0
- package/tha/commands/remove.mjs +328 -0
- package/tha/commands/sync.mjs +826 -0
- package/tha/config/field.mjs +178 -0
- package/tha/config/load.mjs +188 -0
- package/tha/config/manifest.mjs +75 -0
- package/tha/config/paths.mjs +124 -0
- package/tha/config/project.mjs +52 -0
- package/tha/config/rules.mjs +85 -0
- package/tha/config/schema.mjs +306 -0
- package/tha/config/template.mjs +78 -0
- package/tha/generate/domain/api.mjs +67 -0
- package/tha/generate/domain/context.mjs +76 -0
- package/tha/generate/domain/detail.mjs +35 -0
- package/tha/generate/domain/entry.mjs +91 -0
- package/tha/generate/domain/executors.mjs +93 -0
- package/tha/generate/domain/form.mjs +34 -0
- package/tha/generate/domain/index.mjs +72 -0
- package/tha/generate/domain/permissions.mjs +35 -0
- package/tha/generate/domain/store.mjs +68 -0
- package/tha/generate/domain/view.mjs +29 -0
- package/tha/generate/global.mjs +111 -0
- package/tha/generate/header.mjs +29 -0
- package/tha/generate/names.mjs +45 -0
- package/tha/generate/plan.mjs +41 -0
- package/tha/kit/ThaCrudPage.vue +344 -0
- package/tha/kit/ThaDetail.vue +146 -0
- package/tha/kit/ThaForm.vue +194 -0
- package/tha/kit/request.ts +30 -0
- package/tha/kit/transport.ts +11 -0
- package/tha/utils/args.mjs +59 -0
- package/tha/utils/compare.mjs +38 -0
- package/tha/utils/files.mjs +175 -0
- package/tha/utils/format.mjs +23 -0
- package/tha/utils/output.mjs +79 -0
- package/tha/utils/style.mjs +147 -0
- package/tha/utils/ts.mjs +73 -0
- package/tha/verify/ast.mjs +143 -0
- package/tha/verify/boundary.mjs +219 -0
- package/tha/verify/contract.mjs +186 -0
- package/tha/verify/openapi.mjs +345 -0
- package/tha/verify/rules.mjs +91 -0
- package/tha/verify/scan.mjs +59 -0
- package/tha.mjs +22 -0
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// ============ AST 工具:import 关系与中枢函数体检查 ============
|
|
2
|
+
// 契约检查与护栏检查共用这一份实现:同一件事只解析一次,判定口径不会分叉。
|
|
3
|
+
// .vue 文件先抽 script 块再解析,行号回填到文件真实行。
|
|
4
|
+
|
|
5
|
+
import { ts } from '../utils/ts.mjs'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 解析文件(.vue 只取 script 块)
|
|
9
|
+
* @param file 文件绝对路径(用扩展名判断是否 .vue)
|
|
10
|
+
* @param text 文件内容
|
|
11
|
+
* @returns 每个 script 块与其起始行偏移
|
|
12
|
+
*/
|
|
13
|
+
function parseBlocks(file, text) {
|
|
14
|
+
if (!file.endsWith('.vue')) {
|
|
15
|
+
return [{ sourceFile: ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS), offset: 0 }]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const blocks = []
|
|
19
|
+
const pattern = /<script\b[^>]*>([\s\S]*?)<\/script>/gi
|
|
20
|
+
let match
|
|
21
|
+
|
|
22
|
+
while ((match = pattern.exec(text))) {
|
|
23
|
+
const code = match[1]
|
|
24
|
+
const codeStart = match.index + match[0].indexOf(code)
|
|
25
|
+
const offset = text.slice(0, codeStart).split('\n').length - 1
|
|
26
|
+
blocks.push({
|
|
27
|
+
sourceFile: ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS),
|
|
28
|
+
offset
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return blocks.length > 0
|
|
33
|
+
? blocks
|
|
34
|
+
: [{ sourceFile: ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS), offset: 0 }]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isTypeOnly(node) {
|
|
38
|
+
if (ts.isExportDeclaration(node)) return node.isTypeOnly === true
|
|
39
|
+
|
|
40
|
+
const clause = node.importClause
|
|
41
|
+
if (!clause) return false
|
|
42
|
+
if (clause.isTypeOnly) return true
|
|
43
|
+
if (clause.name) return false
|
|
44
|
+
|
|
45
|
+
const bindings = clause.namedBindings
|
|
46
|
+
if (!bindings) return false
|
|
47
|
+
if (ts.isNamespaceImport(bindings)) return false
|
|
48
|
+
|
|
49
|
+
return bindings.elements.length > 0 && bindings.elements.every((element) => element.isTypeOnly)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function specifierOf(node) {
|
|
53
|
+
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
54
|
+
const specifier = node.moduleSpecifier
|
|
55
|
+
if (specifier && ts.isStringLiteral(specifier)) return specifier
|
|
56
|
+
}
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function importedNames(node) {
|
|
61
|
+
if (ts.isExportDeclaration(node)) {
|
|
62
|
+
const clause = node.exportClause
|
|
63
|
+
if (!clause || !ts.isNamedExports(clause)) return []
|
|
64
|
+
return clause.elements.filter((element) => !element.isTypeOnly).map((element) => element.name.text)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const clause = node.importClause
|
|
68
|
+
if (!clause) return []
|
|
69
|
+
|
|
70
|
+
const names = []
|
|
71
|
+
if (clause.name) names.push(clause.name.text)
|
|
72
|
+
|
|
73
|
+
const bindings = clause.namedBindings
|
|
74
|
+
if (bindings) {
|
|
75
|
+
if (ts.isNamespaceImport(bindings)) names.push(bindings.name.text)
|
|
76
|
+
else names.push(...bindings.elements.filter((element) => !element.isTypeOnly).map((element) => element.name.text))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return names
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 提取文件里的运行时依赖(含 re-export;`import type` 不计)
|
|
84
|
+
* @param file 文件绝对路径
|
|
85
|
+
* @param text 文件内容
|
|
86
|
+
* @returns [{ spec, line, names }]:模块路径、真实行号、引入的本地名
|
|
87
|
+
*/
|
|
88
|
+
export function importsFrom(file, text) {
|
|
89
|
+
const imports = []
|
|
90
|
+
|
|
91
|
+
for (const block of parseBlocks(file, text)) {
|
|
92
|
+
const { sourceFile, offset } = block
|
|
93
|
+
|
|
94
|
+
for (const node of sourceFile.statements) {
|
|
95
|
+
const specifier = specifierOf(node)
|
|
96
|
+
if (!specifier || isTypeOnly(node)) continue
|
|
97
|
+
|
|
98
|
+
imports.push({
|
|
99
|
+
spec: specifier.text,
|
|
100
|
+
line: offset + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
101
|
+
names: importedNames(node)
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return imports
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 按名字找函数声明
|
|
111
|
+
* @param sourceFile TS 语法树
|
|
112
|
+
* @param name 函数名
|
|
113
|
+
* @returns 函数节点或 null
|
|
114
|
+
*/
|
|
115
|
+
export function findFunction(sourceFile, name) {
|
|
116
|
+
for (const statement of sourceFile.statements) {
|
|
117
|
+
if (!ts.isFunctionDeclaration(statement)) continue
|
|
118
|
+
if (statement.name?.text === name) return statement
|
|
119
|
+
}
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 中枢函数体违约:if 分支 / await / 直接调用 ctx.api
|
|
125
|
+
* @param hub 中枢函数节点
|
|
126
|
+
* @returns 违约描述列表
|
|
127
|
+
*/
|
|
128
|
+
export function hubViolations(hub) {
|
|
129
|
+
const found = new Set()
|
|
130
|
+
|
|
131
|
+
const visit = (node) => {
|
|
132
|
+
if (ts.isIfStatement(node)) found.add('出现了 if 分支')
|
|
133
|
+
else if (ts.isAwaitExpression(node)) found.add('出现了 await')
|
|
134
|
+
else if (ts.isPropertyAccessExpression(node) && node.getText().startsWith('ctx.api')) {
|
|
135
|
+
found.add('直接调用了 ctx.api')
|
|
136
|
+
}
|
|
137
|
+
ts.forEachChild(node, visit)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (hub.body) visit(hub.body)
|
|
141
|
+
|
|
142
|
+
return [...found]
|
|
143
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// ============ 护栏检查:按「层」判 import 越界,不按模块 ============
|
|
2
|
+
// 模块级(中枢 / 执行器 / 零件)按 <domains>/<模块> 扫;
|
|
3
|
+
// 层级(视图 / 状态 / 基础设施)全局扫一遍 —— 因为文件边界是层边界,不是模块边界。
|
|
4
|
+
// 所有判定只看运行时 import(import type 放行):类型不构成依赖。
|
|
5
|
+
// 目录前缀一律由目录映射推出,规则本身不认识具体目录名。
|
|
6
|
+
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { importsFrom } from './ast.mjs'
|
|
9
|
+
import { PURE_UTILS, RULES } from './rules.mjs'
|
|
10
|
+
import { importSpec } from '../config/paths.mjs'
|
|
11
|
+
import { read, relativePath, walkFiles } from '../utils/files.mjs'
|
|
12
|
+
|
|
13
|
+
const PURE_NAMES = PURE_UTILS.map((spec) => spec.replace(/^@\/utils\//, ''))
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 护栏检查入口
|
|
17
|
+
* @param modules 模块描述列表(见 scan.mjs 的 describeModule)
|
|
18
|
+
* @param root 项目根目录
|
|
19
|
+
* @param dirs 目录映射
|
|
20
|
+
* @returns 偏离清单 [{ rule, level, file, line, message, basis, fix }]
|
|
21
|
+
*/
|
|
22
|
+
export function checkBoundaries(modules, root, dirs) {
|
|
23
|
+
const problems = []
|
|
24
|
+
importCache.clear()
|
|
25
|
+
|
|
26
|
+
for (const module of modules) inspectModule(module, root, dirs, problems)
|
|
27
|
+
|
|
28
|
+
inspectViews(root, dirs, problems)
|
|
29
|
+
inspectStores(root, dirs, problems)
|
|
30
|
+
inspectUtils(root, dirs, problems)
|
|
31
|
+
|
|
32
|
+
return problems.sort((left, right) => left.file.localeCompare(right.file) || left.line - right.line)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function report(problems, rule, file, line, note = '') {
|
|
36
|
+
const meta = RULES[rule]
|
|
37
|
+
problems.push({
|
|
38
|
+
rule,
|
|
39
|
+
level: meta.level,
|
|
40
|
+
file,
|
|
41
|
+
line,
|
|
42
|
+
message: meta.title,
|
|
43
|
+
note,
|
|
44
|
+
basis: meta.basis,
|
|
45
|
+
fix: meta.fix
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const importCache = new Map()
|
|
50
|
+
|
|
51
|
+
function imports(filename) {
|
|
52
|
+
const cached = importCache.get(filename)
|
|
53
|
+
if (cached) return cached
|
|
54
|
+
|
|
55
|
+
const parsed = importsFrom(filename, read(filename))
|
|
56
|
+
importCache.set(filename, parsed)
|
|
57
|
+
return parsed
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 模块级:中枢不许抓零件、中枢应单例、执行器互不引用、执行器只认 ctx */
|
|
61
|
+
function inspectModule(module, root, dirs, problems) {
|
|
62
|
+
if (module.hub) {
|
|
63
|
+
const hubFile = path.join(root, module.hub)
|
|
64
|
+
const hubText = read(hubFile)
|
|
65
|
+
|
|
66
|
+
for (const imported of importsFrom(hubFile, hubText)) {
|
|
67
|
+
if (/^(\.\.?\/)+steps\//.test(imported.spec)) {
|
|
68
|
+
report(problems, 'HUB-01', module.hub, imported.line, `import ${imported.spec}`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isSingletonHub(hubText)) {
|
|
73
|
+
report(problems, 'HUB-03', module.hub, 1, '没找到缓存的实例(每次调用都会新建一个)')
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const transportPrefixes = [
|
|
78
|
+
importSpec(dirs, 'api'),
|
|
79
|
+
importSpec(dirs, 'stores'),
|
|
80
|
+
importSpec(dirs, 'router'),
|
|
81
|
+
'@/store'
|
|
82
|
+
]
|
|
83
|
+
|
|
84
|
+
for (const executor of module.executors) {
|
|
85
|
+
for (const imported of imports(executor)) {
|
|
86
|
+
const file = relativePath(root, executor)
|
|
87
|
+
|
|
88
|
+
if (/^(\.\.?\/)+executors\//.test(imported.spec) || imported.spec.startsWith('./')) {
|
|
89
|
+
report(problems, 'EXE-01', file, imported.line, `import ${imported.spec}`)
|
|
90
|
+
}
|
|
91
|
+
if (matchesAnyPrefix(imported.spec, transportPrefixes)) {
|
|
92
|
+
report(problems, 'EXE-02', file, imported.line, `import ${imported.spec}`)
|
|
93
|
+
}
|
|
94
|
+
if (isForbiddenUtil(imported.spec, dirs)) {
|
|
95
|
+
report(problems, 'EXE-03', file, imported.line, `import ${imported.spec}`)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const step of module.steps) {
|
|
101
|
+
for (const imported of imports(step)) {
|
|
102
|
+
const file = relativePath(root, step)
|
|
103
|
+
|
|
104
|
+
if (matchesAnyPrefix(imported.spec, transportPrefixes)) {
|
|
105
|
+
report(problems, 'EXE-02', file, imported.line, `import ${imported.spec}`)
|
|
106
|
+
}
|
|
107
|
+
if (isForbiddenUtil(imported.spec, dirs)) {
|
|
108
|
+
report(problems, 'EXE-03', file, imported.line, `import ${imported.spec}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 零件复用数:只被 0 或 1 个执行器引用 = 抽早了或死代码
|
|
114
|
+
for (const step of module.steps) {
|
|
115
|
+
const name = path.basename(step, '.ts')
|
|
116
|
+
const users = new Set()
|
|
117
|
+
|
|
118
|
+
for (const executor of module.executors) {
|
|
119
|
+
for (const imported of imports(executor)) {
|
|
120
|
+
if (new RegExp(`steps/${escapeRegExp(name)}$`).test(imported.spec)) {
|
|
121
|
+
users.add(executor)
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (users.size < 2) {
|
|
127
|
+
report(
|
|
128
|
+
problems,
|
|
129
|
+
'STEP-01',
|
|
130
|
+
relativePath(root, step),
|
|
131
|
+
1,
|
|
132
|
+
users.size === 0 ? '没有被任何执行器使用' : '只有 1 个执行器使用'
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 视图层:不碰网络层、不碰执行器与零件、不自己造中枢实例 */
|
|
139
|
+
function inspectViews(root, dirs, problems) {
|
|
140
|
+
const apiBase = importSpec(dirs, 'api')
|
|
141
|
+
const utilsBase = importSpec(dirs, 'utils')
|
|
142
|
+
const domainsBase = importSpec(dirs, 'domains')
|
|
143
|
+
const transportUtils = new Set([`${utilsBase}/request`, `${utilsBase}/stream`, `${utilsBase}/token`])
|
|
144
|
+
const factories = new RegExp(`^${escapeRegExp(domainsBase)}\\/[^/]+$`)
|
|
145
|
+
|
|
146
|
+
for (const file of walkFiles(path.join(root, dirs.views), ['.ts', '.vue'])) {
|
|
147
|
+
const rel = relativePath(root, file)
|
|
148
|
+
for (const imported of imports(file)) {
|
|
149
|
+
if (
|
|
150
|
+
imported.spec.startsWith(`${apiBase}/`) ||
|
|
151
|
+
transportUtils.has(imported.spec) ||
|
|
152
|
+
new RegExp(`${escapeRegExp(domainsBase)}\\/[^/]+\\/(executors|steps)\\/`).test(imported.spec)
|
|
153
|
+
) {
|
|
154
|
+
report(problems, 'VIEW-01', rel, imported.line, `import ${imported.spec}`)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (factories.test(imported.spec)) {
|
|
158
|
+
const factory = imported.names.find((name) => /^create[A-Z]\w*Module$/.test(name))
|
|
159
|
+
if (factory) report(problems, 'VIEW-02', rel, imported.line, `import { ${factory} } from '${imported.spec}'`)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 状态层:不发请求 */
|
|
166
|
+
function inspectStores(root, dirs, problems) {
|
|
167
|
+
const apiBase = importSpec(dirs, 'api')
|
|
168
|
+
|
|
169
|
+
for (const file of walkFiles(path.join(root, dirs.stores), ['.ts'])) {
|
|
170
|
+
const rel = relativePath(root, file)
|
|
171
|
+
for (const imported of imports(file)) {
|
|
172
|
+
if (imported.spec.startsWith(`${apiBase}/`)) {
|
|
173
|
+
report(problems, 'STORE-01', rel, imported.line, `import ${imported.spec}`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 基础设施:只上报不越权(不做展示、不感知业务模块) */
|
|
180
|
+
function inspectUtils(root, dirs, problems) {
|
|
181
|
+
const viewsBase = importSpec(dirs, 'views')
|
|
182
|
+
const domainsBase = importSpec(dirs, 'domains')
|
|
183
|
+
|
|
184
|
+
for (const file of walkFiles(path.join(root, dirs.utils), ['.ts'])) {
|
|
185
|
+
const rel = relativePath(root, file)
|
|
186
|
+
for (const imported of imports(file)) {
|
|
187
|
+
if (
|
|
188
|
+
imported.spec === 'element-plus' ||
|
|
189
|
+
imported.spec.startsWith(`${viewsBase}/`) ||
|
|
190
|
+
imported.spec.startsWith(`${domainsBase}/`)
|
|
191
|
+
) {
|
|
192
|
+
report(problems, 'UTIL-01', rel, imported.line, `import ${imported.spec}`)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function matchesAnyPrefix(spec, prefixes) {
|
|
199
|
+
return prefixes.some((prefix) => spec === prefix || spec.startsWith(`${prefix}/`))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isForbiddenUtil(spec, dirs) {
|
|
203
|
+
const base = importSpec(dirs, 'utils')
|
|
204
|
+
if (spec === base) return true
|
|
205
|
+
if (!spec.startsWith(`${base}/`)) return false
|
|
206
|
+
return !PURE_NAMES.includes(spec.slice(base.length + 1))
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 中枢是否模块级单例:惰性缓存(let + if (!x))或模块级常量 */
|
|
210
|
+
function isSingletonHub(text) {
|
|
211
|
+
if (/(?:export\s+)?const\s+\w+\s*(?::\s*[^=]+)?=\s*create[A-Z]\w*Module\s*\(/.test(text)) return true
|
|
212
|
+
|
|
213
|
+
const cached = [...text.matchAll(/\blet\s+([A-Za-z_$][\w$]*)/g)].map((match) => match[1])
|
|
214
|
+
return cached.some((name) => new RegExp(`if\\s*\\(\\s*!\\s*${escapeRegExp(name)}\\s*\\)`).test(text))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function escapeRegExp(value) {
|
|
218
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
219
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// ============ 契约检查:中枢活在不在、导出名对不对、有没有变厚 ============
|
|
2
|
+
// 两种模块都要过:config 驱动的(标准 5 文件 + 动作挂载)和纯手写的(只有中枢契约)。
|
|
3
|
+
// 手写模块的视图目录名不一定等于模块名(如 chat → views/Home),所以不查页面模板。
|
|
4
|
+
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { ts } from '../utils/ts.mjs'
|
|
7
|
+
import { exists, listFiles, read } from '../utils/files.mjs'
|
|
8
|
+
import { findFunction, hubViolations } from './ast.mjs'
|
|
9
|
+
import { pascal } from '../generate/names.mjs'
|
|
10
|
+
import { relPath } from '../config/paths.mjs'
|
|
11
|
+
|
|
12
|
+
const PAGE_LABEL = { list: '列表页', detail: '详情页', form: '表单页' }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 契约检查:中枢是否存在、createXxxModule 是否存在、中枢是否违约(if / await / ctx.api)、
|
|
16
|
+
* config 模块另外检查页面模板与动作挂载。只做静态分析,不执行模块代码。
|
|
17
|
+
* @param modules 模块描述列表(source = 'config' | 'manual')
|
|
18
|
+
* @param root 项目根目录
|
|
19
|
+
* @param dirs 目录映射
|
|
20
|
+
* @returns 问题清单 [{ file, line, message }]
|
|
21
|
+
*/
|
|
22
|
+
export function checkContracts(modules, root, dirs) {
|
|
23
|
+
const problems = []
|
|
24
|
+
|
|
25
|
+
for (const module of modules) checkModule(module, root, dirs, problems)
|
|
26
|
+
|
|
27
|
+
return problems
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkModule(module, root, dirs, problems) {
|
|
31
|
+
const type = pascal(module.name)
|
|
32
|
+
const hubRel = relPath(dirs, 'domains', module.name, 'index.ts')
|
|
33
|
+
const hubFile = path.join(root, hubRel)
|
|
34
|
+
const manual = module.source === 'manual'
|
|
35
|
+
|
|
36
|
+
// 1. 中枢必须存在(sync 只补一次骨架,删了就是真没了)
|
|
37
|
+
if (!exists(hubFile)) {
|
|
38
|
+
problems.push({
|
|
39
|
+
file: hubRel,
|
|
40
|
+
message: `missing hub file ${hubRel}, run tha sync ${module.name} to scaffold it (少了模块入口,运行 sync 会补一个)`
|
|
41
|
+
})
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 2. 页面模板:只对 config 模块要求;按 pages 逐页检查
|
|
46
|
+
// (手写模块的视图目录名不必等于模块名,所以整体跳过)
|
|
47
|
+
if (!manual) {
|
|
48
|
+
const pages = module.domain.pages ?? ['list']
|
|
49
|
+
const pageFiles = { list: 'Index.vue', detail: 'Detail.vue', form: 'Form.vue' }
|
|
50
|
+
|
|
51
|
+
for (const page of pages) {
|
|
52
|
+
const viewRel = relPath(dirs, 'views', module.name, pageFiles[page] ?? 'Index.vue')
|
|
53
|
+
if (!exists(path.join(root, viewRel))) {
|
|
54
|
+
problems.push({
|
|
55
|
+
file: viewRel,
|
|
56
|
+
message: `missing ${page} page, run tha sync to generate it, or write it yourself then adopt (少了这个页面)`
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const text = read(hubFile)
|
|
63
|
+
const expected = `create${type}Module`
|
|
64
|
+
const alias = `use${type}Module`
|
|
65
|
+
const sourceFile = ts.createSourceFile(hubFile, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
66
|
+
|
|
67
|
+
// 3. 装配入口必须存在:createXxxModule(生成器产出的装配函数)或 useXxxModule(单例访问)
|
|
68
|
+
const hub = findFunction(sourceFile, expected) ?? findFunction(sourceFile, alias)
|
|
69
|
+
if (!hub) {
|
|
70
|
+
problems.push({
|
|
71
|
+
file: hubRel,
|
|
72
|
+
message: `expected ${expected}() or ${alias}() in hub, the file may have been edited (模块入口里少了这两个函数)`
|
|
73
|
+
})
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 4. 中枢违约:只挂载,不判断、不 await、不调接口
|
|
78
|
+
for (const violation of hubViolations(hub)) {
|
|
79
|
+
problems.push({
|
|
80
|
+
file: hubRel,
|
|
81
|
+
message: `hub must only mount actions, found: ${violation} (模块入口出现了不该有的逻辑)`
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 5. 动作必须挂上(只对 config 模块:手写模块的动作由用例决定)
|
|
86
|
+
if (manual) return
|
|
87
|
+
|
|
88
|
+
// 6. 声明了权限码,执行器里就得真有判断(adopt 之后最容易丢的就是这一行)
|
|
89
|
+
checkPermissionGuards(module, root, dirs, problems)
|
|
90
|
+
|
|
91
|
+
const mounted = mountedActions(hub)
|
|
92
|
+
const crud = module.domain.crud ?? {}
|
|
93
|
+
const pages = module.domain.pages ?? ['list']
|
|
94
|
+
|
|
95
|
+
const required = ['refresh']
|
|
96
|
+
if (crud.create) required.push('create')
|
|
97
|
+
if (crud.update) required.push('update')
|
|
98
|
+
if (crud.remove) required.push('remove')
|
|
99
|
+
if (pages.includes('detail') || pages.includes('form')) required.push('load')
|
|
100
|
+
|
|
101
|
+
// 兼容旧骨架:save 同时承担新增与编辑
|
|
102
|
+
const legacySave = mounted.has('save')
|
|
103
|
+
|
|
104
|
+
for (const action of required) {
|
|
105
|
+
if (mounted.has(action)) continue
|
|
106
|
+
if (legacySave && (action === 'create' || action === 'update')) continue
|
|
107
|
+
problems.push({
|
|
108
|
+
file: hubRel,
|
|
109
|
+
message: `missing action ${action} in hub, mount it or drop the capability from config (入口没挂这个动作)`
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 声明了权限码,执行器里就必须真有一处 can(...) 判断。
|
|
116
|
+
* adopt 之后生成物归人管,生成模板里的 ctx.can 可能被删掉 —— 这里是唯一还能发现的地方。
|
|
117
|
+
*/
|
|
118
|
+
function checkPermissionGuards(module, root, dirs, problems) {
|
|
119
|
+
const codes = module.domain.permissions
|
|
120
|
+
if (!codes || Object.keys(codes).length === 0) return
|
|
121
|
+
|
|
122
|
+
const executorsRel = relPath(dirs, 'domains', module.name, 'executors')
|
|
123
|
+
const dir = path.join(root, executorsRel)
|
|
124
|
+
if (!exists(dir)) return
|
|
125
|
+
|
|
126
|
+
const files = listFiles(dir, '.ts')
|
|
127
|
+
if (files.length === 0) return
|
|
128
|
+
|
|
129
|
+
const hasGuard = files.some((file) => /\bcan\s*\(/.test(stripComments(read(path.join(dir, file)))))
|
|
130
|
+
|
|
131
|
+
if (hasGuard) return
|
|
132
|
+
|
|
133
|
+
problems.push({
|
|
134
|
+
file: executorsRel,
|
|
135
|
+
message:
|
|
136
|
+
'permissions declared, but no ctx.can() anywhere in the action files (配了权限码却没做权限判断,等于没开权限)'
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stripComments(text) {
|
|
141
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function unwrapExpr(node) {
|
|
145
|
+
let current = node
|
|
146
|
+
|
|
147
|
+
while (
|
|
148
|
+
ts.isParenthesizedExpression(current) ||
|
|
149
|
+
ts.isAsExpression(current) ||
|
|
150
|
+
ts.isTypeAssertionExpression(current) ||
|
|
151
|
+
(typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(current)) ||
|
|
152
|
+
ts.isNonNullExpression(current)
|
|
153
|
+
) {
|
|
154
|
+
current = current.expression
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return current
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 中枢 return 的对象字面量里挂了哪些动作名
|
|
162
|
+
*/
|
|
163
|
+
function mountedActions(hub) {
|
|
164
|
+
const actions = new Set()
|
|
165
|
+
const body = hub.body
|
|
166
|
+
if (!body) return actions
|
|
167
|
+
|
|
168
|
+
for (const statement of body.statements) {
|
|
169
|
+
if (!ts.isReturnStatement(statement) || !statement.expression) continue
|
|
170
|
+
|
|
171
|
+
const expression = unwrapExpr(statement.expression)
|
|
172
|
+
if (!ts.isObjectLiteralExpression(expression)) continue
|
|
173
|
+
|
|
174
|
+
for (const property of expression.properties) {
|
|
175
|
+
if (
|
|
176
|
+
ts.isPropertyAssignment(property) ||
|
|
177
|
+
ts.isMethodDeclaration(property) ||
|
|
178
|
+
ts.isShorthandPropertyAssignment(property)
|
|
179
|
+
) {
|
|
180
|
+
actions.add(property.name.getText().replace(/['"]/g, ''))
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return actions
|
|
186
|
+
}
|