@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 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.0 版本完成 R-rule 治理体系单一事实源化,并落地业务项目长效治理方案:
217
-
218
- - 新增 **`standards/rules.json`** 作为 29 R-rule 的单一事实源;所有 standards 文档、SKILL.md、scanner、MCP、未来 ESLint 插件均从此派生
219
- - 新增 **`standards/rules-loader.mjs`** 共享加载器;新增 MCP 工具 **`wl_ui_list_rules`** / **`wl_ui_describe_rule`**,AI 写代码前一键查规则
220
- - 修复历史 ID 冲突与逻辑反转 bug:scanner `tag.mjs` 原 R017/R018 重号为 R019/R020(与 `color.mjs` 解耦,旧 ID 通过 `aliases` 兼容);R011 由"必须放 #footer"反转为"不得放 #footer",与 standards/ui/05 对齐
221
- - 清理 `_registry.md` 12 条幽灵 skill 引用;`runtime/style-align/SKILL.md` 改为指针式引用 rules.json,不再复述规则细节
222
- - `scripts/check-docs.mjs` 扩展为 R-rule / scanner / SKILL / registry 四向一致性守卫,CI 不一致直接红灯
223
- - 新增 **`docs/governance-long-term.md`**:业务项目长效治理方案(基线 / 豁免 / 漂移看板 / 版本钉死 / 写作期 AI 守护五机制),杜绝遗留累积与升级回归
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
- # 每次 wl-skills-ui 升版后跑一次
82
- npx wl-ui audit --target src --output json --outFile .wl-drift-after.json
83
- node -e "console.log(require('@agile-team/wl-skills-ui/scanner/drift.mjs').report('.wl-baseline.json','.wl-drift-after.json'))"
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
- gained +12 新增违规(红灯)
91
- fixed -45 消化历史(绿灯)
92
- regressed +3 曾修过又破的(黄灯,最高优先级追查)
93
- exempt +0 豁免清单未扩张
94
- 按规则 Top3:R017(+8) R001(+3) R009(+1)
95
- 按目录 Top3:src/views/quote(+9) src/views/cost(+3)
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
- > 实现入口:v1.8.0 起 `scanner/index.mjs` 已暴露 `--output json` 完整结构(含 `file/line/rule`),业务项目可基于该结构自实现 drift 报告;包内 `scanner/drift.mjs` 1.8.x 后续小版本提供。
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.0",
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">
3
+ <el-table-column type="selection" width="55" />
4
+ <el-table-column prop="name" label="名称" />
5
+ <el-table-column prop="status" label="状态" align="left" />
6
+ </el-table>
7
+ </template>
@@ -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,5 @@
1
+ <template>
2
+ <el-table :data="list">
3
+ <el-table-column prop="name" label="名称" align="center" />
4
+ </el-table>
5
+ </template>
@@ -0,0 +1,5 @@
1
+ <template>
2
+ <el-table :data="list" empty-text="暂无数据">
3
+ <el-table-column prop="name" label="名称" align="center" />
4
+ </el-table>
5
+ </template>
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <el-form>
3
+ <el-form-item label="名称">
4
+ <el-input v-model="form.name" />
5
+ </el-form-item>
6
+ <el-form-item label="类型">
7
+ <el-select v-model="form.type" />
8
+ </el-form-item>
9
+ </el-form>
10
+ </template>
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <el-form>
3
+ <el-form-item label="名称">
4
+ <el-input v-model="form.name" size="small" />
5
+ </el-form-item>
6
+ <el-form-item label="类型">
7
+ <el-select v-model="form.type" size="small" />
8
+ </el-form-item>
9
+ </el-form>
10
+ </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,9 @@
1
+ <template>
2
+ <div class="box">hello</div>
3
+ </template>
4
+ <style scoped>
5
+ .box {
6
+ color: #409eff;
7
+ background-color: #f56c6c;
8
+ }
9
+ </style>
@@ -0,0 +1,9 @@
1
+ <template>
2
+ <div class="box">hello</div>
3
+ </template>
4
+ <style scoped>
5
+ .box {
6
+ color: var(--el-color-primary);
7
+ background-color: var(--el-fill-color-light);
8
+ }
9
+ </style>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <BaseTable :data="list" cid="test-table" empty-text="暂无数据" />
3
+ </template>
@@ -0,0 +1,3 @@
1
+ <template>
2
+ <BaseTable :data="list" render-type="agGrid" cid="test-table" empty-text="暂无数据" />
3
+ </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
- process.exit(values["fail-on-error"] && hasError ? 1 : 0);
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
- .el-form-item.is-required:not(.is-no-asterisk).asterisk-left > .el-form-item__label::before,
512
- .el-form-item.is-required:not(.is-no-asterisk).asterisk-right>.el-form-item__label::after,
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: 13px;
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
- // Element Plus `::before` 放在 `.el-form-item__label` 上,
95
- // jh-ui 没改这一点;但 label 自身是 inline-flex,部分浏览器下
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) > .el-form-item__label::after,
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
  }