@manohub/kit 0.6.1 → 0.7.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.
- package/CONTRACT.md +86 -24
- package/bin/kit.mjs +12 -3
- package/package.json +1 -1
- package/skills/kit-migrate/references/migration-map.md +294 -294
- package/skills/kit-migrate/references/migration-playbook.md +188 -188
- package/skills/lint.mjs +500 -0
package/skills/lint.mjs
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `kit lint` —— 接入方的**机械护栏**(0.6.0 曾整批下线,0.7.0 起恢复最小三组)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么恢复:0.6.0 把三条脚本护栏下线、改成「CONTRACT.md + §7 手工自检」之后,成本立刻兑现 ——
|
|
6
|
+
* 三个应用迁完就累计出「3 份重复 reset」「1 处登记描述错误」「1 处注释引错文件名」,
|
|
7
|
+
* 每一项都是「有脚本就当场红」的类型。手工自检的问题不是不认真,是**每次都要重新认真一遍**。
|
|
8
|
+
*
|
|
9
|
+
* ## 分组(各自独立,可单独跑;默认全跑)
|
|
10
|
+
*
|
|
11
|
+
* | 组 | 检查 | 对应条款 |
|
|
12
|
+
* | --- | --- | --- |
|
|
13
|
+
* | `style` | `!important` / `.mh-*` 覆写 / 裸元素与 `*` / `rem` / `tailwind` | L1-3 L1-6 L1-7 |
|
|
14
|
+
* | `source` | 从白名单之外取外观(第三方组件库 / CSS 框架 / 图标库 / 深引子路径 / 旧包名) | L3-3 |
|
|
15
|
+
* | `namespace` | 业务类名不以本仓命名空间开头 | L1-5(需 `--namespace`) |
|
|
16
|
+
* | `property` | L1-4 属性闭集(视觉属性越界) | L1-4 |
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ **这是应用侧工具,别对着四包源码跑。** L1-4 / L1-6 约束的是**应用自绘的东西**
|
|
19
|
+
* (不许自绘颜色、不许用裸元素、不许碰 `.mh-*`),而库本身就是这些外观的实现方 ——
|
|
20
|
+
* 实测对 `packages/ui/src` 跑会输出 **1141 处**「覆写库的类」,全是假红。
|
|
21
|
+
* 脚本因此**按包名拦住四包**(`@manohub/{kit,ui,theme,icon}`)并提示该给哪个根,
|
|
22
|
+
* 而不是吐一堆不可执行的红。
|
|
23
|
+
*
|
|
24
|
+
* ## 覆盖范围(**有意保守,宁可漏报不误报**)
|
|
25
|
+
*
|
|
26
|
+
* - `style`:扫 `.css` 全文;`.vue` / `.tsx` / `.html` 只扫 `<style>` 块与 `class=` 的字面值。
|
|
27
|
+
* 动态拼出来的类名(`` `mh-x--${x}` ``)**不判**;裸元素**只看第一段复合选择器**
|
|
28
|
+
* (`.foo > span` 里的 `span` 不报 —— 应用自绘样式本就挂在自有类名下),
|
|
29
|
+
* 带属性锚点(`input[type='text']`)也放行。
|
|
30
|
+
* - `source`:按**已知外观库**黑名单判(不可能穷举,故同时看白名单前缀与子路径)。
|
|
31
|
+
* - `namespace`:需 `--namespace <前缀>`(可多次);不给则跳过并提示(本仓命名空间见 `docs/kit-namespaces.md`)。
|
|
32
|
+
* - **判不了的一律不报**:组件默认值是否与文档一致(§3.6 / §3.10 A4 那一类)需要读文档,属 §7 自检。
|
|
33
|
+
*
|
|
34
|
+
* ## 用法
|
|
35
|
+
*
|
|
36
|
+
* pnpm exec kit lint --root apps/skill-topic --namespace topic
|
|
37
|
+
* pnpm exec kit lint --group source # 只跑一组(可多次)
|
|
38
|
+
* pnpm exec kit lint --json # 机器可读输出
|
|
39
|
+
*
|
|
40
|
+
* 退出码:有违规 → 1;否则 0。**这里红的是「条款」,不是「风格偏好」** ——
|
|
41
|
+
* 修不了时按契约 §11 登记,登记后仍违规的几类(自绘页头 / 原生控件承载外观 / `!important` …)
|
|
42
|
+
* 本脚本**不会**为你放行,请改实现。
|
|
43
|
+
*/
|
|
44
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
45
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
46
|
+
// 与 bin/kit.mjs 同源:pnpm 把包放在 .pnpm 下、消费方引用的是软链,
|
|
47
|
+
// 直接比较 argv[1] 与 import.meta.url 会字面不等 → 脚本静默不执行。
|
|
48
|
+
import { isDirectRun } from './install.mjs'
|
|
49
|
+
|
|
50
|
+
/* ============================================================
|
|
51
|
+
* 一、契约里抄下来的闭集(**改契约时同步改这里**)
|
|
52
|
+
* ============================================================ */
|
|
53
|
+
|
|
54
|
+
/** L1-4 属性白名单(逐字展开自 `CONTRACT.md` §3「L1-4 的属性白名单」) */
|
|
55
|
+
const PROPERTY_WHITELIST = new Set([
|
|
56
|
+
// 盒模型与定位
|
|
57
|
+
'display', 'position', 'inset', 'top', 'right', 'bottom', 'left', 'z-index', 'box-sizing',
|
|
58
|
+
'width', 'height', 'min-width', 'min-height', 'max-width', 'max-height',
|
|
59
|
+
'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left',
|
|
60
|
+
'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left',
|
|
61
|
+
'overflow', 'overflow-x', 'overflow-y', 'gap', 'row-gap', 'column-gap', 'aspect-ratio', 'resize',
|
|
62
|
+
// 弹性与栅格
|
|
63
|
+
'flex', 'flex-direction', 'flex-wrap', 'flex-grow', 'flex-shrink', 'flex-basis',
|
|
64
|
+
'align-items', 'align-self', 'align-content', 'justify-content', 'justify-items', 'justify-self',
|
|
65
|
+
'order', 'place-items', 'place-content', 'grid-template-columns', 'grid-template-rows',
|
|
66
|
+
'grid-auto-flow', 'grid-auto-columns', 'grid-auto-rows', 'grid-column', 'grid-row',
|
|
67
|
+
// 文字流(不含颜色与字号)
|
|
68
|
+
'white-space', 'text-overflow', 'overflow-wrap', 'word-break', 'text-align', 'vertical-align',
|
|
69
|
+
'line-clamp', '-webkit-line-clamp', 'hyphens', 'direction', 'writing-mode',
|
|
70
|
+
// 交互与动效
|
|
71
|
+
'cursor', 'user-select', 'pointer-events', 'visibility',
|
|
72
|
+
'transition', 'transition-property', 'transition-duration', 'transition-delay', 'transition-timing-function',
|
|
73
|
+
'animation', 'animation-name', 'animation-duration', 'animation-delay', 'animation-iteration-count',
|
|
74
|
+
'animation-direction', 'animation-fill-mode',
|
|
75
|
+
'transform', 'transform-origin', 'will-change', 'scroll-behavior', 'overscroll-behavior',
|
|
76
|
+
'touch-action', 'scrollbar-width',
|
|
77
|
+
// 其他
|
|
78
|
+
'content', 'list-style', 'list-style-type', 'list-style-position', 'isolation', 'contain',
|
|
79
|
+
'object-fit', 'object-position', 'clip-path',
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
/** 唯一重置例外(`border: 0` / `border: none`);其余 border* 一律违规 */
|
|
83
|
+
const BORDER_RESET_OK = /^(0|none)$/
|
|
84
|
+
|
|
85
|
+
/** L1-6 的唯一例外:入口基线 —— 这几个选择器(及其高度链)在应用侧合法 */
|
|
86
|
+
const ENTRY_BASELINE_SELECTORS = new Set(['html', 'body', ':root', '#app'])
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 由 kit 写入、但**不带 `mh-` 前缀**的 shell 类:`createSubApp` 会无条件用 `.app-container`
|
|
90
|
+
* 包裹子应用(并写 `data-manohub-ui` 锚)。它不属于「应用自绘」,因此不参与 L1-5 命名空间判定
|
|
91
|
+
* —— 应用侧引它只能靠 `class="app-container"`,报红等于要求应用改不了的东西。
|
|
92
|
+
*/
|
|
93
|
+
const SHELL_CLASSES = new Set(['app-container'])
|
|
94
|
+
|
|
95
|
+
/** L3-3:已知的「外观来源」第三方库(黑名单,不可能穷举;白名单前缀见下) */
|
|
96
|
+
const FORBIDDEN_SOURCE_PATTERNS = [
|
|
97
|
+
/^@farris\//, /^element-plus/, /^ant-design-vue/, /^@arco-design/, /^naive-ui/, /^vant/, /^@vant\//,
|
|
98
|
+
/^vue-lucide/, /^lucide(-|$)/, /^@iconify/, /^@iconfu/, /^bootstrap/, /^bulma/, /^tailwindcss/,
|
|
99
|
+
/^@tailwindcss\//, /^@manohub\/app-/, /^@manohub\/base-ui/, /^@manohub\/components/,
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
/** L3-3:允许取外观的包与其**允许的**子路径(深引其它子路径违规) */
|
|
103
|
+
const ALLOWED_SOURCES = [
|
|
104
|
+
{ name: '@manohub/ui', subpaths: ['/styles.css'] },
|
|
105
|
+
{ name: '@manohub/icon', subpaths: ['/glyphs'] },
|
|
106
|
+
{ name: '@manohub/theme', subpaths: ['/default.css', '/farris.css'] },
|
|
107
|
+
{ name: '@manohub/kit', subpaths: ['/entry', '/CONTRACT.md'] },
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 四包自身 —— `kit lint` **面向消费方**,对本仓这四个包跑毫无意义:
|
|
112
|
+
* 契约的 L1-4 / L1-6 约束的是**应用自绘样式**(不许自绘颜色、不许用裸元素、不许碰 `.mh-*`),
|
|
113
|
+
* 而库本身就是外观的实现方 —— 在库源码上跑必然满屏假红(实测 `packages/ui/src` 1141 处)。
|
|
114
|
+
* 与其输出一堆不可执行的红,不如**按包名直接拦住**并说清该给哪个根。
|
|
115
|
+
*/
|
|
116
|
+
const LIBRARY_PACKAGES = new Set(['@manohub/kit', '@manohub/ui', '@manohub/theme', '@manohub/icon'])
|
|
117
|
+
|
|
118
|
+
/* ============================================================
|
|
119
|
+
* 二、扫描
|
|
120
|
+
* ============================================================ */
|
|
121
|
+
|
|
122
|
+
const SCAN_EXT = ['.css', '.vue', '.tsx', '.ts', '.html']
|
|
123
|
+
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.vite', 'coverage'])
|
|
124
|
+
|
|
125
|
+
/** 递归列出文件(返回**相对 root** 的路径,便于报错时读得懂) */
|
|
126
|
+
function walkFiles(root, dir = root, out = []) {
|
|
127
|
+
for (const entry of readdirSync(dir)) {
|
|
128
|
+
if (SKIP_DIRS.has(entry)) continue
|
|
129
|
+
const full = join(dir, entry)
|
|
130
|
+
const info = statSync(full)
|
|
131
|
+
if (info.isDirectory()) walkFiles(root, full, out)
|
|
132
|
+
else if (SCAN_EXT.some((ext) => entry.endsWith(ext))) out.push(full)
|
|
133
|
+
}
|
|
134
|
+
return out
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 剥 CSS 注释(注释里会举反例写法,不剥就会把说明当成实现)。
|
|
138
|
+
* **保留换行与字节偏移**:把注释内容换成空格而非删掉,这样后面算出的行号才是原文件的行号。 */
|
|
139
|
+
const stripCssComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
140
|
+
|
|
141
|
+
/** 按文件类型剥注释:`.css` 只有块注释;其余还要剥 JS 行注释(`// 别用 !important` 这类说明)。 */
|
|
142
|
+
function stripComments(text, isCss) {
|
|
143
|
+
const code = stripCssComments(text)
|
|
144
|
+
if (isCss) return code
|
|
145
|
+
return code.replace(/(?<!:)\/\/[^\n]*/g, (m) => m.replace(/[^\n]/g, ' '))
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 取 `.css` 里的 `选择器 { 声明块 }`(不支持嵌套;够用且不会误判),带起始偏移(用于行号) */
|
|
149
|
+
function cssBlocks(source) {
|
|
150
|
+
const blocks = []
|
|
151
|
+
const re = /([^{}]+)\{([^{}]*)\}/g
|
|
152
|
+
let match
|
|
153
|
+
while ((match = re.exec(source))) {
|
|
154
|
+
blocks.push({ selector: match[1].trim(), body: match[2], index: match.index })
|
|
155
|
+
}
|
|
156
|
+
return blocks
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 偏移 → 行号(1 基) */
|
|
160
|
+
const lineAt = (text, index) => text.slice(0, index).split('\n').length
|
|
161
|
+
|
|
162
|
+
/** 选择器可能写很多行(逗号列表),报错时只留第一行,别把输出淹掉 */
|
|
163
|
+
const firstLine = (text) => text.split('\n')[0].trim()
|
|
164
|
+
|
|
165
|
+
/** `{ display: 'flex', border: '0' }` / `style="display: flex"` → 属性名清单(kebab) */
|
|
166
|
+
function inlineStyleProps(text) {
|
|
167
|
+
const props = []
|
|
168
|
+
for (const block of text.match(/style\s*=\s*\{([^}]*)\}/g) ?? []) {
|
|
169
|
+
for (const pair of block.matchAll(/([A-Za-z-]+)\s*:/g)) {
|
|
170
|
+
props.push(pair[1].replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
for (const block of text.match(/style\s*=\s*"([^"]*)"/g) ?? []) {
|
|
174
|
+
for (const pair of block.matchAll(/([a-z-]+)\s*:/g)) props.push(pair[1])
|
|
175
|
+
}
|
|
176
|
+
return props
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** 从一条声明块里取 `属性: 值` 对(跳过自定义属性 `--x`) */
|
|
180
|
+
function declarations(body) {
|
|
181
|
+
const out = []
|
|
182
|
+
for (const raw of body.split(';')) {
|
|
183
|
+
const index = raw.indexOf(':')
|
|
184
|
+
if (index < 0) continue
|
|
185
|
+
const prop = raw.slice(0, index).trim().toLowerCase()
|
|
186
|
+
const value = raw.slice(index + 1).trim()
|
|
187
|
+
if (!prop || prop.startsWith('--') || prop.startsWith('@')) continue
|
|
188
|
+
out.push({ prop, value })
|
|
189
|
+
}
|
|
190
|
+
return out
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 选择器里是否出现**裸元素 / `*`**(只看第一段复合选择器;入口基线除外) */
|
|
194
|
+
function bareElementSelectors(selector) {
|
|
195
|
+
const offenders = []
|
|
196
|
+
for (const part of selector.split(',').map((s) => s.trim())) {
|
|
197
|
+
if (!part || part.startsWith('%') || part.startsWith('@')) continue
|
|
198
|
+
// 只看第一段(到第一个组合符为止):`.foo > span` 里的 span 不报 —— 应用侧的自绘样式
|
|
199
|
+
// 本就该挂在自有类名下,`span` 只是它内部的结构,误报面大于收益。
|
|
200
|
+
const first = part.split(/[\s>+~]+/)[0]
|
|
201
|
+
if (!first) continue
|
|
202
|
+
const core = first.replace(/::?[\w-]*(\([^)]*\))?/g, '')
|
|
203
|
+
if (!core) continue // 第一段整体是伪类(`:hover` / `:where(…)`)→ 不算裸元素
|
|
204
|
+
if (/[.#[]/.test(core)) continue // 有类 / id / 属性锚点 → 已限定作用域,放行
|
|
205
|
+
if (ENTRY_BASELINE_SELECTORS.has(core)) continue
|
|
206
|
+
if (/^(\*|[a-z][\w-]*)$/i.test(core)) offenders.push(first)
|
|
207
|
+
}
|
|
208
|
+
return offenders
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ============================================================
|
|
212
|
+
* 三、检查组
|
|
213
|
+
* ============================================================ */
|
|
214
|
+
|
|
215
|
+
/** 把每个文件交给回调:(相对路径, 全文, 是否 .css, 已剥注释的同长度文本) */
|
|
216
|
+
function eachFile(root, files, visit) {
|
|
217
|
+
for (const file of files) {
|
|
218
|
+
const text = readFileSync(file, 'utf8')
|
|
219
|
+
const isCss = file.endsWith('.css')
|
|
220
|
+
const rel = relative(root, file).split(sep).join('/')
|
|
221
|
+
visit(rel, text, isCss, stripComments(text, isCss))
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 取文件里所有 `选择器 { 声明块 }` —— 传进来的必须是**已剥注释**的文本(偏移才与原文一致) */
|
|
226
|
+
function styleBlocksOf(code, isCss) {
|
|
227
|
+
if (isCss) return cssBlocks(code)
|
|
228
|
+
const out = []
|
|
229
|
+
for (const tag of code.matchAll(/<style[^>]*>([\s\S]*?)<\/style>/g)) {
|
|
230
|
+
const inner = tag[1]
|
|
231
|
+
const offset = tag.index + tag[0].indexOf(inner)
|
|
232
|
+
for (const block of cssBlocks(inner)) out.push({ ...block, index: offset + block.index })
|
|
233
|
+
}
|
|
234
|
+
return out
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** `style` 组:应用侧红线条(L1-3 / L1-6 / L1-7) */
|
|
238
|
+
function checkStyle(root, files) {
|
|
239
|
+
const issues = []
|
|
240
|
+
eachFile(root, files, (rel, text, isCss, code) => {
|
|
241
|
+
const push = (rule, detail, line) => issues.push({ rule, file: rel, detail, line })
|
|
242
|
+
|
|
243
|
+
// 全文件级三条:`!important` 与 tailwind 在任何地方都是红线;`rem` 是几何口径
|
|
244
|
+
for (const m of code.matchAll(/!important/g)) {
|
|
245
|
+
push('L1-6', '出现 !important(契约 §11 红线,登记也不放行)', lineAt(text, m.index))
|
|
246
|
+
}
|
|
247
|
+
for (const m of code.matchAll(/(?:^|[\s:(,])(-?\d*\.?\d+)rem\b/g)) {
|
|
248
|
+
push('L1-3', `出现 rem(几何取 px 令牌,不跟根字号走):${m[1]}rem`, lineAt(text, m.index))
|
|
249
|
+
}
|
|
250
|
+
for (const m of code.matchAll(/tailwindcss|@tailwindcss\/|\b@theme\b/g)) {
|
|
251
|
+
push('L1-7', '出现 tailwind 相关引用', lineAt(text, m.index))
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
for (const { selector, index } of styleBlocksOf(code, isCss)) {
|
|
255
|
+
const line = lineAt(text, index)
|
|
256
|
+
if (/\.mh-[\w-]/.test(selector)) push('L1-6', `选择器 ${firstLine(selector)} 覆写了库的类`, line)
|
|
257
|
+
for (const bare of bareElementSelectors(selector)) {
|
|
258
|
+
push('L1-6', `选择器 ${bare} 用了裸元素 / *(入口基线 html/body/#app/:root 除外)`, line)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (isCss) return
|
|
263
|
+
// 非 CSS:`class=` 的字面值里出现库的类名(覆写库外观的另一条路)
|
|
264
|
+
for (const cls of code.matchAll(/class(?:Name)?\s*=\s*["'`]([^"'`]*)["'`]/g)) {
|
|
265
|
+
for (const token of cls[1].split(/\s+/)) {
|
|
266
|
+
if (token.startsWith('mh-')) push('L1-6', `class 里出现库的类名 ${token}`, lineAt(text, cls.index))
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
return issues
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** `property` 组:L1-4 属性闭集 —— **只在应用根跑**(库是实现方,跑它必然满屏假红) */
|
|
274
|
+
function checkProperty(root, files) {
|
|
275
|
+
const issues = []
|
|
276
|
+
eachFile(root, files, (rel, text, isCss, code) => {
|
|
277
|
+
const badBorder = (prop, value) => {
|
|
278
|
+
if (prop === 'border') return !BORDER_RESET_OK.test(value)
|
|
279
|
+
return ['width', 'style', 'color'].includes(prop.split('-')[1])
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
for (const { selector, body, index } of styleBlocksOf(code, isCss)) {
|
|
283
|
+
const line = lineAt(text, index)
|
|
284
|
+
const where = `选择器 ${firstLine(selector)}`
|
|
285
|
+
for (const { prop, value } of declarations(body)) {
|
|
286
|
+
if (prop === 'border' || prop.startsWith('border-')) {
|
|
287
|
+
if (badBorder(prop, value)) {
|
|
288
|
+
issues.push({ rule: 'L1-4', file: rel, detail: `${where} 的 ${prop}(只允许 border: 0 / none)`, line })
|
|
289
|
+
}
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
if (!PROPERTY_WHITELIST.has(prop)) {
|
|
293
|
+
issues.push({ rule: 'L1-4', file: rel, detail: `${where} 用了非白名单属性 ${prop}`, line })
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (isCss) return
|
|
299
|
+
for (const prop of inlineStyleProps(code)) {
|
|
300
|
+
if (prop === 'border' || prop.startsWith('border-')) continue
|
|
301
|
+
if (!PROPERTY_WHITELIST.has(prop)) {
|
|
302
|
+
issues.push({ rule: 'L1-4', file: rel, detail: `内联 style 用了非白名单属性 ${prop}` })
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
})
|
|
306
|
+
return issues
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function checkSource(root, files) {
|
|
310
|
+
const issues = []
|
|
311
|
+
eachFile(root, files, (rel, text) => {
|
|
312
|
+
for (const match of text.matchAll(/(?:from|import|require\()\s*['"]([^'"]+)['"]/g)) {
|
|
313
|
+
const spec = match[1]
|
|
314
|
+
if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('node:')) continue
|
|
315
|
+
|
|
316
|
+
const forbidden = FORBIDDEN_SOURCE_PATTERNS.find((re) => re.test(spec))
|
|
317
|
+
if (forbidden) {
|
|
318
|
+
issues.push({ rule: 'L3-3', file: rel, detail: `${spec} 是白名单之外的外观来源` })
|
|
319
|
+
continue
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const allowed = ALLOWED_SOURCES.find((item) => spec === item.name || spec.startsWith(`${item.name}/`))
|
|
323
|
+
if (!allowed) continue
|
|
324
|
+
const sub = spec.slice(allowed.name.length)
|
|
325
|
+
if (sub && !allowed.subpaths.some((p) => sub === p || sub.startsWith(`${p}/`))) {
|
|
326
|
+
issues.push({ rule: 'L3-3', file: rel, detail: `${spec} 深引了未开放的子路径` })
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
})
|
|
330
|
+
return issues
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** `namespace` 组:业务类名必须以本仓命名空间开头(L1-5);库的 `.mh-*` 与 shell 类天然放行 */
|
|
334
|
+
function checkNamespace(root, files, namespaces) {
|
|
335
|
+
const issues = []
|
|
336
|
+
eachFile(root, files, (rel, text, isCss, code) => {
|
|
337
|
+
const known = (selector, line) => {
|
|
338
|
+
for (const part of selector.split(',').map((s) => s.trim())) {
|
|
339
|
+
for (const cls of part.matchAll(/\.([A-Za-z][\w-]*)/g)) {
|
|
340
|
+
const name = cls[1]
|
|
341
|
+
if (name.startsWith('mh-') || SHELL_CLASSES.has(name)) continue
|
|
342
|
+
if (!namespaces.some((ns) => name.startsWith(`${ns}-`))) {
|
|
343
|
+
issues.push({ rule: 'L1-5', file: rel, line, detail: `类名 ${name} 不以本仓命名空间开头(${namespaces.join(' / ')})` })
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const { selector, index } of styleBlocksOf(code, isCss)) known(selector, lineAt(text, index))
|
|
350
|
+
if (isCss) return
|
|
351
|
+
for (const cls of code.matchAll(/class(?:Name)?\s*=\s*["'`]([^"'`]*)["'`]/g)) {
|
|
352
|
+
const line = lineAt(text, cls.index)
|
|
353
|
+
for (const token of cls[1].split(/\s+/)) if (token) known(`.${token}`, line)
|
|
354
|
+
}
|
|
355
|
+
})
|
|
356
|
+
return issues
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/* ============================================================
|
|
360
|
+
* 四、CLI
|
|
361
|
+
* ============================================================ */
|
|
362
|
+
|
|
363
|
+
/** 全部可跑的组(应用侧全跑;`property` 见头注释为何不能对着库跑) */
|
|
364
|
+
export const GROUPS = { style: checkStyle, source: checkSource, namespace: checkNamespace, property: checkProperty }
|
|
365
|
+
|
|
366
|
+
/** 缺省跑的组 —— 四组全跑(根已被限定为消费方) */
|
|
367
|
+
export const DEFAULT_GROUPS = Object.keys(GROUPS)
|
|
368
|
+
|
|
369
|
+
export function parseArgs(argv = []) {
|
|
370
|
+
const options = { root: process.cwd(), namespaces: [], groups: [], json: false, help: false }
|
|
371
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
372
|
+
const arg = argv[i]
|
|
373
|
+
if (arg === '--help' || arg === '-h') options.help = true
|
|
374
|
+
else if (arg === '--json') options.json = true
|
|
375
|
+
else if (arg === '--root') options.root = resolve(argv[++i] ?? '.')
|
|
376
|
+
else if (arg === '--namespace') options.namespaces.push(argv[++i] ?? '')
|
|
377
|
+
else if (arg === '--group') options.groups.push(argv[++i] ?? '')
|
|
378
|
+
}
|
|
379
|
+
return options
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export const USAGE = [
|
|
383
|
+
'kit lint —— 接入契约的机械护栏',
|
|
384
|
+
'',
|
|
385
|
+
'用法:kit lint [--root <dir>] [--namespace <前缀>]… [--group <组>]… [--json]',
|
|
386
|
+
'',
|
|
387
|
+
' --root 要检查的目录(缺省 cwd;应用侧给 apps/<app>)',
|
|
388
|
+
' --namespace 本仓业务类名前缀,可多次(如 topic / role;见 docs/kit-namespaces.md)',
|
|
389
|
+
' 不给则跳过 L1-5 检查(不瞎猜前缀)',
|
|
390
|
+
' --group 只跑指定组,可多次;缺省四组全跑',
|
|
391
|
+
'',
|
|
392
|
+
'组:',
|
|
393
|
+
' style 红线条(!important / .mh-* 覆写 / 裸元素与 * / rem / tailwind)',
|
|
394
|
+
' source 外观来源白名单(第三方组件库 / CSS 框架 / 图标库 / 深引子路径 / 旧包名)',
|
|
395
|
+
' namespace 业务类名前缀(L1-5,需 --namespace)',
|
|
396
|
+
' property L1-4 属性闭集(视觉属性越界)',
|
|
397
|
+
'',
|
|
398
|
+
'退出码:有违规 1 / 无 0。',
|
|
399
|
+
].join('\n')
|
|
400
|
+
|
|
401
|
+
/** 读目录自身的 `package.json` 名字(读不到返回 null —— 任意目录也能扫) */
|
|
402
|
+
function packageNameOf(root) {
|
|
403
|
+
try {
|
|
404
|
+
return JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).name ?? null
|
|
405
|
+
} catch {
|
|
406
|
+
return null
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function runLint(options) {
|
|
411
|
+
const root = options.root
|
|
412
|
+
if (!existsSync(root)) return { root, issues: [], error: `目录不存在:${root}` }
|
|
413
|
+
|
|
414
|
+
const pkgName = packageNameOf(root)
|
|
415
|
+
if (pkgName && LIBRARY_PACKAGES.has(pkgName)) {
|
|
416
|
+
return {
|
|
417
|
+
root,
|
|
418
|
+
issues: [],
|
|
419
|
+
error:
|
|
420
|
+
`${pkgName} 是四包之一(库本体)。kit lint 检查的是**消费方自绘的东西**,` +
|
|
421
|
+
'对它跑只会满屏假红(L1-4 不许自绘颜色、L1-6 不许碰 .mh-* —— 库正是这两条的实现方)。' +
|
|
422
|
+
'请指向应用根,如 --root apps/skill-topic。',
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const wantGroups = options.groups.length ? options.groups : DEFAULT_GROUPS
|
|
427
|
+
const unknown = wantGroups.filter((g) => !GROUPS[g])
|
|
428
|
+
if (unknown.length) return { root, issues: [], error: `未知的检查组:${unknown.join(', ')}(可用:${Object.keys(GROUPS).join(' / ')})` }
|
|
429
|
+
|
|
430
|
+
const files = walkFiles(root)
|
|
431
|
+
const seen = new Set()
|
|
432
|
+
const issues = []
|
|
433
|
+
const collect = (list) => {
|
|
434
|
+
for (const issue of list) {
|
|
435
|
+
// 去重:同一处(规则 + 文件 + 行 + 说明)只报一次。
|
|
436
|
+
// `.app-container` 在 app.css 里出现 13 次就是同一类事的 13 份拷贝,重复输出只会稀释信号。
|
|
437
|
+
const key = `${issue.rule}|${issue.file}|${issue.line ?? ''}|${issue.detail}`
|
|
438
|
+
if (seen.has(key)) continue
|
|
439
|
+
seen.add(key)
|
|
440
|
+
issues.push(issue)
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
for (const group of wantGroups) {
|
|
445
|
+
if (group === 'namespace') {
|
|
446
|
+
if (!options.namespaces.length) continue // 没给前缀就跳过(不瞎猜)
|
|
447
|
+
collect(checkNamespace(root, files, options.namespaces))
|
|
448
|
+
} else {
|
|
449
|
+
collect(GROUPS[group](root, files))
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const skipped = wantGroups.includes('namespace') && !options.namespaces.length ? ['namespace'] : []
|
|
453
|
+
return { root, issues, scanned: files.length, groups: wantGroups, skipped }
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** 打印结果;返回退出码 */
|
|
457
|
+
export function report(result, options) {
|
|
458
|
+
if (options.json) {
|
|
459
|
+
console.log(JSON.stringify(result, null, 2))
|
|
460
|
+
return result.error || result.issues.length ? 1 : 0
|
|
461
|
+
}
|
|
462
|
+
if (result.error) {
|
|
463
|
+
console.error(`[kit lint] ${result.error}`)
|
|
464
|
+
return 1
|
|
465
|
+
}
|
|
466
|
+
const hint = result.skipped?.includes('namespace') ? ';L1-5 已跳过,加 --namespace <前缀> 才查' : ''
|
|
467
|
+
if (!result.issues.length) {
|
|
468
|
+
console.log(`[kit lint] 通过(扫了 ${result.scanned} 个文件,根:${result.root}${hint})`)
|
|
469
|
+
return 0
|
|
470
|
+
}
|
|
471
|
+
const byRule = new Map()
|
|
472
|
+
for (const issue of result.issues) {
|
|
473
|
+
const list = byRule.get(issue.rule) ?? []
|
|
474
|
+
list.push(issue)
|
|
475
|
+
byRule.set(issue.rule, list)
|
|
476
|
+
}
|
|
477
|
+
for (const [rule, list] of byRule) {
|
|
478
|
+
console.error(`\n[${rule}] ${list.length} 处`)
|
|
479
|
+
for (const issue of list) {
|
|
480
|
+
const where = issue.line ? `${issue.file}:${issue.line}` : issue.file
|
|
481
|
+
console.error(` ${where} ${issue.detail}`)
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
console.error(`\n[kit lint] 共 ${result.issues.length} 处违规(${result.root}${hint})`)
|
|
485
|
+
console.error('修不了时按契约 §11 登记;登记不放行的几类(自绘页头 / 原生控件承载外观 / !important …)请改实现。')
|
|
486
|
+
return 1
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function main() {
|
|
490
|
+
const options = parseArgs(process.argv.slice(2))
|
|
491
|
+
if (options.help) {
|
|
492
|
+
console.log(USAGE)
|
|
493
|
+
return
|
|
494
|
+
}
|
|
495
|
+
process.exitCode = report(runLint(options), options)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (isDirectRun(process.argv[1], import.meta.url)) {
|
|
499
|
+
main()
|
|
500
|
+
}
|