@agile-team/wl-skills-ui 1.8.0 → 1.8.2
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/CHANGELOG.md +22 -0
- package/README.md +8 -8
- package/docs/governance-long-term.md +25 -12
- package/mcp/server.js +44 -0
- package/package.json +3 -1
- package/scanner/__tests__/fixtures/r001-fail.vue +7 -0
- package/scanner/__tests__/fixtures/r001-pass.vue +7 -0
- package/scanner/__tests__/fixtures/r002-fail.vue +5 -0
- package/scanner/__tests__/fixtures/r002-pass.vue +5 -0
- package/scanner/__tests__/fixtures/r006-fail.vue +10 -0
- package/scanner/__tests__/fixtures/r006-pass.vue +10 -0
- package/scanner/__tests__/fixtures/r011-fail.vue +11 -0
- package/scanner/__tests__/fixtures/r011-pass.vue +14 -0
- package/scanner/__tests__/fixtures/r016-fail.vue +9 -0
- package/scanner/__tests__/fixtures/r016-pass.vue +9 -0
- package/scanner/__tests__/fixtures/r021-fail.vue +3 -0
- package/scanner/__tests__/fixtures/r021-pass.vue +3 -0
- package/scanner/__tests__/scan.test.mjs +145 -0
- package/scanner/drift.mjs +179 -0
- package/scanner/index.mjs +50 -1
- package/scripts/check-scss.mjs +99 -0
- package/styles/vendors/_base-components.scss +17 -4
- package/styles/vendors/_base-query-toolbar.scss +10 -1
- package/styles/vendors/_jh-ui.scss +3 -6
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to **@agile-team/wl-skills-ui** will be documented in this f
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [1.8.2] - 2026-05-13
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **搜索区字号不一致**:`_base-query-toolbar.scss` 统一 label / input / select / placeholder 为 **12px**,消除 13px→12px 混杂导致的视觉跳动。
|
|
12
|
+
- **必填星号重复 `* *`**:`_base-components.scss` 必填星号改为精确模式——默认只显示 `::before`,`asterisk-right` 仅显示 `::after`,避免 `display: inline-block !important` 同时作用于两个伪元素。`_jh-ui.scss` 同步精简为只声明颜色不控制 `display`。
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- **scanner fixture 测试集**:`scanner/__tests__/` 覆盖 R001/R002/R006/R011/R016/R021 + drift 模块共 16 条自动化测试,`npm test` 一键跑。
|
|
17
|
+
- **SCSS 链路检查**:`scripts/check-scss.mjs` 递归验证 `styles/index.scss` 和 `presets/skin.scss` 的 `@forward/@use` 链路完整性。
|
|
18
|
+
- **scan --baseline 一步到位**:`wl-scan scan --target src --baseline .wl-baseline.json` 扫描后自动对比基线输出漂移报告,`--fail-on-error` 同时拦截新增违规。
|
|
19
|
+
- `package.json` 新增 `test` 和 `check:scss` 脚本。
|
|
20
|
+
|
|
21
|
+
## [1.8.1] - 2026-05-13
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- 新增 **`scanner/drift.mjs`** 漂移检测模块:对比基线与当前扫描 JSON,输出 gained / fixed / regressed 结构化报告。
|
|
26
|
+
- 新增 CLI `wl-scan drift --baseline <f> --current <f>` 子命令,支持 `--output json` 和 `--fail-on-error` 门槛。
|
|
27
|
+
- 新增 MCP 工具 **`wl_ui_drift`**:AI 可直接传入两份扫描 JSON 拿漂移报告。
|
|
28
|
+
|
|
7
29
|
## [1.8.0] - 2026-05-12
|
|
8
30
|
|
|
9
31
|
### Added
|
package/README.md
CHANGED
|
@@ -213,14 +213,14 @@ yarn add @agile-team/wl-skills-ui
|
|
|
213
213
|
|
|
214
214
|
## 版本亮点
|
|
215
215
|
|
|
216
|
-
当前 v1.8.
|
|
217
|
-
|
|
218
|
-
-
|
|
219
|
-
- 新增
|
|
220
|
-
-
|
|
221
|
-
-
|
|
222
|
-
-
|
|
223
|
-
-
|
|
216
|
+
当前 v1.8.2:
|
|
217
|
+
|
|
218
|
+
- **修复**搜索区字号 13px/12px 混杂 → 统一 12px;必填星号 `* *` 重复 → 精确单颗控制
|
|
219
|
+
- 新增 **scanner fixture 测试集**(16 条自动化测试,`npm test`)和 **SCSS 链路检查**(`npm run check:scss`)
|
|
220
|
+
- `scan --baseline` 一步到位:扫描后自动对比基线输出漂移报告,CI 增量门槛
|
|
221
|
+
- **漂移检测** `scanner/drift.mjs` + CLI `wl-scan drift` + MCP `wl_ui_drift`
|
|
222
|
+
- **R-rule 单一事实源** `standards/rules.json`(29 条),五向一致性守卫
|
|
223
|
+
- **长效治理方案** `docs/governance-long-term.md`(基线 / 豁免 / 漂移 / 版本钉死 / AI 守护)
|
|
224
224
|
|
|
225
225
|
历史亮点(v1.7.1):
|
|
226
226
|
|
|
@@ -78,24 +78,37 @@ npx wl-ui scan --target src --baseline .wl-baseline.json --fail-on=new-issues
|
|
|
78
78
|
**目标**:让"逐渐变烂"可见,每次升级都能量化。
|
|
79
79
|
|
|
80
80
|
```bash
|
|
81
|
-
#
|
|
82
|
-
npx wl-ui
|
|
83
|
-
|
|
81
|
+
# 方式一:scan 一步到位(v1.8.2+)
|
|
82
|
+
npx wl-ui scan --target src --baseline .wl-baseline.json --fail-on-error
|
|
83
|
+
|
|
84
|
+
# 方式二:独立 drift 子命令
|
|
85
|
+
npx wl-ui scan --target src --output json --outFile .wl-current.json
|
|
86
|
+
npx wl-ui drift --baseline .wl-baseline.json --current .wl-current.json
|
|
84
87
|
```
|
|
85
88
|
|
|
86
|
-
|
|
89
|
+
输出示例:
|
|
87
90
|
|
|
88
91
|
```
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
92
|
+
╭──────────────────────────────────────╮
|
|
93
|
+
│ wl-skills-ui 漂移报告 │
|
|
94
|
+
╰──────────────────────────────────────╯
|
|
95
|
+
|
|
96
|
+
基线违规: 120 → 当前违规: 87 (-33)
|
|
97
|
+
|
|
98
|
+
🔴 gained +3 新增违规
|
|
99
|
+
🟢 fixed -36 消化历史
|
|
100
|
+
|
|
101
|
+
新增 Top 规则:
|
|
102
|
+
R017 +2
|
|
103
|
+
R001 +1
|
|
104
|
+
|
|
105
|
+
消化 Top 规则:
|
|
106
|
+
R009 -15
|
|
107
|
+
R004 -12
|
|
108
|
+
R016 -9
|
|
96
109
|
```
|
|
97
110
|
|
|
98
|
-
>
|
|
111
|
+
> 实现入口:`scanner/drift.mjs` 提供 `drift()` / `driftFromFiles()` / `formatDriftText()` / `formatDriftJson()` 四个 API。MCP 工具 `wl_ui_drift` 可供 AI 直接调用。
|
|
99
112
|
|
|
100
113
|
---
|
|
101
114
|
|
package/mcp/server.js
CHANGED
|
@@ -133,6 +133,25 @@ const TOOLS = [
|
|
|
133
133
|
required: ["id"],
|
|
134
134
|
},
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
name: "wl_ui_drift",
|
|
138
|
+
description:
|
|
139
|
+
"对比基线与当前扫描 JSON,输出漂移报告(gained / fixed / regressed)。用于 PR 级增量门槛。",
|
|
140
|
+
inputSchema: {
|
|
141
|
+
type: "object",
|
|
142
|
+
properties: {
|
|
143
|
+
baselineJson: {
|
|
144
|
+
type: "string",
|
|
145
|
+
description: "基线扫描 JSON 字符串(.wl-baseline.json 的内容)",
|
|
146
|
+
},
|
|
147
|
+
currentJson: {
|
|
148
|
+
type: "string",
|
|
149
|
+
description: "当前扫描 JSON 字符串",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
required: ["baselineJson", "currentJson"],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
136
155
|
{
|
|
137
156
|
name: "wl_ui_recommend_flow",
|
|
138
157
|
description:
|
|
@@ -495,6 +514,31 @@ async function dispatchTool(id, name, args) {
|
|
|
495
514
|
});
|
|
496
515
|
return;
|
|
497
516
|
}
|
|
517
|
+
if (name === "wl_ui_drift") {
|
|
518
|
+
const { drift, formatDriftJson } = await import("../scanner/drift.mjs");
|
|
519
|
+
try {
|
|
520
|
+
const baseline = JSON.parse(args.baselineJson);
|
|
521
|
+
const current = JSON.parse(args.currentJson);
|
|
522
|
+
const result = drift(baseline, current);
|
|
523
|
+
sendResult(id, {
|
|
524
|
+
content: [{ type: "text", text: formatDriftJson(result) }],
|
|
525
|
+
});
|
|
526
|
+
} catch (e) {
|
|
527
|
+
sendResult(id, {
|
|
528
|
+
content: [
|
|
529
|
+
{
|
|
530
|
+
type: "text",
|
|
531
|
+
text: JSON.stringify(
|
|
532
|
+
{ ok: false, reason: String(e.message) },
|
|
533
|
+
null,
|
|
534
|
+
2,
|
|
535
|
+
),
|
|
536
|
+
},
|
|
537
|
+
],
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
498
542
|
if (name === "wl_ui_recommend_flow") {
|
|
499
543
|
sendResult(id, {
|
|
500
544
|
content: [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agile-team/wl-skills-ui",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "企业级 UI 风格对齐框架 — Vue + Element Plus 项目通用化妆/原生双模式(tokens / element / vendors / layouts / runtime / scanner / fixer / skills)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./es/index.js",
|
|
@@ -41,6 +41,8 @@
|
|
|
41
41
|
"build": "tsup && npm run sync:tokens",
|
|
42
42
|
"prepare": "npm run build",
|
|
43
43
|
"sync:tokens": "node -e \"import('fs').then(fs=>fs.copyFileSync('design/tokens/base.css','dist/tokens.css'))\"",
|
|
44
|
+
"test": "node --test scanner/__tests__/scan.test.mjs",
|
|
45
|
+
"check:scss": "node scripts/check-scss.mjs",
|
|
44
46
|
"docs:check": "node scripts/check-docs.mjs",
|
|
45
47
|
"scan": "node scanner/index.mjs scan",
|
|
46
48
|
"check": "node scanner/index.mjs check",
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-table :data="list" empty-text="暂无数据">
|
|
3
|
+
<el-table-column type="selection" align="center" header-align="center" width="55" />
|
|
4
|
+
<el-table-column prop="name" label="名称" align="center" />
|
|
5
|
+
<el-table-column prop="status" label="状态" align="center" />
|
|
6
|
+
</el-table>
|
|
7
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-dialog v-model="visible" title="列表">
|
|
3
|
+
<el-table :data="list">
|
|
4
|
+
<el-table-column prop="name" label="名称" align="center" />
|
|
5
|
+
</el-table>
|
|
6
|
+
<template #footer>
|
|
7
|
+
<el-pagination :total="total" />
|
|
8
|
+
<el-button @click="visible = false">取消</el-button>
|
|
9
|
+
</template>
|
|
10
|
+
</el-dialog>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<el-dialog v-model="visible" title="列表">
|
|
3
|
+
<el-table :data="list">
|
|
4
|
+
<el-table-column prop="name" label="名称" align="center" />
|
|
5
|
+
</el-table>
|
|
6
|
+
<div class="popup-pagination">
|
|
7
|
+
<el-pagination :total="total" />
|
|
8
|
+
</div>
|
|
9
|
+
<template #footer>
|
|
10
|
+
<el-button @click="visible = false">取消</el-button>
|
|
11
|
+
<el-button type="primary" @click="save">确定</el-button>
|
|
12
|
+
</template>
|
|
13
|
+
</el-dialog>
|
|
14
|
+
</template>
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scanner/__tests__/scan.test.mjs — scanner 规则 fixture 测试
|
|
3
|
+
*
|
|
4
|
+
* 用法:node --test scanner/__tests__/scan.test.mjs
|
|
5
|
+
*
|
|
6
|
+
* 约定:fixtures/<ruleId>-pass.vue → 0 violations of that rule
|
|
7
|
+
* fixtures/<ruleId>-fail.vue → ≥1 violations of that rule
|
|
8
|
+
*
|
|
9
|
+
* 每条测试只断言对应 rule 的命中数,不受其他规则干扰。
|
|
10
|
+
*/
|
|
11
|
+
import { describe, it } from "node:test";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
14
|
+
import { join, dirname } from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
import { getRules } from "../rules/index.mjs";
|
|
17
|
+
|
|
18
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const FIXTURES = join(__dirname, "fixtures");
|
|
20
|
+
const rules = getRules();
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 简易单文件扫描:跑所有规则,返回 issues 数组
|
|
24
|
+
*/
|
|
25
|
+
function scanFixture(filename) {
|
|
26
|
+
const content = readFileSync(join(FIXTURES, filename), "utf8");
|
|
27
|
+
const issues = [];
|
|
28
|
+
|
|
29
|
+
// extract template
|
|
30
|
+
const tmplMatch = content.match(/(<template[^>]*>)([\s\S]*?)(<\/template>)/);
|
|
31
|
+
const template = tmplMatch ? tmplMatch[0] : content;
|
|
32
|
+
const tmplOffset = tmplMatch
|
|
33
|
+
? content.slice(0, content.indexOf(tmplMatch[0])).split("\n").length - 1
|
|
34
|
+
: 0;
|
|
35
|
+
|
|
36
|
+
// extract style
|
|
37
|
+
const styleMatch = content.match(/<style[^>]*>([\s\S]*?)<\/style>/);
|
|
38
|
+
const styleBlock = styleMatch ? styleMatch[1] : null;
|
|
39
|
+
const styleOffset = styleMatch
|
|
40
|
+
? content.slice(0, content.indexOf(styleMatch[0])).split("\n").length
|
|
41
|
+
: 0;
|
|
42
|
+
|
|
43
|
+
// extract script
|
|
44
|
+
const scriptMatch = content.match(/<script[^>]*>([\s\S]*?)<\/script>/);
|
|
45
|
+
const scriptBlock = scriptMatch ? scriptMatch[1] : null;
|
|
46
|
+
const scriptOffset = scriptMatch
|
|
47
|
+
? content.slice(0, content.indexOf(scriptMatch[0])).split("\n").length
|
|
48
|
+
: 0;
|
|
49
|
+
|
|
50
|
+
for (const rule of rules) {
|
|
51
|
+
if (typeof rule.check === "function") {
|
|
52
|
+
issues.push(...rule.check(template, filename, tmplOffset));
|
|
53
|
+
}
|
|
54
|
+
if (styleBlock && typeof rule.checkStyle === "function") {
|
|
55
|
+
issues.push(...rule.checkStyle(styleBlock, filename, styleOffset));
|
|
56
|
+
}
|
|
57
|
+
if (scriptBlock && typeof rule.checkScript === "function") {
|
|
58
|
+
issues.push(...rule.checkScript(scriptBlock, filename, scriptOffset));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return issues;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── 自动发现 fixtures 并生成测试 ────────────────────────────────────────────
|
|
65
|
+
const fixtureFiles = readdirSync(FIXTURES).filter((f) => f.endsWith(".vue"));
|
|
66
|
+
const grouped = new Map(); // ruleId → { pass: [], fail: [] }
|
|
67
|
+
|
|
68
|
+
for (const f of fixtureFiles) {
|
|
69
|
+
const m = f.match(/^(r\d+)-(pass|fail)\.vue$/i);
|
|
70
|
+
if (!m) continue;
|
|
71
|
+
const ruleId = m[1].toUpperCase();
|
|
72
|
+
const kind = m[2]; // pass | fail
|
|
73
|
+
if (!grouped.has(ruleId)) grouped.set(ruleId, { pass: [], fail: [] });
|
|
74
|
+
grouped.get(ruleId)[kind].push(f);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [ruleId, { pass, fail }] of grouped) {
|
|
78
|
+
describe(ruleId, () => {
|
|
79
|
+
for (const f of pass) {
|
|
80
|
+
it(`${f} → 0 violations`, () => {
|
|
81
|
+
const issues = scanFixture(f).filter((i) => i.rule === ruleId);
|
|
82
|
+
assert.equal(
|
|
83
|
+
issues.length,
|
|
84
|
+
0,
|
|
85
|
+
`Expected 0 ${ruleId} issues but got ${issues.length}: ${JSON.stringify(issues, null, 2)}`,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
for (const f of fail) {
|
|
90
|
+
it(`${f} → ≥1 violations`, () => {
|
|
91
|
+
const issues = scanFixture(f).filter((i) => i.rule === ruleId);
|
|
92
|
+
assert.ok(issues.length > 0, `Expected ≥1 ${ruleId} issues but got 0`);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── drift 模块测试 ──────────────────────────────────────────────────────────
|
|
99
|
+
const { drift } = await import("../drift.mjs");
|
|
100
|
+
|
|
101
|
+
describe("drift", () => {
|
|
102
|
+
it("identical → 0 gained, 0 fixed", () => {
|
|
103
|
+
const data = { issues: [{ file: "a.vue", rule: "R001" }] };
|
|
104
|
+
const r = drift(data, data);
|
|
105
|
+
assert.equal(r.gained.count, 0);
|
|
106
|
+
assert.equal(r.fixed.count, 0);
|
|
107
|
+
assert.equal(r.netDelta, 0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("new issues → gained > 0", () => {
|
|
111
|
+
const base = { issues: [] };
|
|
112
|
+
const cur = { issues: [{ file: "a.vue", rule: "R001" }] };
|
|
113
|
+
const r = drift(base, cur);
|
|
114
|
+
assert.equal(r.gained.count, 1);
|
|
115
|
+
assert.equal(r.fixed.count, 0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("removed issues → fixed > 0", () => {
|
|
119
|
+
const base = { issues: [{ file: "a.vue", rule: "R001" }] };
|
|
120
|
+
const cur = { issues: [] };
|
|
121
|
+
const r = drift(base, cur);
|
|
122
|
+
assert.equal(r.gained.count, 0);
|
|
123
|
+
assert.equal(r.fixed.count, 1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("mixed → both gained and fixed", () => {
|
|
127
|
+
const base = {
|
|
128
|
+
issues: [
|
|
129
|
+
{ file: "a.vue", rule: "R001" },
|
|
130
|
+
{ file: "a.vue", rule: "R001" },
|
|
131
|
+
{ file: "b.vue", rule: "R016" },
|
|
132
|
+
],
|
|
133
|
+
};
|
|
134
|
+
const cur = {
|
|
135
|
+
issues: [
|
|
136
|
+
{ file: "a.vue", rule: "R001" },
|
|
137
|
+
{ file: "c.vue", rule: "R017" },
|
|
138
|
+
{ file: "c.vue", rule: "R017" },
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const r = drift(base, cur);
|
|
142
|
+
assert.equal(r.gained.count, 2); // c.vue R017 ×2
|
|
143
|
+
assert.equal(r.fixed.count, 2); // a.vue R001 ×1 + b.vue R016 ×1
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scanner/drift.mjs — 漂移检测
|
|
3
|
+
*
|
|
4
|
+
* 对比 baseline(基线 JSON)与 current(当前扫描 JSON),输出结构化漂移报告。
|
|
5
|
+
* 指纹策略:file + rule(行号不稳定,不参与指纹)。同一 file+rule 多条则按数量 diff。
|
|
6
|
+
*
|
|
7
|
+
* 用法:
|
|
8
|
+
* import { drift, formatDriftText } from '@agile-team/wl-skills-ui/scanner/drift.mjs'
|
|
9
|
+
* const result = drift(baselineJson, currentJson)
|
|
10
|
+
* console.log(formatDriftText(result))
|
|
11
|
+
*
|
|
12
|
+
* CLI(由 scanner/index.mjs 调用):
|
|
13
|
+
* npx wl-ui drift --baseline .wl-baseline.json --current .wl-current.json
|
|
14
|
+
*
|
|
15
|
+
* 事实源:standards/rules.json
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
18
|
+
|
|
19
|
+
// ── 指纹 ────────────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function fingerprint(issue) {
|
|
22
|
+
return `${issue.file}\x00${issue.rule}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildCountMap(issues) {
|
|
26
|
+
const map = new Map();
|
|
27
|
+
for (const iss of issues) {
|
|
28
|
+
const key = fingerprint(iss);
|
|
29
|
+
map.set(key, (map.get(key) || 0) + 1);
|
|
30
|
+
}
|
|
31
|
+
return map;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── 核心 diff ───────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} baseline JSON 扫描结果(含 issues 数组)
|
|
38
|
+
* @param {object} current JSON 扫描结果(含 issues 数组)
|
|
39
|
+
* @returns {DriftResult}
|
|
40
|
+
*/
|
|
41
|
+
export function drift(baseline, current) {
|
|
42
|
+
const baseIssues = baseline.issues || [];
|
|
43
|
+
const curIssues = current.issues || [];
|
|
44
|
+
const baseMap = buildCountMap(baseIssues);
|
|
45
|
+
const curMap = buildCountMap(curIssues);
|
|
46
|
+
|
|
47
|
+
const allKeys = new Set([...baseMap.keys(), ...curMap.keys()]);
|
|
48
|
+
|
|
49
|
+
const gained = []; // 新增(当前有、基线无 或 当前 > 基线)
|
|
50
|
+
const fixed = []; // 消化(基线有、当前无 或 当前 < 基线)
|
|
51
|
+
const regressed = []; // 回归预留(后续可扩展 fix-then-break 检测)
|
|
52
|
+
|
|
53
|
+
for (const key of allKeys) {
|
|
54
|
+
const [file, rule] = key.split("\x00");
|
|
55
|
+
const baseCount = baseMap.get(key) || 0;
|
|
56
|
+
const curCount = curMap.get(key) || 0;
|
|
57
|
+
const delta = curCount - baseCount;
|
|
58
|
+
if (delta > 0) {
|
|
59
|
+
gained.push({ file, rule, delta, baseCount, curCount });
|
|
60
|
+
} else if (delta < 0) {
|
|
61
|
+
fixed.push({ file, rule, delta, baseCount, curCount });
|
|
62
|
+
}
|
|
63
|
+
// delta === 0 → 无变化,跳过
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 按规则 & 目录 聚合
|
|
67
|
+
const byRule = aggregate(gained, "rule");
|
|
68
|
+
const byDir = aggregateDir(gained);
|
|
69
|
+
const fixedByRule = aggregate(fixed, "rule");
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
baselineTotal: baseIssues.length,
|
|
73
|
+
currentTotal: curIssues.length,
|
|
74
|
+
netDelta: curIssues.length - baseIssues.length,
|
|
75
|
+
gained: { count: sumDelta(gained), items: gained },
|
|
76
|
+
fixed: { count: Math.abs(sumDelta(fixed)), items: fixed },
|
|
77
|
+
regressed: { count: 0, items: regressed },
|
|
78
|
+
topGainedByRule: sortDesc(byRule).slice(0, 5),
|
|
79
|
+
topGainedByDir: sortDesc(byDir).slice(0, 5),
|
|
80
|
+
topFixedByRule: sortDesc(fixedByRule).slice(0, 5),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sumDelta(arr) {
|
|
85
|
+
return arr.reduce((s, i) => s + Math.abs(i.delta), 0);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function aggregate(items, field) {
|
|
89
|
+
const map = new Map();
|
|
90
|
+
for (const it of items) {
|
|
91
|
+
const key = it[field];
|
|
92
|
+
map.set(key, (map.get(key) || 0) + Math.abs(it.delta));
|
|
93
|
+
}
|
|
94
|
+
return [...map.entries()].map(([key, count]) => ({ key, count }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function aggregateDir(items) {
|
|
98
|
+
const map = new Map();
|
|
99
|
+
for (const it of items) {
|
|
100
|
+
const parts = it.file.split("/");
|
|
101
|
+
const dir = parts.length > 1 ? parts.slice(0, -1).join("/") : ".";
|
|
102
|
+
map.set(dir, (map.get(dir) || 0) + Math.abs(it.delta));
|
|
103
|
+
}
|
|
104
|
+
return [...map.entries()].map(([key, count]) => ({ key, count }));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function sortDesc(arr) {
|
|
108
|
+
return arr.sort((a, b) => b.count - a.count);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── 文本格式化 ──────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
export function formatDriftText(result) {
|
|
114
|
+
const lines = [];
|
|
115
|
+
lines.push("╭──────────────────────────────────────╮");
|
|
116
|
+
lines.push("│ wl-skills-ui 漂移报告 │");
|
|
117
|
+
lines.push("╰──────────────────────────────────────╯");
|
|
118
|
+
lines.push("");
|
|
119
|
+
lines.push(`基线违规: ${result.baselineTotal} → 当前违规: ${result.currentTotal} (${result.netDelta >= 0 ? "+" : ""}${result.netDelta})`);
|
|
120
|
+
lines.push("");
|
|
121
|
+
lines.push(` 🔴 gained +${result.gained.count} 新增违规`);
|
|
122
|
+
lines.push(` 🟢 fixed -${result.fixed.count} 消化历史`);
|
|
123
|
+
if (result.regressed.count > 0) {
|
|
124
|
+
lines.push(` 🟡 regressed +${result.regressed.count} 回归`);
|
|
125
|
+
}
|
|
126
|
+
lines.push("");
|
|
127
|
+
|
|
128
|
+
if (result.topGainedByRule.length > 0) {
|
|
129
|
+
lines.push("新增 Top 规则:");
|
|
130
|
+
for (const { key, count } of result.topGainedByRule) {
|
|
131
|
+
lines.push(` ${key} +${count}`);
|
|
132
|
+
}
|
|
133
|
+
lines.push("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.topGainedByDir.length > 0) {
|
|
137
|
+
lines.push("新增 Top 目录:");
|
|
138
|
+
for (const { key, count } of result.topGainedByDir) {
|
|
139
|
+
lines.push(` ${key} +${count}`);
|
|
140
|
+
}
|
|
141
|
+
lines.push("");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (result.topFixedByRule.length > 0) {
|
|
145
|
+
lines.push("消化 Top 规则:");
|
|
146
|
+
for (const { key, count } of result.topFixedByRule) {
|
|
147
|
+
lines.push(` ${key} -${count}`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return lines.join("\n");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── JSON 格式化 ─────────────────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
export function formatDriftJson(result) {
|
|
158
|
+
return JSON.stringify(result, null, 2);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── 文件级便捷 API ──────────────────────────────────────────────────────────
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 从两个 JSON 文件路径生成漂移报告
|
|
165
|
+
* @param {string} baselinePath
|
|
166
|
+
* @param {string} currentPath
|
|
167
|
+
* @returns {DriftResult}
|
|
168
|
+
*/
|
|
169
|
+
export function driftFromFiles(baselinePath, currentPath) {
|
|
170
|
+
if (!existsSync(baselinePath)) {
|
|
171
|
+
throw new Error(`基线文件不存在: ${baselinePath}`);
|
|
172
|
+
}
|
|
173
|
+
if (!existsSync(currentPath)) {
|
|
174
|
+
throw new Error(`当前扫描结果不存在: ${currentPath}`);
|
|
175
|
+
}
|
|
176
|
+
const baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
|
|
177
|
+
const current = JSON.parse(readFileSync(currentPath, "utf8"));
|
|
178
|
+
return drift(baseline, current);
|
|
179
|
+
}
|
package/scanner/index.mjs
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* wl-scan fix --target <path> --dry-run
|
|
12
12
|
* wl-scan all --project <path> # 接入检查 + 风格扫描 + 报告
|
|
13
13
|
* wl-scan init # 打印接入指引
|
|
14
|
+
* wl-scan drift --baseline <f> --current <f> # 漂移检测
|
|
14
15
|
* wl-scan snapshot list # 列出快照
|
|
15
16
|
* wl-scan snapshot rollback [--id <id>] # 回退到快照(默认最新)
|
|
16
17
|
* wl-scan snapshot diff [--id <id>] # 查看快照与当前差异
|
|
@@ -43,6 +44,7 @@ const SUBCOMMANDS = new Set([
|
|
|
43
44
|
"fix",
|
|
44
45
|
"all",
|
|
45
46
|
"init",
|
|
47
|
+
"drift",
|
|
46
48
|
"snapshot",
|
|
47
49
|
]);
|
|
48
50
|
let subcommand = "scan";
|
|
@@ -67,6 +69,8 @@ const { values } = parseArgs({
|
|
|
67
69
|
keep: { type: "string", default: "5" },
|
|
68
70
|
"no-snapshot": { type: "boolean", default: false },
|
|
69
71
|
exempt: { type: "string", default: "" },
|
|
72
|
+
baseline: { type: "string", default: "" },
|
|
73
|
+
current: { type: "string", default: "" },
|
|
70
74
|
},
|
|
71
75
|
strict: false,
|
|
72
76
|
});
|
|
@@ -263,6 +267,27 @@ if (subcommand === "check") {
|
|
|
263
267
|
process.exit(values["fail-on-error"] && hasError ? 1 : 0);
|
|
264
268
|
}
|
|
265
269
|
|
|
270
|
+
if (subcommand === "drift") {
|
|
271
|
+
const { driftFromFiles, formatDriftText, formatDriftJson } =
|
|
272
|
+
await import("./drift.mjs");
|
|
273
|
+
const baselinePath = resolve(values.baseline || ".wl-baseline.json");
|
|
274
|
+
const currentPath = resolve(values.current);
|
|
275
|
+
if (!values.current) {
|
|
276
|
+
console.error(
|
|
277
|
+
"用法: wl-scan drift --baseline .wl-baseline.json --current .wl-current.json",
|
|
278
|
+
);
|
|
279
|
+
process.exit(1);
|
|
280
|
+
}
|
|
281
|
+
const result = driftFromFiles(baselinePath, currentPath);
|
|
282
|
+
if (values.output === "json") {
|
|
283
|
+
console.log(formatDriftJson(result));
|
|
284
|
+
} else {
|
|
285
|
+
console.log(formatDriftText(result));
|
|
286
|
+
}
|
|
287
|
+
const hasNew = result.gained.count > 0;
|
|
288
|
+
process.exit(values["fail-on-error"] && hasNew ? 1 : 0);
|
|
289
|
+
}
|
|
290
|
+
|
|
266
291
|
if (subcommand === "snapshot") {
|
|
267
292
|
const projectRoot = resolve(values.project);
|
|
268
293
|
const sub = args[0] || "list";
|
|
@@ -398,6 +423,30 @@ if (subcommand === "all") {
|
|
|
398
423
|
} else {
|
|
399
424
|
console.log(report);
|
|
400
425
|
}
|
|
426
|
+
|
|
427
|
+
// ── baseline 漂移检测(--baseline <file>)────────────────────────────────
|
|
428
|
+
let driftResult = null;
|
|
429
|
+
if (values.baseline) {
|
|
430
|
+
const { existsSync: fileExists } = await import("node:fs");
|
|
431
|
+
const baselinePath = resolve(values.baseline);
|
|
432
|
+
if (fileExists(baselinePath)) {
|
|
433
|
+
const { drift, formatDriftText, formatDriftJson } =
|
|
434
|
+
await import("./drift.mjs");
|
|
435
|
+
const baseline = JSON.parse(readFileSync(baselinePath, "utf8"));
|
|
436
|
+
const currentData = { issues: filtered };
|
|
437
|
+
driftResult = drift(baseline, currentData);
|
|
438
|
+
console.error("");
|
|
439
|
+
if (values.output === "json") {
|
|
440
|
+
console.error(formatDriftJson(driftResult));
|
|
441
|
+
} else {
|
|
442
|
+
console.error(formatDriftText(driftResult));
|
|
443
|
+
}
|
|
444
|
+
} else {
|
|
445
|
+
console.error(`[wl-scan] 基线文件不存在: ${baselinePath},跳过漂移检测`);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
401
449
|
const hasError = filtered.some((i) => i.severity === "error");
|
|
402
|
-
|
|
450
|
+
const hasDriftNew = driftResult && driftResult.gained.count > 0;
|
|
451
|
+
process.exit(values["fail-on-error"] && (hasError || hasDriftNew) ? 1 : 0);
|
|
403
452
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/check-scss.mjs — SCSS 链路完整性检查
|
|
4
|
+
*
|
|
5
|
+
* 递归验证 styles/index.scss 的 @forward/@use 链,
|
|
6
|
+
* 确保所有被引入的 SCSS partial 文件真实存在。
|
|
7
|
+
* 跳过 sass: 内置模块和包自引用(@agile-team/... / wl-skills-ui/...)。
|
|
8
|
+
*
|
|
9
|
+
* 用法:npm run check:scss
|
|
10
|
+
*/
|
|
11
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
12
|
+
import { join, dirname, resolve, basename } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const ROOT = resolve(__dirname, "..");
|
|
17
|
+
const STYLES = join(ROOT, "styles");
|
|
18
|
+
|
|
19
|
+
let errors = 0;
|
|
20
|
+
|
|
21
|
+
/** 跳过包自引用和 sass 内置 */
|
|
22
|
+
function shouldSkip(ref) {
|
|
23
|
+
if (ref.startsWith("sass:")) return true;
|
|
24
|
+
if (ref.startsWith("~")) return true;
|
|
25
|
+
if (ref.startsWith("@agile-team/")) return true;
|
|
26
|
+
if (ref.startsWith("wl-skills-ui/")) return true;
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 解析 SCSS @forward/@use 引用到实际文件路径 */
|
|
31
|
+
function resolveScssRef(dir, ref) {
|
|
32
|
+
// 已有 .scss 后缀
|
|
33
|
+
if (ref.endsWith(".scss")) {
|
|
34
|
+
const direct = join(dir, ref);
|
|
35
|
+
if (existsSync(direct)) return direct;
|
|
36
|
+
// _partial
|
|
37
|
+
const partial = join(dirname(direct), "_" + basename(direct));
|
|
38
|
+
if (existsSync(partial)) return partial;
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// 无后缀:SCSS partial 解析规则
|
|
42
|
+
const name = basename(ref);
|
|
43
|
+
const refDir = dirname(ref);
|
|
44
|
+
const base = join(dir, refDir);
|
|
45
|
+
const candidates = [
|
|
46
|
+
join(base, name + ".scss"),
|
|
47
|
+
join(base, "_" + name + ".scss"),
|
|
48
|
+
join(base, name, "index.scss"),
|
|
49
|
+
join(base, name, "_index.scss"),
|
|
50
|
+
];
|
|
51
|
+
return candidates.find((c) => existsSync(c)) || null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function checkImportChain(filePath, visited = new Set()) {
|
|
55
|
+
const norm = resolve(filePath);
|
|
56
|
+
if (visited.has(norm)) return;
|
|
57
|
+
visited.add(norm);
|
|
58
|
+
if (!existsSync(norm)) {
|
|
59
|
+
console.error(`❌ 文件不存在: ${norm}`);
|
|
60
|
+
errors++;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const content = readFileSync(norm, "utf8");
|
|
64
|
+
const re = /@(?:forward|use|import)\s+['"]([^'"]+)['"]/g;
|
|
65
|
+
let m;
|
|
66
|
+
while ((m = re.exec(content)) !== null) {
|
|
67
|
+
const ref = m[1];
|
|
68
|
+
if (shouldSkip(ref)) continue;
|
|
69
|
+
const resolved = resolveScssRef(dirname(norm), ref);
|
|
70
|
+
if (!resolved) {
|
|
71
|
+
console.error(`❌ ${norm} → "${ref}" 无法解析`);
|
|
72
|
+
errors++;
|
|
73
|
+
} else {
|
|
74
|
+
checkImportChain(resolved, visited);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 入口
|
|
80
|
+
const entryPoints = [
|
|
81
|
+
join(STYLES, "index.scss"),
|
|
82
|
+
join(STYLES, "presets", "skin.scss"),
|
|
83
|
+
];
|
|
84
|
+
|
|
85
|
+
for (const entry of entryPoints) {
|
|
86
|
+
if (!existsSync(entry)) {
|
|
87
|
+
console.error(`❌ 入口文件不存在: ${entry}`);
|
|
88
|
+
errors++;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
checkImportChain(entry);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (errors > 0) {
|
|
95
|
+
console.error(`\n❌ SCSS 链路检查失败: ${errors} 个错误`);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
} else {
|
|
98
|
+
console.log("✅ SCSS 链路检查通过");
|
|
99
|
+
}
|
|
@@ -508,13 +508,26 @@
|
|
|
508
508
|
word-break: keep-all;
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
.el-form-item.is-required:not(.is-no-asterisk)>.el-form-item__label::before
|
|
514
|
-
.el-form-item.is-required:not(.is-no-asterisk)>.el-form-item__label::after {
|
|
511
|
+
// 必填星号:只保留一颗 — 默认 asterisk-left(::before),asterisk-right 用 ::after
|
|
512
|
+
// 旧写法同时 display:inline-block 两个伪元素导致 * * 重复
|
|
513
|
+
.el-form-item.is-required:not(.is-no-asterisk)>.el-form-item__label::before {
|
|
515
514
|
color: var(--el-color-danger, #f56c6c) !important;
|
|
516
515
|
display: inline-block !important;
|
|
517
516
|
}
|
|
517
|
+
// asterisk-right 模式:显示 ::after,隐藏 ::before
|
|
518
|
+
.el-form-item.is-required:not(.is-no-asterisk).asterisk-right>.el-form-item__label::before {
|
|
519
|
+
display: none !important;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.el-form-item.is-required:not(.is-no-asterisk).asterisk-right>.el-form-item__label::after {
|
|
523
|
+
color: var(--el-color-danger, #f56c6c) !important;
|
|
524
|
+
display: inline-block !important;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// 默认模式:隐藏 ::after 防止重复星号
|
|
528
|
+
.el-form-item.is-required:not(.is-no-asterisk):not(.asterisk-right)>.el-form-item__label::after {
|
|
529
|
+
display: none !important;
|
|
530
|
+
}
|
|
518
531
|
|
|
519
532
|
.el-form-item.is-error {
|
|
520
533
|
.el-form-item__label,
|
|
@@ -33,12 +33,21 @@
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
// ── 搜索区字号统一 12px(紧凑密度,与 label / button 一致)────────────────
|
|
37
|
+
.el-form-item__label {
|
|
38
|
+
font-size: 12px !important;
|
|
39
|
+
}
|
|
36
40
|
.el-input__inner {
|
|
37
|
-
font-size:
|
|
41
|
+
font-size: 12px;
|
|
38
42
|
color: var(--el-text-color-primary);
|
|
39
43
|
&::placeholder { color: var(--el-text-color-placeholder); font-size: 12px; }
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
.el-select__placeholder,
|
|
47
|
+
.el-select__tags-text,
|
|
48
|
+
.el-select .el-select__selected-item {
|
|
49
|
+
font-size: 12px;
|
|
50
|
+
}
|
|
42
51
|
// ── 选择框 ────────────────────────────────────────────────────────────────
|
|
43
52
|
.el-select .el-select__wrapper {
|
|
44
53
|
border-radius: 6px !important;
|
|
@@ -91,12 +91,9 @@
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
// 6) 必填星号兼容 jh-ui label 结构
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
// `::before` 会作为 flex 项参与排版,必须显式声明颜色/显示。
|
|
94
|
+
// 颜色统一 danger 色;显示/隐藏由 _base-components.scss 总控,
|
|
95
|
+
// 此处只负责颜色,不设 display,避免与总控冲突导致 * * 重复。
|
|
97
96
|
.el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label::before,
|
|
98
|
-
.el-form-item.is-required:not(.is-no-asterisk)
|
|
99
|
-
.el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label > .com-text::before,
|
|
100
|
-
.el-form-item.is-required:not(.is-no-asterisk) > .el-form-item__label > .com-text::after {
|
|
97
|
+
.el-form-item.is-required:not(.is-no-asterisk)>.el-form-item__label>.com-text::before {
|
|
101
98
|
color: var(--el-color-danger, #f56c6c) !important;
|
|
102
99
|
}
|