@manohub/app-kit 0.2.0 → 0.2.3
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/CONTRACT.md +174 -9
- package/README.md +9 -5
- package/bin/appkit.mjs +121 -0
- package/lint/__tests__/guardrails.spec.mjs +154 -0
- package/lint/component-audit.mjs +1 -1
- package/lint/guardrails.config.schema.json +42 -0
- package/lint/pre-commit.sample +38 -26
- package/lint/run-all.mjs +63 -59
- package/lint/shared.mjs +462 -347
- package/lint/structure-audit.mjs +1 -1
- package/lint/style-audit.mjs +1 -1
- package/package.json +7 -3
- package/skills/README.md +32 -10
- package/skills/app-kit/SKILL.md +19 -11
- package/skills/app-kit/references/adoption.md +76 -6
- package/skills/app-kit/references/contract-index.md +7 -2
- package/skills/app-kit-dev/SKILL.md +40 -8
- package/skills/app-kit-dev/references/page-recipes.md +53 -5
- package/skills/app-kit-dev/references/style-rules.md +2 -2
- package/skills/app-kit-migrate/SKILL.md +30 -11
- package/skills/app-kit-migrate/references/migration-playbook.md +53 -4
- package/skills/install.mjs +298 -203
package/lint/shared.mjs
CHANGED
|
@@ -1,347 +1,462 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 护栏共享基建:配置加载 / 文件遍历 / 注释剥离 / 违规输出与 correction 表。
|
|
3
|
-
*
|
|
4
|
-
* 三条护栏脚本(style / component / structure)与 run-all 统一依赖本文件,
|
|
5
|
-
* 保证「同一份配置 + 同一种输出契约」,消费方只需写一个配置文件。
|
|
6
|
-
*
|
|
7
|
-
* 输出契约(大模型自我修正的前提):
|
|
8
|
-
* { app, file, rule, line, severity, message, snippet,
|
|
9
|
-
* correction: { summary, example, doc } }
|
|
10
|
-
*/
|
|
11
|
-
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
12
|
-
import { join, relative, extname, sep } from 'node:path'
|
|
13
|
-
import { execSync } from 'node:child_process'
|
|
14
|
-
|
|
15
|
-
/** 默认配置(消费方可覆盖);`apps[].dir` 相对消费仓根(= process.cwd())。 */
|
|
16
|
-
export const DEFAULT_CONFIG = {
|
|
17
|
-
apps: [],
|
|
18
|
-
srcGlobs: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
19
|
-
styleGlobs: ['src/**/*.css'],
|
|
20
|
-
exemptionRegistry: null,
|
|
21
|
-
contractDoc: 'node_modules/@manohub/app-kit/CONTRACT.md',
|
|
22
|
-
ignore: ['node_modules', 'dist'],
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 护栏共享基建:配置加载 / 文件遍历 / 注释剥离 / 违规输出与 correction 表。
|
|
3
|
+
*
|
|
4
|
+
* 三条护栏脚本(style / component / structure)与 run-all 统一依赖本文件,
|
|
5
|
+
* 保证「同一份配置 + 同一种输出契约」,消费方只需写一个配置文件。
|
|
6
|
+
*
|
|
7
|
+
* 输出契约(大模型自我修正的前提):
|
|
8
|
+
* { app, file, rule, line, severity, message, snippet,
|
|
9
|
+
* correction: { summary, example, doc } }
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
12
|
+
import { join, relative, extname, sep } from 'node:path'
|
|
13
|
+
import { execSync } from 'node:child_process'
|
|
14
|
+
|
|
15
|
+
/** 默认配置(消费方可覆盖);`apps[].dir` 相对消费仓根(= process.cwd())。 */
|
|
16
|
+
export const DEFAULT_CONFIG = {
|
|
17
|
+
apps: [],
|
|
18
|
+
srcGlobs: ['src/**/*.ts', 'src/**/*.tsx'],
|
|
19
|
+
styleGlobs: ['src/**/*.css'],
|
|
20
|
+
exemptionRegistry: null,
|
|
21
|
+
contractDoc: 'node_modules/@manohub/app-kit/CONTRACT.md',
|
|
22
|
+
ignore: ['node_modules', 'dist'],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 规则通配匹配:支持精确名(`api/farris-import`)与整类通配(`structure/*`)。
|
|
27
|
+
*
|
|
28
|
+
* 只允许「尾部一个 `*`」这一种通配:规则名是 `域/规则` 两段,`*` 只用来表达「整个域」,
|
|
29
|
+
* 中间含 `*` 的写法(如 `style/*-element`)会让人以为能精确匹配、实际很难推理,不实现。
|
|
30
|
+
*/
|
|
31
|
+
export function matchRule(pattern, rule) {
|
|
32
|
+
if (typeof pattern !== 'string' || !pattern || typeof rule !== 'string') return false
|
|
33
|
+
if (pattern === rule) return true
|
|
34
|
+
if (pattern.endsWith('/*')) return rule.startsWith(`${pattern.slice(0, -1)}`)
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 取某应用登记的豁免规则模式(兼容 `"rule"` 简写与 `{ rule, reason }` 对象两种写法) */
|
|
39
|
+
export function waivedPatternsOf(app) {
|
|
40
|
+
return (app?.waivedRules ?? [])
|
|
41
|
+
.map((entry) => (typeof entry === 'string' ? entry : entry?.rule))
|
|
42
|
+
.filter((rule) => typeof rule === 'string' && rule.length > 0)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function loadConfig(cwd = process.cwd()) {
|
|
46
|
+
const path = join(cwd, 'appkit-guardrails.config.json')
|
|
47
|
+
if (!existsSync(path)) {
|
|
48
|
+
fail(
|
|
49
|
+
`未找到护栏配置:${path}\n` +
|
|
50
|
+
`请在应用包根创建 appkit-guardrails.config.json(结构见 @manohub/app-kit/lint/guardrails.config.schema.json)。`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
const raw = JSON.parse(readFileSync(path, 'utf8'))
|
|
54
|
+
const config = { ...DEFAULT_CONFIG, ...raw }
|
|
55
|
+
if (!Array.isArray(config.apps) || config.apps.length === 0) {
|
|
56
|
+
fail('配置里的 apps 不能为空(至少要列出被审计的应用目录)。')
|
|
57
|
+
}
|
|
58
|
+
// 豁免必须写明理由:豁免是「暂时接受的技术债」,不是开关。
|
|
59
|
+
// 不写理由的豁免等于给了个静音键,半年后没人知道为什么留着。
|
|
60
|
+
for (const app of config.apps) {
|
|
61
|
+
const name = app?.name ?? app?.dir ?? '(未命名应用)'
|
|
62
|
+
for (const entry of app?.waivedRules ?? []) {
|
|
63
|
+
const rule = typeof entry === 'string' ? entry : entry?.rule
|
|
64
|
+
const reason = typeof entry === 'string' ? '' : entry?.reason
|
|
65
|
+
if (typeof rule !== 'string' || !rule) {
|
|
66
|
+
fail(`应用 ${name} 的 waivedRules 里有条目缺少 rule(应为 "域/规则" 或 "域/*")。`)
|
|
67
|
+
}
|
|
68
|
+
if (!reason) {
|
|
69
|
+
fail(
|
|
70
|
+
`应用 ${name} 的 waivedRules 里 ${rule} 缺少 reason:\n` +
|
|
71
|
+
`豁免要写清「为什么暂时不查」与「什么时候收口」,形如\n` +
|
|
72
|
+
` { "rule": "${rule}", "reason": "只迁骨架(Shell-only),组件替换在下一批次", "since": "2026-09-18" }`,
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return config
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function fail(message) {
|
|
81
|
+
process.stderr.write(`\n[guardrails] ${message}\n\n`)
|
|
82
|
+
process.exit(2)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 简易 CLI 参数解析:--json / --strict / --changed / --app=<name> / --cwd=<path> */
|
|
86
|
+
export function parseArgs(argv = process.argv.slice(2)) {
|
|
87
|
+
const args = { json: false, strict: false, changed: false, app: null, cwd: process.cwd() }
|
|
88
|
+
for (const raw of argv) {
|
|
89
|
+
if (raw === '--json') args.json = true
|
|
90
|
+
else if (raw === '--strict') args.strict = true
|
|
91
|
+
else if (raw === '--changed') args.changed = true
|
|
92
|
+
else if (raw.startsWith('--app=')) args.app = raw.slice('--app='.length)
|
|
93
|
+
else if (raw.startsWith('--cwd=')) args.cwd = raw.slice('--cwd='.length)
|
|
94
|
+
}
|
|
95
|
+
return args
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** 递归收集文件:按扩展名 + 配置的 glob 范围过滤,跳过 ignore 目录与 .d.ts。 */
|
|
99
|
+
export function collectFiles(root, config, extensions, globs = null) {
|
|
100
|
+
const out = []
|
|
101
|
+
const ignore = new Set(config.ignore)
|
|
102
|
+
const matchers = (globs ?? []).map(globToRegExp)
|
|
103
|
+
const walk = (dir) => {
|
|
104
|
+
if (!existsSync(dir)) return
|
|
105
|
+
for (const entry of readdirSync(dir)) {
|
|
106
|
+
if (ignore.has(entry) || entry.startsWith('.')) continue
|
|
107
|
+
const full = join(dir, entry)
|
|
108
|
+
const st = statSync(full)
|
|
109
|
+
if (st.isDirectory()) {
|
|
110
|
+
walk(full)
|
|
111
|
+
} else if (extensions.includes(extname(entry)) && !entry.endsWith('.d.ts')) {
|
|
112
|
+
if (matchers.length > 0) {
|
|
113
|
+
const rel = relative(root, full).split(sep).join('/')
|
|
114
|
+
if (!matchers.some((m) => m.test(rel))) continue
|
|
115
|
+
}
|
|
116
|
+
out.push(full)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
walk(root)
|
|
121
|
+
return out
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 极简 glob → RegExp(支持 `**` 与 `*`),用于 srcGlobs / styleGlobs。
|
|
126
|
+
*
|
|
127
|
+
* 注意替换顺序:双星号通配(globstar)必须先换成**占位符**,再做单星号替换,
|
|
128
|
+
* 否则刚插入的 `.*` 会被单星号那条规则二次改坏
|
|
129
|
+
* —— 曾因此只匹配到顶层文件、嵌套目录被静默漏扫(自测里有回归用例)。
|
|
130
|
+
*/
|
|
131
|
+
const GLOBSTAR_SLASH = '\u0000GS\u0000'
|
|
132
|
+
const GLOBSTAR = '\u0000G\u0000'
|
|
133
|
+
|
|
134
|
+
export function globToRegExp(glob) {
|
|
135
|
+
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
136
|
+
const re = escaped
|
|
137
|
+
.replace(/\*\*\//g, GLOBSTAR_SLASH)
|
|
138
|
+
.replace(/\*\*/g, GLOBSTAR)
|
|
139
|
+
.replace(/\*/g, '[^/]*')
|
|
140
|
+
.replace(new RegExp(GLOBSTAR_SLASH, 'g'), '(?:.*/)?')
|
|
141
|
+
.replace(new RegExp(GLOBSTAR, 'g'), '.*')
|
|
142
|
+
return new RegExp(`^${re}$`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `--changed` 用的 git 改动文件集(相对仓根,posix 分隔符)。
|
|
147
|
+
*
|
|
148
|
+
* **必须是两半之和**:
|
|
149
|
+
* 1. `git diff --name-only HEAD` —— 已跟踪文件的改动(含已暂存);
|
|
150
|
+
* 2. `git ls-files --others --exclude-standard` —— **未加入索引的新增文件**。
|
|
151
|
+
*
|
|
152
|
+
* 少了第 2 半会出「假绿」:新写一个页面(还没 `git add`)时,`--changed` 根本看不到它,
|
|
153
|
+
* 于是「新增文件必须归零」的自检输出全绿 —— 而新增文件恰恰是最该查的一类。
|
|
154
|
+
*/
|
|
155
|
+
export function changedFiles(cwd) {
|
|
156
|
+
// `stdio` 显式关掉 stderr 透传:execSync 默认会把子进程 stderr 直接打到终端,
|
|
157
|
+
// 于是非 git 目录下跑 `--changed` 会甩出一整屏 git 用法说明(那是正常的「不裁剪」分支,不是错误)。
|
|
158
|
+
const opts = { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
|
159
|
+
let tracked
|
|
160
|
+
try {
|
|
161
|
+
tracked = execSync('git diff --name-only HEAD', opts)
|
|
162
|
+
} catch {
|
|
163
|
+
return null // 非 git 环境或 git 不可用:不裁剪(宁可多检查)
|
|
164
|
+
}
|
|
165
|
+
let untracked = ''
|
|
166
|
+
try {
|
|
167
|
+
untracked = execSync('git ls-files --others --exclude-standard', opts)
|
|
168
|
+
} catch {
|
|
169
|
+
untracked = '' // 取不到新增列表时退回「只看已跟踪改动」,不因次要命令失败而整条放弃
|
|
170
|
+
}
|
|
171
|
+
return new Set(
|
|
172
|
+
`${tracked}\n${untracked}`
|
|
173
|
+
.split('\n')
|
|
174
|
+
.map((s) => s.trim())
|
|
175
|
+
.filter(Boolean),
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 剥离注释,但**保留行号**(违规行号必须与源码一致)。
|
|
181
|
+
* 处理 `/* *\/` 与 `//`;引号内的 `//`(如 URL)不会被误判。
|
|
182
|
+
*/
|
|
183
|
+
export function stripComments(source) {
|
|
184
|
+
let out = ''
|
|
185
|
+
let i = 0
|
|
186
|
+
const n = source.length
|
|
187
|
+
let inBlock = false
|
|
188
|
+
let inLine = false
|
|
189
|
+
let quote = null
|
|
190
|
+
while (i < n) {
|
|
191
|
+
const ch = source[i]
|
|
192
|
+
const next = source[i + 1]
|
|
193
|
+
if (inBlock) {
|
|
194
|
+
if (ch === '*' && next === '/') {
|
|
195
|
+
out += ' '
|
|
196
|
+
i += 2
|
|
197
|
+
inBlock = false
|
|
198
|
+
continue
|
|
199
|
+
}
|
|
200
|
+
out += ch === '\n' ? '\n' : ' '
|
|
201
|
+
i += 1
|
|
202
|
+
continue
|
|
203
|
+
}
|
|
204
|
+
if (inLine) {
|
|
205
|
+
if (ch === '\n') {
|
|
206
|
+
out += '\n'
|
|
207
|
+
inLine = false
|
|
208
|
+
} else {
|
|
209
|
+
out += ' '
|
|
210
|
+
}
|
|
211
|
+
i += 1
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
if (quote) {
|
|
215
|
+
out += ch
|
|
216
|
+
if (ch === '\\') {
|
|
217
|
+
out += source[i + 1] ?? ''
|
|
218
|
+
i += 2
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
if (ch === quote) quote = null
|
|
222
|
+
i += 1
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
if (ch === '/' && next === '*') {
|
|
226
|
+
out += ' '
|
|
227
|
+
i += 2
|
|
228
|
+
inBlock = true
|
|
229
|
+
continue
|
|
230
|
+
}
|
|
231
|
+
if (ch === '/' && next === '/') {
|
|
232
|
+
out += ' '
|
|
233
|
+
i += 2
|
|
234
|
+
inLine = true
|
|
235
|
+
continue
|
|
236
|
+
}
|
|
237
|
+
if (ch === '"' || ch === "'" || ch === '`') quote = ch
|
|
238
|
+
out += ch
|
|
239
|
+
i += 1
|
|
240
|
+
}
|
|
241
|
+
return out
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** 相对仓根的展示路径(统一 posix 分隔符)。 */
|
|
245
|
+
export function displayPath(cwd, file) {
|
|
246
|
+
return relative(cwd, file).split(sep).join('/')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** correction:唯一改法 + 正确写法 + CONTRACT.md 锚点(大模型据此自我修正)。 */
|
|
250
|
+
export const CORRECTIONS = {
|
|
251
|
+
'style/tailwind': {
|
|
252
|
+
summary: '删掉 Tailwind 引入;样式走 app-kit 三行链与 --ui-* 令牌。',
|
|
253
|
+
example: '@import "@manohub/app-kit/styles.css";',
|
|
254
|
+
doc: 'CONTRACT.md#1-消费方式',
|
|
255
|
+
},
|
|
256
|
+
'style/token': {
|
|
257
|
+
summary: '不要在应用侧定义 CSS 变量/@theme;需要新令牌请在 app-kit 内加。',
|
|
258
|
+
example: '给 app-kit 提 issue/PR,在 packages/app-kit/src/styles/tokens.css 增加令牌',
|
|
259
|
+
doc: 'CONTRACT.md#6-样式纪律',
|
|
260
|
+
},
|
|
261
|
+
'style/visual': {
|
|
262
|
+
summary: '视觉属性一律删除,改由组件承载或使用 --ui-* 令牌。',
|
|
263
|
+
example: 'background: var(--ui-base-200); →(删除,交给组件/令牌)',
|
|
264
|
+
doc: 'CONTRACT.md#6-样式纪律',
|
|
265
|
+
},
|
|
266
|
+
'style/important': {
|
|
267
|
+
summary: '删掉 !important;确需覆写底层组件库内部样式时,写进包内 farris-bridge.css。',
|
|
268
|
+
example: '(app 侧)height: 2rem; // 去掉 !important',
|
|
269
|
+
doc: 'CONTRACT.md#6-样式纪律',
|
|
270
|
+
},
|
|
271
|
+
'style/bare-element': {
|
|
272
|
+
summary: '裸元素选择器会污染宿主,改为带应用前缀的类名。',
|
|
273
|
+
example: '.vm-manage button { … } → .vm-toolbar-action { … }',
|
|
274
|
+
doc: 'CONTRACT.md#6-样式纪律',
|
|
275
|
+
},
|
|
276
|
+
'style/prefix': {
|
|
277
|
+
summary: '选择器首段必须匹配本应用登记前缀(见配置文件 apps[].prefixes)。',
|
|
278
|
+
example: '.my-app-header { … }(前缀已在配置中登记)',
|
|
279
|
+
doc: 'CONTRACT.md#6-样式纪律',
|
|
280
|
+
},
|
|
281
|
+
'style/ak-selector': {
|
|
282
|
+
summary: '删除 .ak-* 选择器:那是 app-kit 的类名,应用侧只能用它的组件。',
|
|
283
|
+
example: '<AppPanel> 替代对 .ak-panel 的样式覆写',
|
|
284
|
+
doc: 'CONTRACT.md#6-样式纪律',
|
|
285
|
+
},
|
|
286
|
+
'style/rem': {
|
|
287
|
+
summary: '删掉自写 rem 尺寸:全仓统一 px 令牌,不跟随根字号缩放(见已知偏差)。',
|
|
288
|
+
example: 'padding: var(--ui-space-2); // 不要 0.5rem',
|
|
289
|
+
doc: 'CONTRACT.md#9-已知偏差',
|
|
290
|
+
},
|
|
291
|
+
'api/farris-import': {
|
|
292
|
+
summary: '删掉对底层组件库的直接 import,改用 app-kit 的 App* 件;缺件走建件流程。',
|
|
293
|
+
example: "import { AppTable } from '@manohub/app-kit'",
|
|
294
|
+
doc: 'CONTRACT.md#7-缺件处置流程',
|
|
295
|
+
},
|
|
296
|
+
'api/old-ui-import': {
|
|
297
|
+
summary: '禁止引入旧 UI 包;组件与图标分别用 @manohub/app-kit 与 @manohub/icon。',
|
|
298
|
+
example: "import { Icon } from '@manohub/icon'",
|
|
299
|
+
doc: 'CONTRACT.md#2-四条铁律',
|
|
300
|
+
},
|
|
301
|
+
'api/legacy-prop': {
|
|
302
|
+
summary: '这是底层组件库的写法;换成 app-kit 的统一 prop 词表。',
|
|
303
|
+
example: 'options={[{ label, value }]} / modelValue / onChange',
|
|
304
|
+
doc: 'CONTRACT.md#3-统一-prop-词表',
|
|
305
|
+
},
|
|
306
|
+
'structure/no-shell': {
|
|
307
|
+
summary: '路由级页面必须用 AppShell 承载结构。',
|
|
308
|
+
example: '<AppShell><AppShell.Header title="…" /><AppShell.Body mode="table">…</AppShell.Body></AppShell>',
|
|
309
|
+
doc: 'CONTRACT.md#41-三种页面模板',
|
|
310
|
+
},
|
|
311
|
+
'structure/header-title': {
|
|
312
|
+
summary: 'AppShell.Header 必须传 title(或登记为自定义页头白名单)。',
|
|
313
|
+
example: '<AppShell.Header title={t("page.title")} />',
|
|
314
|
+
doc: 'CONTRACT.md#41-三种页面模板',
|
|
315
|
+
},
|
|
316
|
+
'structure/body-mode': {
|
|
317
|
+
summary: 'AppShell.Body 必须显式声明 mode。',
|
|
318
|
+
example: '<AppShell.Body mode="table|scroll|plain">',
|
|
319
|
+
doc: 'CONTRACT.md#44-两级滚动归属不要混',
|
|
320
|
+
},
|
|
321
|
+
'structure/native-control': {
|
|
322
|
+
summary:
|
|
323
|
+
'原生控件(含无 href 的锚点)不要用来承载交互/外观:按钮 → AppButton、输入 → AppInput/AppTextarea、下拉 → AppSelect。',
|
|
324
|
+
example: '<AppButton shape="link" tone="error" onClick={() => remove(row)}>删除</AppButton>',
|
|
325
|
+
doc: 'CONTRACT.md#7-缺件处置流程',
|
|
326
|
+
},
|
|
327
|
+
'structure/viewport-height': {
|
|
328
|
+
summary: '禁用 100vh / h-screen;高度用 height:100%(子应用被注入宿主容器)。',
|
|
329
|
+
example: 'height: 100%;',
|
|
330
|
+
doc: 'CONTRACT.md#41-三种页面模板',
|
|
331
|
+
},
|
|
332
|
+
'structure/self-header': {
|
|
333
|
+
summary: '删掉自绘页头/面板头/表格框:页头→AppShell.Header、面板头→AppPanel、表格框→AppTable framed。',
|
|
334
|
+
example: '<AppPanel title="业务域"><AppTree … /></AppPanel>',
|
|
335
|
+
doc: 'CONTRACT.md#43-apppanel区域容器无框',
|
|
336
|
+
},
|
|
337
|
+
'structure/toolbar-fields': {
|
|
338
|
+
summary: '单个面板 toolbar 的字段数不得超过 3;超出请上提为页面级 AppShell.Filter。',
|
|
339
|
+
example: '<AppShell.Filter>…4 个以上字段…</AppShell.Filter>',
|
|
340
|
+
doc: 'CONTRACT.md#45-四处操作位唯一化先问过滤谁的数据再问几个字段',
|
|
341
|
+
},
|
|
342
|
+
'structure/self-border': {
|
|
343
|
+
summary: '删掉 Split 栏子元素上的竖直边框:分隔线由 AppShell.Split 统一提供。',
|
|
344
|
+
example: '(删除 border-right / border-left)',
|
|
345
|
+
doc: 'CONTRACT.md#42-appshellsplit双栏',
|
|
346
|
+
},
|
|
347
|
+
'structure/pagination-scope': {
|
|
348
|
+
summary:
|
|
349
|
+
'分页要与承载表格的容器同层:表格在 AppPanel 内 → 分页放 AppPanel.Footer;只有表格直接挂在 AppShell.Body 下才用 AppShell.Footer。',
|
|
350
|
+
example: '<AppPanel title="列表"><AppTable … /><AppPanel.Footer><AppPagination … /></AppPanel.Footer></AppPanel>',
|
|
351
|
+
doc: 'CONTRACT.md#43-apppanel区域容器无框',
|
|
352
|
+
},
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function makeViolation({ app, file, rule, line, severity = 'error', message, snippet }) {
|
|
356
|
+
return {
|
|
357
|
+
app,
|
|
358
|
+
file,
|
|
359
|
+
rule,
|
|
360
|
+
line,
|
|
361
|
+
severity,
|
|
362
|
+
message,
|
|
363
|
+
snippet: (snippet ?? '').trim().slice(0, 160),
|
|
364
|
+
correction: CORRECTIONS[rule] ?? null,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* 统一报告:人读格式 + 退出码。
|
|
370
|
+
*
|
|
371
|
+
* 两级「暂不拦断」的机制,都**照常报告、只是不计入退出码**(技术债必须可见):
|
|
372
|
+
*
|
|
373
|
+
* - `pending`(应用级):`apps[].pending = true` —— 整个应用挂起,用于存量应用刚接入时产基线。
|
|
374
|
+
* - `waivedRules`(规则级):`apps[].waivedRules` 里登记的规则(支持 `structure/*`)——
|
|
375
|
+
* 用于**分阶段迁移**,典型是「只迁骨架(Shell-only)」:结构类规则即刻纳入门禁,
|
|
376
|
+
* 组件直连与样式类登记豁免、下一批次再收。
|
|
377
|
+
*
|
|
378
|
+
* 两者在 `--strict` 时**一律失效**:收口验收就是跑一次 `--strict`,确认「不再有任何东西被挡着」。
|
|
379
|
+
* warn 一律不计入退出码(用于"建议上提"这类不可机械判定的项)。
|
|
380
|
+
*
|
|
381
|
+
* 另:豁免当前 0 条命中时会提示可撤销 —— 豁免只该在"还没迁到那一层"期间存在,
|
|
382
|
+
* 迁完就撤,否则它会静默地一直关着门。
|
|
383
|
+
*/
|
|
384
|
+
export function report(title, violations, args, config = null, extra = '') {
|
|
385
|
+
const apps = config?.apps ?? []
|
|
386
|
+
const pending = new Set(apps.filter((a) => a.pending).map((a) => a.name ?? a.dir))
|
|
387
|
+
const isPending = (v) => pending.has(v.app)
|
|
388
|
+
|
|
389
|
+
// 规则级豁免(分阶段迁移用):`--strict` 时一律失效 —— 收口验收就靠它。
|
|
390
|
+
const waivers = new Map(apps.map((a) => [a.name ?? a.dir, waivedPatternsOf(a)]))
|
|
391
|
+
const hitsByPattern = new Map()
|
|
392
|
+
const isWaived = (v) => {
|
|
393
|
+
if (args.strict) return false
|
|
394
|
+
for (const pattern of waivers.get(v.app) ?? []) {
|
|
395
|
+
if (matchRule(pattern, v.rule)) {
|
|
396
|
+
hitsByPattern.set(`${v.app}\u0000${pattern}`, (hitsByPattern.get(`${v.app}\u0000${pattern}`) ?? 0) + 1)
|
|
397
|
+
return true
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return false
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const rawErrors = violations.filter((v) => v.severity === 'error')
|
|
404
|
+
const waived = new Set(rawErrors.filter(isWaived))
|
|
405
|
+
const errors = rawErrors.filter((v) => !waived.has(v))
|
|
406
|
+
const warns = violations.filter((v) => v.severity !== 'error')
|
|
407
|
+
const blocking = args.strict ? errors : errors.filter((v) => !isPending(v))
|
|
408
|
+
const pendingErrors = errors.length - blocking.length
|
|
409
|
+
|
|
410
|
+
/** 豁免里「当前 0 条命中」的项:迁移往前走了,这些豁免该撤销了 */
|
|
411
|
+
const staleWaivers = []
|
|
412
|
+
for (const app of apps) {
|
|
413
|
+
if (args.app && app.name !== args.app && app.dir !== args.app) continue
|
|
414
|
+
for (const pattern of waivedPatternsOf(app)) {
|
|
415
|
+
if (!hitsByPattern.has(`${app.name ?? app.dir}\u0000${pattern}`)) {
|
|
416
|
+
staleWaivers.push(`${app.name ?? app.dir} 的 ${pattern}`)
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (args.json) {
|
|
422
|
+
const payload = violations.map((v) => (waived.has(v) ? { ...v, waived: true } : v))
|
|
423
|
+
process.stdout.write(JSON.stringify({ audit: title, violations: payload }, null, 2) + '\n')
|
|
424
|
+
return blocking.length > 0 ? 1 : 0
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (violations.length === 0) {
|
|
428
|
+
process.stdout.write(`✓ ${title}:无违规${extra ? `(${extra})` : ''}\n`)
|
|
429
|
+
} else {
|
|
430
|
+
const notes = []
|
|
431
|
+
if (pendingErrors > 0) notes.push(`${pendingErrors} 个属存量挂起`)
|
|
432
|
+
if (waived.size > 0) notes.push(`${waived.size} 条属已登记豁免`)
|
|
433
|
+
process.stdout.write(
|
|
434
|
+
`\n${title}:${errors.length} 个 error / ${warns.length} 个 warn` +
|
|
435
|
+
(notes.length > 0 ? `(其中 ${notes.join('、')},不计入退出码)` : '') +
|
|
436
|
+
'\n',
|
|
437
|
+
)
|
|
438
|
+
for (const v of violations) {
|
|
439
|
+
const labels = [v.severity]
|
|
440
|
+
if (isPending(v)) labels.push('pending')
|
|
441
|
+
if (waived.has(v)) labels.push('waived')
|
|
442
|
+
process.stdout.write(
|
|
443
|
+
` [${labels.join('/')}] ${v.file}:${v.line} ${v.rule}\n` +
|
|
444
|
+
` ${v.message}\n` +
|
|
445
|
+
(v.snippet ? ` > ${v.snippet}\n` : '') +
|
|
446
|
+
(waived.has(v) ? ` 豁免:已登记(不参与门禁;用 --strict 可验证「收口后会怎样」)\n` : '') +
|
|
447
|
+
(v.correction
|
|
448
|
+
? ` 改法:${v.correction.summary}\n 写法:${v.correction.example}\n 出处:${v.correction.doc}\n`
|
|
449
|
+
: ''),
|
|
450
|
+
)
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (staleWaivers.length > 0) {
|
|
454
|
+
process.stdout.write(
|
|
455
|
+
` 提示:以下豁免当前 0 条命中,可以撤销了(撤销后该规则即刻纳入门禁):\n` +
|
|
456
|
+
staleWaivers.map((item) => ` - ${item}`).join('\n') +
|
|
457
|
+
'\n',
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
process.stdout.write('\n')
|
|
461
|
+
return blocking.length > 0 ? 1 : 0
|
|
462
|
+
}
|