@agile-team/wl-skills-ui 1.8.0 → 1.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +1 -1
- package/mcp/server.js +44 -0
- package/package.json +1 -1
- package/scanner/drift.mjs +179 -0
- package/scanner/index.mjs +25 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,14 @@ 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.1] - 2026-05-13
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- 新增 **`scanner/drift.mjs`** 漂移检测模块:对比基线与当前扫描 JSON,输出 gained / fixed / regressed 结构化报告。
|
|
12
|
+
- 新增 CLI `wl-scan drift --baseline <f> --current <f>` 子命令,支持 `--output json` 和 `--fail-on-error` 门槛。
|
|
13
|
+
- 新增 MCP 工具 **`wl_ui_drift`**:AI 可直接传入两份扫描 JSON 拿漂移报告。
|
|
14
|
+
|
|
7
15
|
## [1.8.0] - 2026-05-12
|
|
8
16
|
|
|
9
17
|
### Added
|
package/README.md
CHANGED
|
@@ -213,7 +213,7 @@ yarn add @agile-team/wl-skills-ui
|
|
|
213
213
|
|
|
214
214
|
## 版本亮点
|
|
215
215
|
|
|
216
|
-
当前 v1.8.
|
|
216
|
+
当前 v1.8.1 版本完成 R-rule 治理体系单一事实源化,并落地业务项目长效治理方案:
|
|
217
217
|
|
|
218
218
|
- 新增 **`standards/rules.json`** 作为 29 条 R-rule 的单一事实源;所有 standards 文档、SKILL.md、scanner、MCP、未来 ESLint 插件均从此派生
|
|
219
219
|
- 新增 **`standards/rules-loader.mjs`** 共享加载器;新增 MCP 工具 **`wl_ui_list_rules`** / **`wl_ui_describe_rule`**,AI 写代码前一键查规则
|
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.1",
|
|
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",
|
|
@@ -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";
|