tha-cli 0.0.0-stage → 0.1.0

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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +193 -0
  2. package/LICENSE +21 -0
  3. package/README.md +408 -2
  4. package/package.json +42 -4
  5. package/tha/cli.mjs +116 -0
  6. package/tha/commands/adopt.mjs +119 -0
  7. package/tha/commands/check.mjs +226 -0
  8. package/tha/commands/diff.mjs +70 -0
  9. package/tha/commands/init.mjs +262 -0
  10. package/tha/commands/remove.mjs +328 -0
  11. package/tha/commands/sync.mjs +826 -0
  12. package/tha/config/field.mjs +178 -0
  13. package/tha/config/load.mjs +188 -0
  14. package/tha/config/manifest.mjs +75 -0
  15. package/tha/config/paths.mjs +124 -0
  16. package/tha/config/project.mjs +52 -0
  17. package/tha/config/rules.mjs +85 -0
  18. package/tha/config/schema.mjs +306 -0
  19. package/tha/config/template.mjs +78 -0
  20. package/tha/generate/domain/api.mjs +67 -0
  21. package/tha/generate/domain/context.mjs +76 -0
  22. package/tha/generate/domain/detail.mjs +35 -0
  23. package/tha/generate/domain/entry.mjs +91 -0
  24. package/tha/generate/domain/executors.mjs +93 -0
  25. package/tha/generate/domain/form.mjs +34 -0
  26. package/tha/generate/domain/index.mjs +72 -0
  27. package/tha/generate/domain/permissions.mjs +35 -0
  28. package/tha/generate/domain/store.mjs +68 -0
  29. package/tha/generate/domain/view.mjs +29 -0
  30. package/tha/generate/global.mjs +111 -0
  31. package/tha/generate/header.mjs +29 -0
  32. package/tha/generate/names.mjs +45 -0
  33. package/tha/generate/plan.mjs +41 -0
  34. package/tha/kit/ThaCrudPage.vue +344 -0
  35. package/tha/kit/ThaDetail.vue +146 -0
  36. package/tha/kit/ThaForm.vue +194 -0
  37. package/tha/kit/request.ts +30 -0
  38. package/tha/kit/transport.ts +11 -0
  39. package/tha/utils/args.mjs +59 -0
  40. package/tha/utils/compare.mjs +38 -0
  41. package/tha/utils/files.mjs +175 -0
  42. package/tha/utils/format.mjs +23 -0
  43. package/tha/utils/output.mjs +79 -0
  44. package/tha/utils/style.mjs +147 -0
  45. package/tha/utils/ts.mjs +73 -0
  46. package/tha/verify/ast.mjs +143 -0
  47. package/tha/verify/boundary.mjs +219 -0
  48. package/tha/verify/contract.mjs +186 -0
  49. package/tha/verify/openapi.mjs +345 -0
  50. package/tha/verify/rules.mjs +91 -0
  51. package/tha/verify/scan.mjs +59 -0
  52. package/tha.mjs +22 -0
@@ -0,0 +1,178 @@
1
+ import {
2
+ ENUM_COLORS,
3
+ FIELD_KEYS,
4
+ FIELD_TYPES,
5
+ OPTION_KEYS,
6
+ SEARCH_TYPES,
7
+ error,
8
+ isPlainObject
9
+ } from './rules.mjs'
10
+
11
+ export function validateFields(errors, file, value) {
12
+ if (!isPlainObject(value)) {
13
+ error(errors, file, 'fields', '必须是对象,至少一个字段')
14
+ return null
15
+ }
16
+
17
+ const keys = Object.keys(value)
18
+ if (keys.length === 0) {
19
+ error(errors, file, 'fields', '至少一个字段')
20
+ return null
21
+ }
22
+
23
+ const fields = {}
24
+ let ok = true
25
+
26
+ for (const key of keys) {
27
+ const at = `fields.${key}`
28
+ if (!/^[a-z][a-zA-Z0-9]*$/.test(key)) {
29
+ error(errors, file, at, '字段名必须是小写开头,例如 title')
30
+ ok = false
31
+ continue
32
+ }
33
+ if (key === 'id') {
34
+ error(errors, file, at, 'id 由 CLI 生成,不用手写')
35
+ ok = false
36
+ continue
37
+ }
38
+
39
+ const raw = value[key]
40
+ if (!isPlainObject(raw)) {
41
+ error(errors, file, at, '必须是对象')
42
+ ok = false
43
+ continue
44
+ }
45
+
46
+ const before = errors.length
47
+
48
+ for (const property of Object.keys(raw)) {
49
+ if (!FIELD_KEYS.has(property)) {
50
+ error(errors, file, `${at}.${property}`, '不认识的配置项')
51
+ }
52
+ }
53
+
54
+ if (typeof raw.label !== 'string' || !raw.label.trim()) {
55
+ error(errors, file, `${at}.label`, '必须是非空字符串')
56
+ }
57
+
58
+ if (!FIELD_TYPES.has(raw.type)) {
59
+ error(errors, file, `${at}.type`, `类型不支持;可用:${[...FIELD_TYPES].join('、')}`)
60
+ }
61
+
62
+ for (const flag of ['required', 'list', 'form', 'search', 'readonly']) {
63
+ if (raw[flag] !== undefined && typeof raw[flag] !== 'boolean') {
64
+ error(errors, file, `${at}.${flag}`, '必须是 boolean')
65
+ }
66
+ }
67
+
68
+ if (raw.readonly === true && raw.form === true) {
69
+ error(errors, file, `${at}.form`, 'readonly 字段不能同时出现在表单里')
70
+ }
71
+
72
+ if (raw.search === true && !SEARCH_TYPES.has(raw.type)) {
73
+ error(errors, file, `${at}.search`, '只有 string、enum、number、date、datetime 可以搜索')
74
+ }
75
+
76
+ const options = validateOptions(errors, file, `${at}.options`, raw)
77
+ validateDefault(errors, file, `${at}.default`, raw, options)
78
+
79
+ if (errors.length !== before) {
80
+ ok = false
81
+ continue
82
+ }
83
+
84
+ fields[key] = {
85
+ label: raw.label.trim(),
86
+ type: raw.type,
87
+ required: raw.required === true,
88
+ list: raw.list !== false,
89
+ form: raw.readonly === true ? false : raw.form !== false,
90
+ search: raw.search === true,
91
+ readonly: raw.readonly === true,
92
+ ...(options ? { options } : {}),
93
+ ...(raw.default !== undefined ? { default: raw.default } : {})
94
+ }
95
+ }
96
+
97
+ return ok ? fields : null
98
+ }
99
+
100
+ function validateOptions(errors, file, at, field) {
101
+ if (field.type !== 'enum') {
102
+ if (field.options !== undefined) {
103
+ error(errors, file, at, '只有类型为 enum 的字段可以写 options')
104
+ }
105
+ return null
106
+ }
107
+
108
+ if (!Array.isArray(field.options) || field.options.length === 0) {
109
+ error(errors, file, at, '类型为 enum 的字段必须提供非空 options')
110
+ return null
111
+ }
112
+
113
+ const options = []
114
+ const values = new Set()
115
+
116
+ field.options.forEach((option, index) => {
117
+ const optionAt = `${at}[${index}]`
118
+ if (!isPlainObject(option)) {
119
+ error(errors, file, optionAt, '必须是 { label, value, color? }')
120
+ return
121
+ }
122
+
123
+ for (const key of Object.keys(option)) {
124
+ if (!OPTION_KEYS.has(key)) error(errors, file, `${optionAt}.${key}`, '不认识的配置项')
125
+ }
126
+
127
+ if (typeof option.label !== 'string' || !option.label.trim()) {
128
+ error(errors, file, `${optionAt}.label`, '必须是非空字符串')
129
+ }
130
+ if (typeof option.value !== 'string' || !option.value) {
131
+ error(errors, file, `${optionAt}.value`, '必须是非空字符串')
132
+ } else if (values.has(option.value)) {
133
+ error(errors, file, `${optionAt}.value`, `可选值重复:${option.value}`)
134
+ } else {
135
+ values.add(option.value)
136
+ }
137
+ if (option.color !== undefined && !ENUM_COLORS.has(option.color)) {
138
+ error(errors, file, `${optionAt}.color`, `未知颜色;可用:${[...ENUM_COLORS].join('、')}`)
139
+ }
140
+
141
+ options.push({
142
+ label: option.label,
143
+ value: option.value,
144
+ ...(option.color ? { color: option.color } : {})
145
+ })
146
+ })
147
+
148
+ return options
149
+ }
150
+
151
+ function validateDefault(errors, file, at, field, options) {
152
+ if (field.default === undefined) return
153
+
154
+ if (field.type === 'enum') {
155
+ if (typeof field.default !== 'string' || !options?.some((option) => option.value === field.default)) {
156
+ error(errors, file, at, 'enum 的默认值必须是 options 里的某个 value')
157
+ }
158
+ return
159
+ }
160
+
161
+ if (field.type === 'number') {
162
+ if (typeof field.default !== 'number' || !Number.isFinite(field.default)) {
163
+ error(errors, file, at, 'number 默认值必须是数字')
164
+ }
165
+ return
166
+ }
167
+
168
+ if (field.type === 'boolean') {
169
+ if (typeof field.default !== 'boolean') {
170
+ error(errors, file, at, 'boolean 默认值必须是 true 或 false')
171
+ }
172
+ return
173
+ }
174
+
175
+ if (typeof field.default !== 'string') {
176
+ error(errors, file, at, `${field.type} 默认值必须是字符串`)
177
+ }
178
+ }
@@ -0,0 +1,188 @@
1
+ import path from 'node:path'
2
+ import { ts } from '../utils/ts.mjs'
3
+ import { exists, listDirectories, read, relativePath } from '../utils/files.mjs'
4
+ import { absPath } from './paths.mjs'
5
+
6
+ export function loadConfigs(root, dirs) {
7
+ const domainsDir = absPath(root, dirs, 'domains')
8
+ const configs = []
9
+ const errors = []
10
+
11
+ for (const dirName of listDirectories(domainsDir)) {
12
+ const file = path.join(domainsDir, dirName, 'config.ts')
13
+ if (!exists(file)) continue
14
+
15
+ const relativeFile = relativePath(root, file)
16
+
17
+ try {
18
+ const parsed = parseConfigFile(file)
19
+ configs.push({
20
+ ...parsed,
21
+ file,
22
+ relativeFile,
23
+ dirName
24
+ })
25
+ } catch (error) {
26
+ errors.push({
27
+ file: relativeFile,
28
+ path: '',
29
+ message: error instanceof Error ? error.message : String(error)
30
+ })
31
+ }
32
+ }
33
+
34
+ return { configs, errors }
35
+ }
36
+
37
+ export function parseConfigFile(file) {
38
+ const text = read(file)
39
+ const sourceFile = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
40
+
41
+ assertTypeOnlyImports(sourceFile)
42
+
43
+ const candidates = []
44
+
45
+ for (const statement of sourceFile.statements) {
46
+ if (!ts.isVariableStatement(statement)) continue
47
+
48
+ for (const declaration of statement.declarationList.declarations) {
49
+ if (!declaration.initializer) continue
50
+
51
+ const initializer = unwrap(declaration.initializer)
52
+ if (!ts.isObjectLiteralExpression(initializer)) continue
53
+ if (!hasProperty(initializer, 'name')) continue
54
+
55
+ candidates.push({
56
+ node: initializer,
57
+ exportName: declaration.name.getText(sourceFile)
58
+ })
59
+ }
60
+ }
61
+
62
+ if (candidates.length === 0) {
63
+ throw new Error('没有找到配置对象:请写 export const <模块>Config = { ... }')
64
+ }
65
+
66
+ if (candidates.length > 1) {
67
+ throw new Error('找到多个带 name 的配置对象,只能有一个模块配置')
68
+ }
69
+
70
+ const candidate = candidates[0]
71
+ const value = readLiteral(candidate.node, sourceFile)
72
+
73
+ return {
74
+ value,
75
+ exportName: candidate.exportName
76
+ }
77
+ }
78
+
79
+ function assertTypeOnlyImports(sourceFile) {
80
+ for (const statement of sourceFile.statements) {
81
+ if (!ts.isImportDeclaration(statement)) continue
82
+
83
+ const clause = statement.importClause
84
+ if (!clause) {
85
+ throw new Error('config 只能写 import type,不能执行其他代码')
86
+ }
87
+ if (clause.isTypeOnly) continue
88
+
89
+ const bindings = clause.namedBindings
90
+ if (bindings && ts.isNamedImports(bindings) && bindings.elements.every((element) => element.isTypeOnly)) {
91
+ continue
92
+ }
93
+
94
+ throw new Error('config 只能写 import type,不能写运行时 import')
95
+ }
96
+ }
97
+
98
+ function unwrap(node) {
99
+ let current = node
100
+
101
+ while (
102
+ ts.isParenthesizedExpression(current) ||
103
+ ts.isAsExpression(current) ||
104
+ ts.isTypeAssertionExpression(current) ||
105
+ (typeof ts.isSatisfiesExpression === 'function' && ts.isSatisfiesExpression(current)) ||
106
+ ts.isNonNullExpression(current)
107
+ ) {
108
+ current = current.expression
109
+ }
110
+
111
+ return current
112
+ }
113
+
114
+ function hasProperty(node, name) {
115
+ return node.properties.some((property) => {
116
+ if (!ts.isPropertyAssignment(property)) return false
117
+ return propertyName(property.name) === name
118
+ })
119
+ }
120
+
121
+ function propertyName(name) {
122
+ if (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) {
123
+ return name.text
124
+ }
125
+ return null
126
+ }
127
+
128
+ function readLiteral(node, sourceFile) {
129
+ if (ts.isObjectLiteralExpression(node)) {
130
+ const object = {}
131
+ const seen = new Set()
132
+
133
+ for (const property of node.properties) {
134
+ if (!ts.isPropertyAssignment(property)) {
135
+ throw at(node, sourceFile, '只能直接写 key: value,不能用 spread、方法或简写')
136
+ }
137
+
138
+ const key = propertyName(property.name)
139
+ if (!key) {
140
+ throw at(property.name, sourceFile, '不允许计算属性名')
141
+ }
142
+ if (seen.has(key)) {
143
+ throw at(property.name, sourceFile, `重复的键:${key}`)
144
+ }
145
+ seen.add(key)
146
+
147
+ object[key] = readLiteral(property.initializer, sourceFile)
148
+ }
149
+
150
+ return object
151
+ }
152
+
153
+ if (ts.isArrayLiteralExpression(node)) {
154
+ return node.elements.map((element) => {
155
+ if (ts.isSpreadElement(element) || ts.isOmittedExpression(element)) {
156
+ throw at(element, sourceFile, '数组里不允许 spread 或空位')
157
+ }
158
+ return readLiteral(element, sourceFile)
159
+ })
160
+ }
161
+
162
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
163
+ return node.text
164
+ }
165
+
166
+ if (ts.isNumericLiteral(node)) {
167
+ return Number(node.text)
168
+ }
169
+
170
+ if (node.kind === ts.SyntaxKind.TrueKeyword) return true
171
+ if (node.kind === ts.SyntaxKind.FalseKeyword) return false
172
+
173
+ if (
174
+ ts.isPrefixUnaryExpression(node) &&
175
+ node.operator === ts.SyntaxKind.MinusToken &&
176
+ ts.isNumericLiteral(node.operand)
177
+ ) {
178
+ return -Number(node.operand.text)
179
+ }
180
+
181
+ throw at(node, sourceFile, `只能直接写值,不能写 ${ts.SyntaxKind[node.kind]};也不能引用变量或调用函数`)
182
+ }
183
+
184
+ function at(node, sourceFile, message) {
185
+ const position = node.getStart(sourceFile)
186
+ const { line, character } = sourceFile.getLineAndCharacterOfPosition(position)
187
+ return new Error(`第 ${line + 1} 行第 ${character + 1} 列:${message}`)
188
+ }
@@ -0,0 +1,75 @@
1
+ // ============ 交接清单:哪些生成位改由人维护 ============
2
+ // adopt 的语义只有一个:这个文件不再由 CLI 生成,但仍然在架构里、照常被护栏检查。
3
+ // 工具放弃「写」,不放弃「管」——否则手写文件只能变成永久 conflict。
4
+
5
+ import path from 'node:path'
6
+ import { exists, read, toPosix, writeAtomic } from '../utils/files.mjs'
7
+
8
+ const MANIFEST = '.tha/manifest.json'
9
+
10
+ /**
11
+ * 读取交接清单
12
+ * @param root 项目根目录
13
+ * @returns { version, manual: string[] }(manual 为相对路径,posix 分隔)
14
+ */
15
+ export function loadManifest(root) {
16
+ const file = path.join(root, MANIFEST)
17
+ if (!exists(file)) return { version: 1, manual: [] }
18
+
19
+ try {
20
+ const parsed = JSON.parse(read(file))
21
+ return {
22
+ version: 1,
23
+ manual: Array.isArray(parsed.manual) ? parsed.manual.map((entry) => toPosix(entry)) : []
24
+ }
25
+ } catch {
26
+ return { version: 1, manual: [] }
27
+ }
28
+ }
29
+
30
+ /**
31
+ * 交接清单里的路径集合(比较用)
32
+ * @param root 项目根目录
33
+ * @returns Set<string>
34
+ */
35
+ export function manualPaths(root) {
36
+ return new Set(loadManifest(root).manual)
37
+ }
38
+
39
+ /**
40
+ * 把文件记入交接清单
41
+ * @param root 项目根目录
42
+ * @param paths 相对路径列表(posix)
43
+ * @returns 新增的路径
44
+ */
45
+ export function adoptFiles(root, paths) {
46
+ const manifest = loadManifest(root)
47
+ const existing = new Set(manifest.manual)
48
+ const added = paths.map((entry) => toPosix(entry)).filter((entry) => !existing.has(entry))
49
+
50
+ save(root, [...existing, ...added])
51
+ return added
52
+ }
53
+
54
+ /**
55
+ * 从交接清单移除(交回生成管理)
56
+ * @param root 项目根目录
57
+ * @param paths 相对路径列表(posix)
58
+ * @returns 实际移除的路径
59
+ */
60
+ export function releaseFiles(root, paths) {
61
+ const manifest = loadManifest(root)
62
+ const wanted = new Set(paths.map((entry) => toPosix(entry)))
63
+ const removed = manifest.manual.filter((entry) => wanted.has(entry))
64
+
65
+ save(
66
+ root,
67
+ manifest.manual.filter((entry) => !wanted.has(entry))
68
+ )
69
+ return removed
70
+ }
71
+
72
+ function save(root, manual) {
73
+ const file = path.join(root, MANIFEST)
74
+ writeAtomic(file, `${JSON.stringify({ version: 1, manual: [...manual].sort() }, null, 2)}\n`)
75
+ }
@@ -0,0 +1,124 @@
1
+ // ============ 目录映射:一处定义,所有模板与检查器共用 ============
2
+ // 默认值与约定一致(src/domains、src/views …)。
3
+ // 项目根放一个 tha.config.json 就能整体搬走,CLI 不再写死任何目录名。
4
+
5
+ import path from 'node:path'
6
+ import { exists, read } from '../utils/files.mjs'
7
+
8
+ export const DIR_KEYS = [
9
+ 'domains',
10
+ 'views',
11
+ 'stores',
12
+ 'api',
13
+ 'router',
14
+ 'layouts',
15
+ 'components',
16
+ 'utils'
17
+ ]
18
+
19
+ export const DEFAULT_DIRS = Object.freeze({
20
+ domains: 'src/domains',
21
+ views: 'src/views',
22
+ stores: 'src/stores',
23
+ api: 'src/api',
24
+ router: 'src/router',
25
+ layouts: 'src/layouts',
26
+ components: 'src/components',
27
+ utils: 'src/utils'
28
+ })
29
+
30
+ const CONFIG_FILE = 'tha.config.json'
31
+ const SAFE_PATH = /^[a-zA-Z0-9_.-]+(?:[a-zA-Z0-9_.-]*[a-zA-Z0-9_.-])?(?:\/[a-zA-Z0-9_.-]+)*$/
32
+
33
+ /**
34
+ * 读取目录映射
35
+ * @param root 项目根目录
36
+ * @returns { dirs, errors, configured } —— configured 表示项目里确实有这个文件
37
+ */
38
+ export function loadDirs(root) {
39
+ const file = path.join(root, CONFIG_FILE)
40
+ const errors = []
41
+
42
+ if (!exists(file)) return { dirs: { ...DEFAULT_DIRS }, errors, configured: false }
43
+
44
+ let raw
45
+ try {
46
+ raw = JSON.parse(read(file))
47
+ } catch (err) {
48
+ const message = err instanceof Error ? err.message : String(err)
49
+ errors.push({ file: CONFIG_FILE, path: '', message: `不是合法 JSON:${message}` })
50
+ return { dirs: { ...DEFAULT_DIRS }, errors, configured: true }
51
+ }
52
+
53
+ if (!isPlainObject(raw)) {
54
+ errors.push({ file: CONFIG_FILE, path: '', message: '必须是对象,例如 { "dirs": { "views": "src/pages" } }' })
55
+ return { dirs: { ...DEFAULT_DIRS }, errors, configured: true }
56
+ }
57
+
58
+ for (const key of Object.keys(raw)) {
59
+ if (key !== 'dirs') {
60
+ errors.push({ file: CONFIG_FILE, path: key, message: '不认识的配置项;只支持 dirs' })
61
+ }
62
+ }
63
+
64
+ const dirs = { ...DEFAULT_DIRS }
65
+ const value = raw.dirs
66
+
67
+ if (value === undefined) return { dirs, errors, configured: true }
68
+
69
+ if (!isPlainObject(value)) {
70
+ errors.push({ file: CONFIG_FILE, path: 'dirs', message: '必须是对象,例如 { "views": "src/pages" }' })
71
+ return { dirs, errors, configured: true }
72
+ }
73
+
74
+ for (const key of Object.keys(value)) {
75
+ const at = `dirs.${key}`
76
+ if (!DIR_KEYS.includes(key)) {
77
+ errors.push({ file: CONFIG_FILE, path: at, message: `不认识的目录键;可用:${DIR_KEYS.join('、')}` })
78
+ continue
79
+ }
80
+ const dirValue = value[key]
81
+ if (typeof dirValue !== 'string' || !dirValue.trim()) {
82
+ errors.push({ file: CONFIG_FILE, path: at, message: '必须是非空字符串,例如 "src/pages"' })
83
+ continue
84
+ }
85
+ const normalized = dirValue.trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
86
+ if (!SAFE_PATH.test(normalized) || normalized.split('/').some((part) => part === '..' || part === '.')) {
87
+ errors.push({ file: CONFIG_FILE, path: at, message: '必须是项目内的相对路径,不能写绝对路径或 ..' })
88
+ continue
89
+ }
90
+ dirs[key] = normalized
91
+ }
92
+
93
+ return { dirs, errors, configured: true }
94
+ }
95
+
96
+ /**
97
+ * 相对项目根的路径
98
+ * @returns 例如 src/views/doc/Index.vue
99
+ */
100
+ export function relPath(dirs, key, ...rest) {
101
+ return [dirs[key], ...rest].filter(Boolean).join('/')
102
+ }
103
+
104
+ /**
105
+ * 绝对路径
106
+ */
107
+ export function absPath(root, dirs, key, ...rest) {
108
+ return path.join(root, relPath(dirs, key, ...rest))
109
+ }
110
+
111
+ /**
112
+ * 生成代码里的 import 路径(@ 是项目源码别名)
113
+ * @returns 例如 @/views/doc/Index.vue
114
+ */
115
+ export function importSpec(dirs, key, ...rest) {
116
+ const base = dirs[key].startsWith('src/') ? dirs[key].slice('src/'.length) : dirs[key]
117
+ return ['@', base, ...rest].filter(Boolean).join('/')
118
+ }
119
+
120
+ export { CONFIG_FILE }
121
+
122
+ function isPlainObject(value) {
123
+ return Object.prototype.toString.call(value) === '[object Object]'
124
+ }
@@ -0,0 +1,52 @@
1
+ // ============ 项目装载:config 驱动模块 + 手写模块 ============
2
+ // 两件事分开:domains 是「构造机管的」,manual 是「人写的但仍在架构里」。
3
+ // 后者不参与生成与漂移比较,但一样要过契约与护栏检查。
4
+
5
+ import { loadConfigs } from './load.mjs'
6
+ import { validateProject } from './schema.mjs'
7
+ import { loadDirs, absPath, relPath } from './paths.mjs'
8
+ import { exists } from '../utils/files.mjs'
9
+ import { scanManualModules } from '../verify/scan.mjs'
10
+
11
+ export function loadProject(root) {
12
+ // 目录映射先读:所有路径推导都依赖它,读错就全错
13
+ const { dirs, errors: dirErrors } = loadDirs(root)
14
+
15
+ if (dirErrors.length > 0) {
16
+ return { domains: [], manual: [], errors: dirErrors, dirs }
17
+ }
18
+
19
+ const domainsDir = absPath(root, dirs, 'domains')
20
+
21
+ // 目录本身不存在 = 项目还没初始化;目录存在但没有 config = 合法的空项目(删光模块后 sync 仍要能跑)
22
+ if (!exists(domainsDir)) {
23
+ return {
24
+ domains: [],
25
+ manual: [],
26
+ errors: [
27
+ {
28
+ file: relPath(dirs, 'domains'),
29
+ path: '',
30
+ message: `没有 ${relPath(dirs, 'domains')} 目录:先运行 tha init <模块>`
31
+ }
32
+ ],
33
+ dirs
34
+ }
35
+ }
36
+
37
+ const { configs, errors: parseErrors } = loadConfigs(root, dirs)
38
+ const manual = scanManualModules(root, new Set(configs.map((config) => config.dirName)), dirs)
39
+
40
+ if (configs.length === 0) {
41
+ return { domains: [], manual, errors: parseErrors, dirs }
42
+ }
43
+
44
+ const result = validateProject(configs)
45
+
46
+ return {
47
+ domains: result.domains,
48
+ manual,
49
+ errors: [...parseErrors, ...result.errors],
50
+ dirs
51
+ }
52
+ }
@@ -0,0 +1,85 @@
1
+ // 小写字母开头,允许小驼峰(提示文案里一直写着支持 userProfile,正则必须跟上)
2
+ export const NAME_RE = /^[a-z][a-zA-Z0-9]*$/
3
+ export const PATH_RE = /^\/(?:[a-zA-Z0-9_-]+)(?:\/[a-zA-Z0-9_-]+)*$/
4
+ export const API_RE = /^\/(?:[a-zA-Z0-9_-]+)(?:\/[a-zA-Z0-9_-]+)*$/
5
+
6
+ export const FIELD_TYPES = new Set([
7
+ 'string',
8
+ 'text',
9
+ 'number',
10
+ 'boolean',
11
+ 'enum',
12
+ 'date',
13
+ 'datetime'
14
+ ])
15
+
16
+ export const SEARCH_TYPES = new Set(['string', 'enum', 'number', 'date', 'datetime'])
17
+ export const ENUM_COLORS = new Set(['gray', 'ok', 'warn', 'red', 'blue'])
18
+
19
+ export const TOP_KEYS = new Set([
20
+ 'name',
21
+ 'title',
22
+ 'path',
23
+ 'api',
24
+ 'fields',
25
+ 'list',
26
+ 'crud',
27
+ 'permissions',
28
+ 'pages'
29
+ ])
30
+
31
+ // api 只认两个键:接口地址 + 可选的后端契约文件(OpenAPI)
32
+ export const API_KEYS = new Set(['base', 'contract'])
33
+
34
+ // 页面模板:list 必生成,detail / form 按需开启
35
+ export const PAGE_NAMES = new Set(['list', 'detail', 'form'])
36
+ export const DEFAULT_PAGES = ['list', 'detail']
37
+
38
+ export const FIELD_KEYS = new Set([
39
+ 'label',
40
+ 'type',
41
+ 'required',
42
+ 'list',
43
+ 'form',
44
+ 'search',
45
+ 'readonly',
46
+ 'options',
47
+ 'default'
48
+ ])
49
+
50
+ export const OPTION_KEYS = new Set(['label', 'value', 'color'])
51
+ export const LIST_KEYS = new Set(['pageSize', 'orderBy', 'order'])
52
+
53
+ // 只有这几种类型能用来排序:布尔、枚举排出来没有业务含义
54
+ export const SORT_TYPES = new Set(['string', 'text', 'number', 'date', 'datetime'])
55
+ export const CRUD_KEYS = new Set(['create', 'update', 'remove'])
56
+ export const PERMISSION_KEYS = new Set(['view', 'create', 'update', 'remove'])
57
+
58
+ export const RESERVED_NAMES = new Set([
59
+ 'api',
60
+ 'stores',
61
+ 'domains',
62
+ 'views',
63
+ 'router',
64
+ 'layouts',
65
+ 'components',
66
+ 'utils',
67
+ 'composables'
68
+ ])
69
+
70
+ export function normalizeUrlPath(value, fallback) {
71
+ let result = String(value ?? fallback ?? '').trim()
72
+ if (!result) result = String(fallback ?? '').trim()
73
+ if (!result) return ''
74
+ if (!result.startsWith('/')) result = `/${result}`
75
+ result = result.replace(/\/+$/, '')
76
+ return result
77
+ }
78
+
79
+ export function isPlainObject(value) {
80
+ return Object.prototype.toString.call(value) === '[object Object]'
81
+ }
82
+
83
+ export function error(errors, file, at, message) {
84
+ errors.push({ file, path: at, message })
85
+ }