dsh-visualkit 0.0.1 → 0.0.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 +14 -0
- package/README.md +20 -4
- package/data/assets/illustrations/openpeeps/LICENSE +1 -1
- package/lib/contrast-render.js +213 -213
- package/lib/detect.js +701 -701
- package/lib/diff.js +153 -153
- package/lib/icon-render.js +107 -107
- package/lib/index.js +1289 -1289
- package/lib/layouts/image.js +1010 -1010
- package/lib/layouts/source.js +1961 -1961
- package/lib/render-html.js +255 -255
- package/lib/rules.js +267 -267
- package/lib/style.js +688 -688
- package/lib/svg.js +729 -729
- package/lib/taste.js +219 -219
- package/package.json +4 -4
package/lib/taste.js
CHANGED
|
@@ -1,219 +1,219 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* taste.js — ui_taste 工具的实现:扫描前端产物 → 量化「AI 味」→ 给返工指令。
|
|
3
|
-
*
|
|
4
|
-
* 与 ui_audit 的分工:
|
|
5
|
-
* ui_audit 查「违反项目契约」(emoji 图标禁令、MorphIcon 误用、字典引用),
|
|
6
|
-
* 是硬规则、有明确对错;
|
|
7
|
-
* ui_taste 查「像不像 AI 默认产物」,是审美诊断、带分数与改进方向。
|
|
8
|
-
* 两者都扫源码,但一个报 error/warn/info,一个报 score + fix。
|
|
9
|
-
*
|
|
10
|
-
* 支持单文件(.html/.css)与目录(自动发现前端文件)。
|
|
11
|
-
*/
|
|
12
|
-
import { readFile, readdir, stat } from "node:fs/promises";
|
|
13
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
14
|
-
import { PROFILES, RULES } from "./rules.js";
|
|
15
|
-
import { tasteImage } from "./taste-image.js";
|
|
16
|
-
import { runRules } from "./detect.js";
|
|
17
|
-
import { isSkippableDir } from "./skip.js";
|
|
18
|
-
|
|
19
|
-
/** 项目内的设计意图声明文件名(与扫描同级目录)。 */
|
|
20
|
-
const INTENT_FILE = ".design-intent.json";
|
|
21
|
-
|
|
22
|
-
// 跳过列表与 layout_check 共用一份(lib/skip.js)——两处各写一份的话,
|
|
23
|
-
// 修了一边另一边就会漏(实测:taste 曾扫到 test/fixtures 把自己报成 12 条 AI 味)。
|
|
24
|
-
/**
|
|
25
|
-
* 参与扫描的前端产物扩展名。
|
|
26
|
-
* 含 .js/.mjs/.ts 是因为 CSS-in-JS 与「样式字符串内联在 JS 里」是主流写法
|
|
27
|
-
* (styled-components / emotion / 插件把 CSS 拼成字符串注入)——不含它们会
|
|
28
|
-
* 把这类项目整体漏掉。代价是纯逻辑文件也会被扫,但除 emoji 外的规则都基于
|
|
29
|
-
* 具体样式声明,没有样式的文件不会命中。
|
|
30
|
-
*/
|
|
31
|
-
const TASTE_EXTS = [".html", ".htm", ".css", ".vue", ".svelte", ".jsx", ".tsx", ".astro", ".js", ".mjs", ".ts"];
|
|
32
|
-
const PER_FILE_CAP = 2 * 1024 * 1024;
|
|
33
|
-
/** 默认文件上限:taste 只做统计,不需要扫全仓库。 */
|
|
34
|
-
const DEFAULT_MAX_FILES = 600;
|
|
35
|
-
/** findings 上限:报告要能读完,热点位置比穷举更有用。 */
|
|
36
|
-
const DEFAULT_MAX_FINDINGS = 24;
|
|
37
|
-
|
|
38
|
-
async function readCapped(file) {
|
|
39
|
-
try {
|
|
40
|
-
const info = await stat(file);
|
|
41
|
-
if (!info.isFile() || info.size > PER_FILE_CAP) return null;
|
|
42
|
-
return await readFile(file, "utf8");
|
|
43
|
-
} catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 目录/单文件 → [{path, rel, raw}];跳过依赖与构建产物目录。 */
|
|
49
|
-
async function collect(target, maxFiles) {
|
|
50
|
-
const abs = resolve(target);
|
|
51
|
-
let info;
|
|
52
|
-
try {
|
|
53
|
-
info = await stat(abs);
|
|
54
|
-
} catch {
|
|
55
|
-
throw new Error(`path not found: ${abs}`);
|
|
56
|
-
}
|
|
57
|
-
const files = [];
|
|
58
|
-
if (info.isFile()) {
|
|
59
|
-
const raw = await readCapped(abs);
|
|
60
|
-
if (raw !== null) files.push({ path: abs, rel: abs.split(sep).pop() ?? abs, raw });
|
|
61
|
-
return { files, root: abs, truncated: false };
|
|
62
|
-
}
|
|
63
|
-
const root = abs;
|
|
64
|
-
const queue = [root];
|
|
65
|
-
if (!info.isDirectory()) throw new Error(`not a file or directory: ${abs}`);
|
|
66
|
-
while (queue.length > 0 && files.length < maxFiles) {
|
|
67
|
-
const dir = queue.shift();
|
|
68
|
-
let entries;
|
|
69
|
-
try {
|
|
70
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
71
|
-
} catch {
|
|
72
|
-
continue;
|
|
73
|
-
}
|
|
74
|
-
for (const entry of entries) {
|
|
75
|
-
if (files.length >= maxFiles) break;
|
|
76
|
-
const full = join(dir, entry.name);
|
|
77
|
-
if (entry.isDirectory()) {
|
|
78
|
-
if (!isSkippableDir(entry.name) && !entry.name.startsWith(".")) queue.push(full);
|
|
79
|
-
continue;
|
|
80
|
-
}
|
|
81
|
-
if (!entry.isFile()) continue;
|
|
82
|
-
if (!TASTE_EXTS.some((ext) => entry.name.endsWith(ext))) continue;
|
|
83
|
-
if (entry.name.endsWith(".min.js") || entry.name.endsWith(".map")) continue;
|
|
84
|
-
const raw = await readCapped(full);
|
|
85
|
-
if (raw !== null) files.push({ path: full, rel: relative(root, full).split(sep).join("/"), raw });
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
return { files, root, truncated: files.length >= maxFiles };
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* 读取项目内的 .design-intent.json(设计意图声明)。
|
|
93
|
-
* 格式:{ "skin": "playful-geometric", "allow": ["radius-blob"], "reason": "..." }
|
|
94
|
-
* 解析失败不报错——一个坏的声明文件不该让整次体检失败,应当静默忽略并在返回里标明。
|
|
95
|
-
*/
|
|
96
|
-
async function readIntent(root) {
|
|
97
|
-
const path = join(root, INTENT_FILE);
|
|
98
|
-
let raw;
|
|
99
|
-
try {
|
|
100
|
-
raw = await readFile(path, "utf8");
|
|
101
|
-
} catch {
|
|
102
|
-
return { found: false };
|
|
103
|
-
}
|
|
104
|
-
try {
|
|
105
|
-
const parsed = JSON.parse(raw);
|
|
106
|
-
return {
|
|
107
|
-
found: true,
|
|
108
|
-
skin: typeof parsed?.skin === "string" ? parsed.skin : undefined,
|
|
109
|
-
allow: Array.isArray(parsed?.allow) ? parsed.allow.filter((id) => typeof id === "string") : [],
|
|
110
|
-
reason: typeof parsed?.reason === "string" ? parsed.reason : undefined
|
|
111
|
-
};
|
|
112
|
-
} catch {
|
|
113
|
-
return { found: true, invalid: true, allow: [] };
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/**
|
|
118
|
-
* 量化一次「AI 味」体检。
|
|
119
|
-
* @param {string} target 目录或单文件(绝对路径优先;相对路径由调用方解析)
|
|
120
|
-
* @param {{maxFiles?:number, maxFindings?:number, ignore?:string[], profile?:string, allow?:string[]}} [opts]
|
|
121
|
-
*/
|
|
122
|
-
async function taste(target, opts = {}) {
|
|
123
|
-
// 位图入口:用户手里常常只有一张图(别人发的参考 / 模型直接出的稿 / 截屏),
|
|
124
|
-
// 这种情况根本没有源码可扫。同一个问题(像不像 AI)、另一种输入,所以入口同一个。
|
|
125
|
-
// 位图:PNG 直读;JPEG/WebP 由 taste-image 内部转码(需本机浏览器)。
|
|
126
|
-
if (/\.(?:png|jpe?g|webp)$/i.test(String(target))) return await tasteImage(target, opts);
|
|
127
|
-
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
128
|
-
const maxFindings = opts.maxFindings ?? DEFAULT_MAX_FINDINGS;
|
|
129
|
-
const { files, root, truncated } = await collect(target, maxFiles);
|
|
130
|
-
if (files.length === 0) {
|
|
131
|
-
throw new Error(`未扫到前端文件(${TASTE_EXTS.join(" / ")}):${root}`);
|
|
132
|
-
}
|
|
133
|
-
// 豁免来源两处:命令行 allow 参数 + 项目内 .design-intent.json 的 allow。
|
|
134
|
-
// 两者取并集——声明是「这个偏离是有意的」,不是「不检测」。
|
|
135
|
-
// 单文件目标:声明文件在它所在目录(旧实现把文件路径当目录 join,单文件模式永远读不到)
|
|
136
|
-
const intentRoot = files.length === 1 && files[0].path === root ? dirname(root) : root;
|
|
137
|
-
const intent = await readIntent(intentRoot);
|
|
138
|
-
const explicitAllow = Array.isArray(opts.allow) ? opts.allow : [];
|
|
139
|
-
const declared = [...new Set([...(intent.allow ?? []), ...explicitAllow])];
|
|
140
|
-
const ignore = [...new Set([...(opts.ignore ?? []), ...declared])];
|
|
141
|
-
const result = runRules(files, { ignore, profile: opts.profile });
|
|
142
|
-
const profileName = result.profile;
|
|
143
|
-
const findings = result.findings.slice(0, maxFindings);
|
|
144
|
-
const byRule = new Map();
|
|
145
|
-
for (const finding of findings) {
|
|
146
|
-
if (!byRule.has(finding.rule)) byRule.set(finding.rule, []);
|
|
147
|
-
byRule.get(finding.rule).push(finding);
|
|
148
|
-
}
|
|
149
|
-
const counted = result.metrics.filter((metric) => metric.score > 0 && metric.counted);
|
|
150
|
-
// **成组出现**才是指纹:单个视觉元素或单个口癖不能证明什么,
|
|
151
|
-
// 但 3 项以上默认选择同时出现,用户就会认定「这是 AI 做的」。
|
|
152
|
-
// (口径来自「AI 味儿图鉴」的核心判断法:数特征组合,而不是数单项严重度。)
|
|
153
|
-
// 只数**风格默认项**(fingerprint)。字号档位、间距网格、交互态覆盖这些是
|
|
154
|
-
// 通用质量缺陷——桌面应用本来就有 6 档小字号、语义色有 7 个,把它们算进
|
|
155
|
-
// 「AI 味组合」会把真实的成熟产品判成模板(实测在线文档参照稿就是这样)。
|
|
156
|
-
// 只数「够分量的」指纹:单个 emoji(20 分)、5 个卡片里 3 种(35 分)这种
|
|
157
|
-
// 属于零星迹象,把它们凑进“3 项组合”会把正常界面也判成模板。
|
|
158
|
-
const distinctHits = counted.filter((metric) => metric.fingerprint === true && metric.score >= 40).length;
|
|
159
|
-
// 措辞只说「风格风险」,不判定作者——单项风格元素证明不了什么,
|
|
160
|
-
// 这一点与「AI 味儿图鉴」的口径一致。
|
|
161
|
-
const coOccurrence =
|
|
162
|
-
distinctHits >= 5
|
|
163
|
-
? { level: "strong", text: `${distinctHits} 组风格默认同时出现——页面会明显显得模板化,建议优先改这一组` }
|
|
164
|
-
: distinctHits >= 3
|
|
165
|
-
? { level: "pattern", text: `${distinctHits} 组风格默认同时出现——单看每项都不致命,组合起来会显得像套模板` }
|
|
166
|
-
: distinctHits === 0
|
|
167
|
-
? { level: "none", text: "" }
|
|
168
|
-
: { level: "scattered", text: `只命中 ${distinctHits} 项(零星),不足以说明风格——先确认是不是刻意选择` };
|
|
169
|
-
return {
|
|
170
|
-
score: result.score,
|
|
171
|
-
grade: result.grade,
|
|
172
|
-
profile: profileName,
|
|
173
|
-
profileLabel: PROFILES[profileName].label,
|
|
174
|
-
root,
|
|
175
|
-
filesScanned: files.length,
|
|
176
|
-
truncated,
|
|
177
|
-
// 只返回「正在失分」的规则,干净的规则不进模型上下文
|
|
178
|
-
coOccurrence,
|
|
179
|
-
metrics: result.metrics.filter((metric) => metric.score > 0),
|
|
180
|
-
findings,
|
|
181
|
-
findingsTotal: result.findings.length,
|
|
182
|
-
findingsTruncated: result.findings.length > maxFindings,
|
|
183
|
-
// 返工清单:只包含「当前档位下计分」的规则;风格层被降为 0 权重的进 advisories
|
|
184
|
-
actions: counted.map((metric) => {
|
|
185
|
-
const rule = RULES.find((item) => item.id === metric.id);
|
|
186
|
-
return {
|
|
187
|
-
rule: metric.id,
|
|
188
|
-
label: metric.label,
|
|
189
|
-
scope: metric.scope,
|
|
190
|
-
score: metric.score,
|
|
191
|
-
weight: metric.weight,
|
|
192
|
-
note: metric.note,
|
|
193
|
-
fix: rule.fix,
|
|
194
|
-
count: byRule.get(metric.id)?.length ?? 0
|
|
195
|
-
};
|
|
196
|
-
}),
|
|
197
|
-
// 风格取向提示:不计分,供有判断力的人/模型自行取舍
|
|
198
|
-
advisories: [...new Set(result.metrics.filter((m) => m.score > 0 && !m.counted).map((m) => m.id))].map((id) => {
|
|
199
|
-
const metric = result.metrics.find((m) => m.id === id);
|
|
200
|
-
const rule = RULES.find((item) => item.id === id);
|
|
201
|
-
return { rule: id, label: metric.label, score: metric.score, note: metric.note, fix: rule.fix };
|
|
202
|
-
}),
|
|
203
|
-
// 不能把 undefined 作为自身属性放进去:宿主会把 undefined 当 INVALID_TOOL_OUTPUT,
|
|
204
|
-
// 而缺 skin/reason(含 JSON 写坏)是很常见的声明形态。
|
|
205
|
-
intent: intent.found
|
|
206
|
-
? {
|
|
207
|
-
found: true,
|
|
208
|
-
invalid: intent.invalid === true,
|
|
209
|
-
...(intent.skin !== undefined ? { skin: intent.skin } : {}),
|
|
210
|
-
allow: declared,
|
|
211
|
-
...(intent.reason !== undefined ? { reason: intent.reason } : {})
|
|
212
|
-
}
|
|
213
|
-
: { found: false, allow: declared },
|
|
214
|
-
exempted: declared
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
export { DEFAULT_MAX_FILES, DEFAULT_MAX_FINDINGS, TASTE_EXTS, taste };
|
|
219
|
-
export { tasteImage };
|
|
1
|
+
/**
|
|
2
|
+
* taste.js — ui_taste 工具的实现:扫描前端产物 → 量化「AI 味」→ 给返工指令。
|
|
3
|
+
*
|
|
4
|
+
* 与 ui_audit 的分工:
|
|
5
|
+
* ui_audit 查「违反项目契约」(emoji 图标禁令、MorphIcon 误用、字典引用),
|
|
6
|
+
* 是硬规则、有明确对错;
|
|
7
|
+
* ui_taste 查「像不像 AI 默认产物」,是审美诊断、带分数与改进方向。
|
|
8
|
+
* 两者都扫源码,但一个报 error/warn/info,一个报 score + fix。
|
|
9
|
+
*
|
|
10
|
+
* 支持单文件(.html/.css)与目录(自动发现前端文件)。
|
|
11
|
+
*/
|
|
12
|
+
import { readFile, readdir, stat } from "node:fs/promises";
|
|
13
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
14
|
+
import { PROFILES, RULES } from "./rules.js";
|
|
15
|
+
import { tasteImage } from "./taste-image.js";
|
|
16
|
+
import { runRules } from "./detect.js";
|
|
17
|
+
import { isSkippableDir } from "./skip.js";
|
|
18
|
+
|
|
19
|
+
/** 项目内的设计意图声明文件名(与扫描同级目录)。 */
|
|
20
|
+
const INTENT_FILE = ".design-intent.json";
|
|
21
|
+
|
|
22
|
+
// 跳过列表与 layout_check 共用一份(lib/skip.js)——两处各写一份的话,
|
|
23
|
+
// 修了一边另一边就会漏(实测:taste 曾扫到 test/fixtures 把自己报成 12 条 AI 味)。
|
|
24
|
+
/**
|
|
25
|
+
* 参与扫描的前端产物扩展名。
|
|
26
|
+
* 含 .js/.mjs/.ts 是因为 CSS-in-JS 与「样式字符串内联在 JS 里」是主流写法
|
|
27
|
+
* (styled-components / emotion / 插件把 CSS 拼成字符串注入)——不含它们会
|
|
28
|
+
* 把这类项目整体漏掉。代价是纯逻辑文件也会被扫,但除 emoji 外的规则都基于
|
|
29
|
+
* 具体样式声明,没有样式的文件不会命中。
|
|
30
|
+
*/
|
|
31
|
+
const TASTE_EXTS = [".html", ".htm", ".css", ".vue", ".svelte", ".jsx", ".tsx", ".astro", ".js", ".mjs", ".ts"];
|
|
32
|
+
const PER_FILE_CAP = 2 * 1024 * 1024;
|
|
33
|
+
/** 默认文件上限:taste 只做统计,不需要扫全仓库。 */
|
|
34
|
+
const DEFAULT_MAX_FILES = 600;
|
|
35
|
+
/** findings 上限:报告要能读完,热点位置比穷举更有用。 */
|
|
36
|
+
const DEFAULT_MAX_FINDINGS = 24;
|
|
37
|
+
|
|
38
|
+
async function readCapped(file) {
|
|
39
|
+
try {
|
|
40
|
+
const info = await stat(file);
|
|
41
|
+
if (!info.isFile() || info.size > PER_FILE_CAP) return null;
|
|
42
|
+
return await readFile(file, "utf8");
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 目录/单文件 → [{path, rel, raw}];跳过依赖与构建产物目录。 */
|
|
49
|
+
async function collect(target, maxFiles) {
|
|
50
|
+
const abs = resolve(target);
|
|
51
|
+
let info;
|
|
52
|
+
try {
|
|
53
|
+
info = await stat(abs);
|
|
54
|
+
} catch {
|
|
55
|
+
throw new Error(`path not found: ${abs}`);
|
|
56
|
+
}
|
|
57
|
+
const files = [];
|
|
58
|
+
if (info.isFile()) {
|
|
59
|
+
const raw = await readCapped(abs);
|
|
60
|
+
if (raw !== null) files.push({ path: abs, rel: abs.split(sep).pop() ?? abs, raw });
|
|
61
|
+
return { files, root: abs, truncated: false };
|
|
62
|
+
}
|
|
63
|
+
const root = abs;
|
|
64
|
+
const queue = [root];
|
|
65
|
+
if (!info.isDirectory()) throw new Error(`not a file or directory: ${abs}`);
|
|
66
|
+
while (queue.length > 0 && files.length < maxFiles) {
|
|
67
|
+
const dir = queue.shift();
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (files.length >= maxFiles) break;
|
|
76
|
+
const full = join(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory()) {
|
|
78
|
+
if (!isSkippableDir(entry.name) && !entry.name.startsWith(".")) queue.push(full);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!entry.isFile()) continue;
|
|
82
|
+
if (!TASTE_EXTS.some((ext) => entry.name.endsWith(ext))) continue;
|
|
83
|
+
if (entry.name.endsWith(".min.js") || entry.name.endsWith(".map")) continue;
|
|
84
|
+
const raw = await readCapped(full);
|
|
85
|
+
if (raw !== null) files.push({ path: full, rel: relative(root, full).split(sep).join("/"), raw });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { files, root, truncated: files.length >= maxFiles };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 读取项目内的 .design-intent.json(设计意图声明)。
|
|
93
|
+
* 格式:{ "skin": "playful-geometric", "allow": ["radius-blob"], "reason": "..." }
|
|
94
|
+
* 解析失败不报错——一个坏的声明文件不该让整次体检失败,应当静默忽略并在返回里标明。
|
|
95
|
+
*/
|
|
96
|
+
async function readIntent(root) {
|
|
97
|
+
const path = join(root, INTENT_FILE);
|
|
98
|
+
let raw;
|
|
99
|
+
try {
|
|
100
|
+
raw = await readFile(path, "utf8");
|
|
101
|
+
} catch {
|
|
102
|
+
return { found: false };
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const parsed = JSON.parse(raw);
|
|
106
|
+
return {
|
|
107
|
+
found: true,
|
|
108
|
+
skin: typeof parsed?.skin === "string" ? parsed.skin : undefined,
|
|
109
|
+
allow: Array.isArray(parsed?.allow) ? parsed.allow.filter((id) => typeof id === "string") : [],
|
|
110
|
+
reason: typeof parsed?.reason === "string" ? parsed.reason : undefined
|
|
111
|
+
};
|
|
112
|
+
} catch {
|
|
113
|
+
return { found: true, invalid: true, allow: [] };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* 量化一次「AI 味」体检。
|
|
119
|
+
* @param {string} target 目录或单文件(绝对路径优先;相对路径由调用方解析)
|
|
120
|
+
* @param {{maxFiles?:number, maxFindings?:number, ignore?:string[], profile?:string, allow?:string[]}} [opts]
|
|
121
|
+
*/
|
|
122
|
+
async function taste(target, opts = {}) {
|
|
123
|
+
// 位图入口:用户手里常常只有一张图(别人发的参考 / 模型直接出的稿 / 截屏),
|
|
124
|
+
// 这种情况根本没有源码可扫。同一个问题(像不像 AI)、另一种输入,所以入口同一个。
|
|
125
|
+
// 位图:PNG 直读;JPEG/WebP 由 taste-image 内部转码(需本机浏览器)。
|
|
126
|
+
if (/\.(?:png|jpe?g|webp)$/i.test(String(target))) return await tasteImage(target, opts);
|
|
127
|
+
const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;
|
|
128
|
+
const maxFindings = opts.maxFindings ?? DEFAULT_MAX_FINDINGS;
|
|
129
|
+
const { files, root, truncated } = await collect(target, maxFiles);
|
|
130
|
+
if (files.length === 0) {
|
|
131
|
+
throw new Error(`未扫到前端文件(${TASTE_EXTS.join(" / ")}):${root}`);
|
|
132
|
+
}
|
|
133
|
+
// 豁免来源两处:命令行 allow 参数 + 项目内 .design-intent.json 的 allow。
|
|
134
|
+
// 两者取并集——声明是「这个偏离是有意的」,不是「不检测」。
|
|
135
|
+
// 单文件目标:声明文件在它所在目录(旧实现把文件路径当目录 join,单文件模式永远读不到)
|
|
136
|
+
const intentRoot = files.length === 1 && files[0].path === root ? dirname(root) : root;
|
|
137
|
+
const intent = await readIntent(intentRoot);
|
|
138
|
+
const explicitAllow = Array.isArray(opts.allow) ? opts.allow : [];
|
|
139
|
+
const declared = [...new Set([...(intent.allow ?? []), ...explicitAllow])];
|
|
140
|
+
const ignore = [...new Set([...(opts.ignore ?? []), ...declared])];
|
|
141
|
+
const result = runRules(files, { ignore, profile: opts.profile });
|
|
142
|
+
const profileName = result.profile;
|
|
143
|
+
const findings = result.findings.slice(0, maxFindings);
|
|
144
|
+
const byRule = new Map();
|
|
145
|
+
for (const finding of findings) {
|
|
146
|
+
if (!byRule.has(finding.rule)) byRule.set(finding.rule, []);
|
|
147
|
+
byRule.get(finding.rule).push(finding);
|
|
148
|
+
}
|
|
149
|
+
const counted = result.metrics.filter((metric) => metric.score > 0 && metric.counted);
|
|
150
|
+
// **成组出现**才是指纹:单个视觉元素或单个口癖不能证明什么,
|
|
151
|
+
// 但 3 项以上默认选择同时出现,用户就会认定「这是 AI 做的」。
|
|
152
|
+
// (口径来自「AI 味儿图鉴」的核心判断法:数特征组合,而不是数单项严重度。)
|
|
153
|
+
// 只数**风格默认项**(fingerprint)。字号档位、间距网格、交互态覆盖这些是
|
|
154
|
+
// 通用质量缺陷——桌面应用本来就有 6 档小字号、语义色有 7 个,把它们算进
|
|
155
|
+
// 「AI 味组合」会把真实的成熟产品判成模板(实测在线文档参照稿就是这样)。
|
|
156
|
+
// 只数「够分量的」指纹:单个 emoji(20 分)、5 个卡片里 3 种(35 分)这种
|
|
157
|
+
// 属于零星迹象,把它们凑进“3 项组合”会把正常界面也判成模板。
|
|
158
|
+
const distinctHits = counted.filter((metric) => metric.fingerprint === true && metric.score >= 40).length;
|
|
159
|
+
// 措辞只说「风格风险」,不判定作者——单项风格元素证明不了什么,
|
|
160
|
+
// 这一点与「AI 味儿图鉴」的口径一致。
|
|
161
|
+
const coOccurrence =
|
|
162
|
+
distinctHits >= 5
|
|
163
|
+
? { level: "strong", text: `${distinctHits} 组风格默认同时出现——页面会明显显得模板化,建议优先改这一组` }
|
|
164
|
+
: distinctHits >= 3
|
|
165
|
+
? { level: "pattern", text: `${distinctHits} 组风格默认同时出现——单看每项都不致命,组合起来会显得像套模板` }
|
|
166
|
+
: distinctHits === 0
|
|
167
|
+
? { level: "none", text: "" }
|
|
168
|
+
: { level: "scattered", text: `只命中 ${distinctHits} 项(零星),不足以说明风格——先确认是不是刻意选择` };
|
|
169
|
+
return {
|
|
170
|
+
score: result.score,
|
|
171
|
+
grade: result.grade,
|
|
172
|
+
profile: profileName,
|
|
173
|
+
profileLabel: PROFILES[profileName].label,
|
|
174
|
+
root,
|
|
175
|
+
filesScanned: files.length,
|
|
176
|
+
truncated,
|
|
177
|
+
// 只返回「正在失分」的规则,干净的规则不进模型上下文
|
|
178
|
+
coOccurrence,
|
|
179
|
+
metrics: result.metrics.filter((metric) => metric.score > 0),
|
|
180
|
+
findings,
|
|
181
|
+
findingsTotal: result.findings.length,
|
|
182
|
+
findingsTruncated: result.findings.length > maxFindings,
|
|
183
|
+
// 返工清单:只包含「当前档位下计分」的规则;风格层被降为 0 权重的进 advisories
|
|
184
|
+
actions: counted.map((metric) => {
|
|
185
|
+
const rule = RULES.find((item) => item.id === metric.id);
|
|
186
|
+
return {
|
|
187
|
+
rule: metric.id,
|
|
188
|
+
label: metric.label,
|
|
189
|
+
scope: metric.scope,
|
|
190
|
+
score: metric.score,
|
|
191
|
+
weight: metric.weight,
|
|
192
|
+
note: metric.note,
|
|
193
|
+
fix: rule.fix,
|
|
194
|
+
count: byRule.get(metric.id)?.length ?? 0
|
|
195
|
+
};
|
|
196
|
+
}),
|
|
197
|
+
// 风格取向提示:不计分,供有判断力的人/模型自行取舍
|
|
198
|
+
advisories: [...new Set(result.metrics.filter((m) => m.score > 0 && !m.counted).map((m) => m.id))].map((id) => {
|
|
199
|
+
const metric = result.metrics.find((m) => m.id === id);
|
|
200
|
+
const rule = RULES.find((item) => item.id === id);
|
|
201
|
+
return { rule: id, label: metric.label, score: metric.score, note: metric.note, fix: rule.fix };
|
|
202
|
+
}),
|
|
203
|
+
// 不能把 undefined 作为自身属性放进去:宿主会把 undefined 当 INVALID_TOOL_OUTPUT,
|
|
204
|
+
// 而缺 skin/reason(含 JSON 写坏)是很常见的声明形态。
|
|
205
|
+
intent: intent.found
|
|
206
|
+
? {
|
|
207
|
+
found: true,
|
|
208
|
+
invalid: intent.invalid === true,
|
|
209
|
+
...(intent.skin !== undefined ? { skin: intent.skin } : {}),
|
|
210
|
+
allow: declared,
|
|
211
|
+
...(intent.reason !== undefined ? { reason: intent.reason } : {})
|
|
212
|
+
}
|
|
213
|
+
: { found: false, allow: declared },
|
|
214
|
+
exempted: declared
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export { DEFAULT_MAX_FILES, DEFAULT_MAX_FINDINGS, TASTE_EXTS, taste };
|
|
219
|
+
export { tasteImage };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-visualkit",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.2",
|
|
4
4
|
"description": "视觉设计工具箱(Visual Design Toolkit) — 给工具和框架,不给风格目录:同一套原语(配色/字体/形状/图案/栅格/动效/交互态)可落成 UI、网页、平面、海报与文本版式。含图标检索出码、AI 味评分与返工、排版几何体检、风格规格、本地渲染合成。零外部进程(resvg vendored)。",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ikeyuchen",
|
|
@@ -70,10 +70,10 @@
|
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
72
|
"scripts": {
|
|
73
|
-
"test": "node --test test
|
|
74
|
-
"test:spec": "node --test --test-reporter=spec test
|
|
73
|
+
"test": "node --test \"test/*.test.mjs\"",
|
|
74
|
+
"test:spec": "node --test --test-reporter=spec \"test/*.test.mjs\"",
|
|
75
75
|
"test:pack": "node --test test/pack.test.mjs",
|
|
76
|
-
"check": "node --test test
|
|
76
|
+
"check": "node --test \"test/*.test.mjs\"",
|
|
77
77
|
"pack:dry": "npm pack --dry-run",
|
|
78
78
|
"prepublishOnly": "npm run release:check && npm test",
|
|
79
79
|
"selfcheck": "node tools/selfcheck.mjs",
|