@qilitt-mickey/vue3-temp-skill 1.1.75 → 1.2.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/README.md +85 -84
- package/SKILL.md +55 -27
- package/bin/cli.js +5 -6
- package/mapping/admin.json +60 -0
- package/package.json +4 -13
- package/references/adapters/_new-adapter.md +88 -0
- package/references/adapters/vue-antdv.md +70 -0
- package/references/adapters/vue-element-plus.md +90 -0
- package/references/design-apply.md +238 -122
- package/scripts/modules.json +2 -2
- package/dist/mapping-type.d.ts +0 -33
- package/dist/theme-vars.js +0 -62
- package/dist/theme-vars.ts +0 -113
- package/dist/theme.css +0 -73
- package/mapping/basic/color.json +0 -35
- package/mapping/basic/radius.json +0 -17
- package/mapping/basic/semantic-values.json +0 -72
- package/mapping/basic/spacing.json +0 -21
- package/mapping/basic/typography.json +0 -21
- package/mapping/component/button.json +0 -90
- package/mapping/component/card.json +0 -31
- package/mapping/component/input.json +0 -42
- package/mapping/component/overlay.json +0 -79
- package/mapping/component/pagination.json +0 -34
- package/mapping/component/table.json +0 -42
- package/mapping/component/tag.json +0 -48
- package/mapping/layout/breakpoint.json +0 -20
- package/mapping/layout/shell-geometry.json +0 -26
- package/mapping/scenes/admin/content.json +0 -497
- package/mapping/scenes/admin/layout.json +0 -577
- package/parser/fixtures/changeset-fullpage-restore.json +0 -259
- package/parser/fixtures/changeset-gap-block.json +0 -24
- package/parser/fixtures/changeset-legacy.json +0 -11
- package/parser/fixtures/changeset-neg1.json +0 -8
- package/parser/fixtures/changeset-neg2.json +0 -10
- package/parser/fixtures/changeset-neg3.json +0 -22
- package/parser/fixtures/changeset-neg4-g8.json +0 -15
- package/parser/fixtures/changeset-neg5-g8-stub.json +0 -26
- package/parser/fixtures/changeset-neg6-g9-identity.json +0 -39
- package/parser/fixtures/changeset-neg7-g10-dims.json +0 -57
- package/parser/fixtures/changeset-neg8-g11-oracle.json +0 -57
- package/parser/fixtures/changeset-neg9-g6-token-chain.json +0 -34
- package/parser/fixtures/changeset-pos-g6-token-chain.json +0 -35
- package/parser/fixtures/changeset-pos-theme-neutral.json +0 -264
- package/parser/fixtures/changeset-pos-theme-nometa.json +0 -260
- package/parser/fixtures/changeset-pos.json +0 -50
- package/parser/fixtures/changeset-registry-gap.json +0 -29
- package/parser/fixtures/changeset-v32-fields.json +0 -71
- package/parser/fixtures/fake-project/src/styles/app.scss +0 -5
- package/parser/fixtures/fake-project/src/views/list.vue +0 -13
- package/parser/fixtures/registry-bad.json +0 -11
- package/parser/fixtures/registry-g6-token-chain.json +0 -10
- package/parser/fixtures/registry-sample.json +0 -15
- package/parser/fixtures/verify/actual-clean.json +0 -35
- package/parser/fixtures/verify/actual.json +0 -33
- package/parser/fixtures/verify/mockup-baseline-admin-list.html +0 -27
- package/parser/fixtures/verify/mockup-g6-content.html +0 -12
- package/parser/fixtures/verify/mockup-neutral.html +0 -26
- package/parser/fixtures/verify/mockup-nometa.html +0 -26
- package/parser/fixtures/verify/oracle.json +0 -37
- package/parser/fixtures/verify/snapshot-latest.json +0 -103
- package/parser/generator.js +0 -178
- package/parser/index.js +0 -2706
- package/parser/loader.js +0 -66
- package/parser/override.js +0 -119
- package/parser/regression.js +0 -301
- package/parser/validator.js +0 -206
- package/parser/watch.js +0 -103
- package/references/steps/maintenance.md +0 -31
- package/references/steps/regression.md +0 -49
- package/references/steps/step0-preflight.md +0 -58
- package/references/steps/step1-match.md +0 -65
- package/references/steps/step1b-audit.md +0 -23
- package/references/steps/step1c-registry.md +0 -29
- package/references/steps/step2-structure.md +0 -73
- package/references/steps/step3-style.md +0 -38
- package/references/steps/step3b-shell.md +0 -63
- package/references/steps/step3c-priority.md +0 -26
- package/references/steps/step4-l3.md +0 -14
- package/references/steps/step5-gates.md +0 -64
- package/references/steps/step6-verify.md +0 -77
- package/scripts/apply-final-gate.mjs +0 -170
- package/scripts/check.mjs +0 -207
- package/scripts/design-audit.mjs +0 -1575
- package/scripts/validate.mjs +0 -106
package/parser/index.js
DELETED
|
@@ -1,2706 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* index.js — 映射体系解析执行入口
|
|
5
|
-
*
|
|
6
|
-
* 职责:
|
|
7
|
-
* 1. 读取:递归合并 mapping/ 全目录 → 完整映射数据集
|
|
8
|
-
* 2. 校验:调用 validator 全量校验,失败 exit 1
|
|
9
|
-
* 3. 编译:调用 generator 生成 dist/ 产物
|
|
10
|
-
* 4. 匹配:对设计变更集(design-changeset/v1)执行三级匹配,输出逐 ID 转换计划
|
|
11
|
-
* - 一级匹配:ID 完整命中 mappings
|
|
12
|
-
* - 二级匹配:父模块(去掉最后一段)命中
|
|
13
|
-
* - 三级匹配:全新模块 → 待补充映射清单
|
|
14
|
-
*
|
|
15
|
-
* CLI:
|
|
16
|
-
* node parser/index.js build [--dry] # 校验 + 生成产物
|
|
17
|
-
* node parser/index.js match <changeset.json...> # 三级匹配(可多文件)
|
|
18
|
-
* node parser/index.js match --batch <dir> # 项目级批量匹配目录下全部变更集
|
|
19
|
-
* node parser/index.js list [--scene admin] [--verbose] # 列出全部语义 ID(--verbose 含字段与形态)
|
|
20
|
-
* node parser/index.js query <semantic.id> # 查询单个 ID 映射详情
|
|
21
|
-
* node parser/index.js audit --project <dir> [<cs…>] # 挂载点存在性 + 死通道静态审计
|
|
22
|
-
* node parser/index.js verify --oracle <o> --actual <a> # 渲染态逐点验收:computed style 期望 vs 实测 diff(G12 存在性闸 + 残差豁免 + 快照回归)
|
|
23
|
-
* node parser/index.js registry <registry.json> # 登记表通道核验:登记项 sid / dims 是否在词典有落点
|
|
24
|
-
* node parser/watch.js # 开发态监听 mapping/ 自动重编译(§3.4.3)
|
|
25
|
-
*/
|
|
26
|
-
|
|
27
|
-
import { readFileSync, writeFileSync, statSync, readdirSync, existsSync } from "node:fs";
|
|
28
|
-
import path from "node:path";
|
|
29
|
-
import { fileURLToPath } from "node:url";
|
|
30
|
-
import { buildMappingDataset } from "./loader.js";
|
|
31
|
-
import { validateMappingDataset, buildMergedIndex } from "./validator.js";
|
|
32
|
-
import { generateAll } from "./generator.js";
|
|
33
|
-
|
|
34
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
-
const ROOT = path.resolve(__dirname, "..");
|
|
36
|
-
|
|
37
|
-
/** 定性值(非色彩类语义描述,如 中等/全圆角/宽松):不参与译表,AI 按字段语义施工,不告警。
|
|
38
|
-
* 注意:模块级常量必须在 main() 调用前声明(TDZ),勿下移到文件底部。 */
|
|
39
|
-
const QUALITATIVE_RE = /^(中等|加粗|常规|细|粗|小圆角|全圆角|直角|宽松|紧凑|标准|大|中|小|等宽数字|等宽|左对齐|居中|右对齐|两端对齐|不换行|换行)$/;
|
|
40
|
-
/** 数值字面量:带 CSS 单位的精确尺寸(40px / 1.5rem / 50%)——设计侧已消歧的精确值,透传不提示 */
|
|
41
|
-
const NUMERIC_VALUE_RE = /^\d+(\.\d+)?(px|rem|em|%|vh|vw|pt)$/i;
|
|
42
|
-
/** 复合样式字面量:含空格的多段值(如 1px solid #F0F0F0)按已消歧处理(hex 已被禁词校验限定位置) */
|
|
43
|
-
const COMPOSITE_VALUE_RE = /\s/;
|
|
44
|
-
/** 落点层级枚举(与词典 validator 的 LAYER_ENUM 一致) */
|
|
45
|
-
const LAYER_VALUES = ["host", "inner"];
|
|
46
|
-
/** 语义 ID 严格格式(apply_via 锚点名须符合) */
|
|
47
|
-
const ID_RE_STRICT = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$/;
|
|
48
|
-
/**
|
|
49
|
-
* 内层盒属性族(changeset-schema §layer):这些文本控制/行高/对齐类属性必须落在 inner 层
|
|
50
|
-
* 才生效。根因:Element Plus 给内层文本盒 .cell 写了 white-space:normal / text-align 等出厂
|
|
51
|
-
* 样式,写在宿主盒(th/td)上会被内层覆盖 —— 这是「表头/内容竖排换行」的根因。
|
|
52
|
-
* 本集合作两处消费:
|
|
53
|
-
* ① resolveAnchorTarget —— 命中即把该字段自动路由到 inner 锚点(保底落点,杜绝静默落 host);
|
|
54
|
-
* ② checkInnerOnlyProps 闸门 —— 条目缺 inner 锚点却声明了此类字段时,报词典完整性缺口。
|
|
55
|
-
*/
|
|
56
|
-
const INNER_ONLY_PROPS = new Set(["white_space", "text_overflow", "overflow", "word_break", "text_align", "line_height", "vertical_align", "nowrap", "text_align_last"]);
|
|
57
|
-
/** 几何字段(宿主内边距/外边距族):落 host 时与出厂内层盒自带 padding 叠加,必须同步清零内层(G5) */
|
|
58
|
-
const GEOMETRY_PAD_RE = /^(padding|padding_x|padding_y|padding_inline|padding_block|margin|margin_x|margin_y|margin_inline|margin_block)$/;
|
|
59
|
-
/** 登记表里唯一允许的「非词典点位」:全局令牌通道(对应变更集顶层 global 节点,无 css selector 故不入词典)。
|
|
60
|
-
* ⚠ 必须在 main() 调用前初始化(registry/match 命令运行时即访问;下移会触发 TDZ ReferenceError) */
|
|
61
|
-
const GLOBAL_PSEUDO_ID = "global";
|
|
62
|
-
|
|
63
|
-
/** audit:审计时跳过的目录(依赖 / 产物 / 缓存) */
|
|
64
|
-
const AUDIT_SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", ".output", ".vite", ".nuxt", "coverage", "public"]);
|
|
65
|
-
/** audit:「模板 / 脚本」扩展名:命中 = 结构挂载 */
|
|
66
|
-
const STRUCT_EXT = new Set([".vue", ".html", ".htm", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
67
|
-
/** audit:「样式」扩展名:只命中样式 = 仅样式定义、无结构挂载 */
|
|
68
|
-
const STYLE_EXT = new Set([".scss", ".sass", ".css", ".less", ".styl"]);
|
|
69
|
-
/** audit:设计层文件特征(只命中设计层 = 无实际挂载) */
|
|
70
|
-
const DESIGN_LAYER_RE = /design-shell|design-layer|design\.scss/i;
|
|
71
|
-
/** audit:组件库运行时渲染的裸类名(无 el- 前缀,但不由业务源码挂载) */
|
|
72
|
-
const BUILTIN_BARE_CLASSES = new Set(["cell", "cell-group", "row", "header", "body", "caret-wrapper", "sort-caret", "ascending", "descending", "expanded-cell", "el-tooltip", "text", "value", "label"]);
|
|
73
|
-
|
|
74
|
-
main();
|
|
75
|
-
|
|
76
|
-
function main() {
|
|
77
|
-
const [cmd, ...args] = process.argv.slice(2);
|
|
78
|
-
|
|
79
|
-
// 每个命令先构建 + 校验(保证在任何操作前映射数据可信)
|
|
80
|
-
const dataset = buildMappingDataset();
|
|
81
|
-
const { errors, warnings } = validateMappingDataset(dataset);
|
|
82
|
-
if (errors.length > 0) {
|
|
83
|
-
console.error(`✖ 映射表校验失败(${errors.length} 项):`);
|
|
84
|
-
for (const e of errors)
|
|
85
|
-
console.error(` ✘ ${e}`);
|
|
86
|
-
process.exit(1);
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
switch (cmd) {
|
|
90
|
-
case "build":
|
|
91
|
-
return cmdBuild(args, warnings);
|
|
92
|
-
case "match":
|
|
93
|
-
return cmdMatch(args, dataset);
|
|
94
|
-
case "list":
|
|
95
|
-
return cmdList(args, dataset);
|
|
96
|
-
case "query":
|
|
97
|
-
return cmdQuery(args, dataset);
|
|
98
|
-
case "audit":
|
|
99
|
-
return cmdAudit(args, dataset);
|
|
100
|
-
case "verify":
|
|
101
|
-
return cmdVerify(args, dataset);
|
|
102
|
-
case "registry":
|
|
103
|
-
return cmdRegistry(args, dataset);
|
|
104
|
-
default:
|
|
105
|
-
printHelp();
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function printHelp() {
|
|
110
|
-
console.log(`vue3-temp-skill mapping parser
|
|
111
|
-
|
|
112
|
-
用法:
|
|
113
|
-
node parser/index.js build [--dry] 校验映射表并生成 dist/ 产物
|
|
114
|
-
node parser/index.js match <changeset.json...> 对设计变更集执行三级匹配(可多文件;mode 唯一合法值 restore——G8 模式闸强制 oracle.mockup + coverage.registry 齐备,显式 design 直接拒绝)
|
|
115
|
-
主题身份:changeset.theme(优先)→ global.brand_primary(回退);
|
|
116
|
-
基线图色板:图内 data-mockup-theme(优先)→ 文件名主题词(回退)。
|
|
117
|
-
二者不一致时须声明 oracle.theme_mapping(只做色值归一,不减声明)。
|
|
118
|
-
node parser/index.js match <cs.json> --registry <reg.json>
|
|
119
|
-
匹配 + 登记表覆盖率当场重算(子项级细闸,有缺口 exit 1)
|
|
120
|
-
node parser/index.js match --batch <dir> 项目级批量匹配:目录下全部变更集 + 跨文件汇总
|
|
121
|
-
node parser/index.js audit --project <dir> [<cs.json…>] 挂载点存在性 + 死通道静态审计(需目标项目源码)
|
|
122
|
-
node parser/index.js verify --oracle <o.json> --actual <a.json> [--sids a,b]
|
|
123
|
-
渲染态逐点验收:computed style 期望 vs 实测 diff(oracle.absent 存在性闸 G12 + residual_accepted 残差豁免 + oracle.states 状态断言闸 G14 + 孤儿点位对账闸 G15)
|
|
124
|
-
node parser/index.js verify --oracle <o.json> --actual <a.json> --baseline <snap.json> [--snapshot <out.json>]
|
|
125
|
-
快照回归:与上轮快照比对基线漂移(上轮命中本轮退步 = 红);--snapshot 导出本轮快照
|
|
126
|
-
node parser/index.js verify --oracle <o.json> --resolve [--sids a,b]
|
|
127
|
-
实测端定位计划:sid → 词典 selector(项目页不打 data-sid 时按此抽取)
|
|
128
|
-
node parser/index.js verify … --from-theme 钴蓝 --to-theme 橘橙
|
|
129
|
-
同构异色归一:按语义槽位把基线图色值归一为本主题色值再比(配合 oracle.theme_mapping)
|
|
130
|
-
node parser/index.js registry <registry.json> [--verbose]
|
|
131
|
-
登记表通道核验:登记项 sid 是否在词典、dims 是否有落点
|
|
132
|
-
node parser/index.js list [--scene admin] [--verbose] 列出全部语义 ID(--verbose 含字段与形态,供下发词汇约束)
|
|
133
|
-
node parser/index.js query <semantic.id> 查询单个 ID 的映射详情
|
|
134
|
-
node parser/watch.js 开发态监听 mapping/ 变更自动重编译
|
|
135
|
-
node parser/override.js <overrides.json> 业务级映射覆写调试(API 见 parser/override.js)`);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ---- build ----
|
|
139
|
-
function cmdBuild(args, warnings) {
|
|
140
|
-
const dry = args.includes("--dry");
|
|
141
|
-
for (const w of warnings)
|
|
142
|
-
console.warn(` ⚠ ${w}`);
|
|
143
|
-
const { written, tokenCount } = generateAll(path.join(ROOT, "dist"), dry);
|
|
144
|
-
console.log(`✔ 映射表校验通过(token ${tokenCount} 项)`);
|
|
145
|
-
if (dry)
|
|
146
|
-
console.log(`[dry-run] 将生成:${written.join("、")}`);
|
|
147
|
-
else
|
|
148
|
-
console.log(`✔ 产物已生成 → dist/:${written.join("、")}`);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// ---- match:三级匹配 ----
|
|
152
|
-
function cmdMatch(args, dataset) {
|
|
153
|
-
// --registry <path>:登记表路径(子项级覆盖率细闸)。缺省时回落到 changeset.coverage.registry / oracle.registry。
|
|
154
|
-
const regIdx = args.indexOf("--registry");
|
|
155
|
-
const registryArg = regIdx >= 0 ? args[regIdx + 1] : null;
|
|
156
|
-
const consumed = new Set(registryArg ? [registryArg] : []);
|
|
157
|
-
const files = args.filter(a => !a.startsWith("--") && !consumed.has(a));
|
|
158
|
-
if (files.length === 0) {
|
|
159
|
-
console.error("✖ 缺少变更集文件参数:node parser/index.js match <changeset.json...|--batch <dir>>");
|
|
160
|
-
process.exit(1);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// --batch 目录模式:批量读取目录下全部变更集(设计侧项目级模式产出
|
|
164
|
-
// 「一份全局变更集 + 各页面变更集」,项目侧需一次性匹配)
|
|
165
|
-
let targets = [];
|
|
166
|
-
if (args.includes("--batch")) {
|
|
167
|
-
const dir = files[0];
|
|
168
|
-
const stat = statSync(path.resolve(dir));
|
|
169
|
-
if (!stat.isDirectory()) {
|
|
170
|
-
console.error(`✖ --batch 参数应为目录:${dir}`);
|
|
171
|
-
process.exit(1);
|
|
172
|
-
}
|
|
173
|
-
targets = readdirSync(path.resolve(dir))
|
|
174
|
-
.filter(f => f.endsWith(".json") && !f.startsWith("_"))
|
|
175
|
-
.sort()
|
|
176
|
-
.map(f => path.join(path.resolve(dir), f));
|
|
177
|
-
if (targets.length === 0) {
|
|
178
|
-
console.error(`✖ 目录内无变更集 JSON:${dir}`);
|
|
179
|
-
process.exit(1);
|
|
180
|
-
}
|
|
181
|
-
console.log(`── 批量模式:${targets.length} 份变更集(${dir})──`);
|
|
182
|
-
}
|
|
183
|
-
else {
|
|
184
|
-
targets = files.map(f => path.resolve(f));
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
// 跨文件汇总(项目级模式对账用)
|
|
188
|
-
const batchSummary = { files: 0, l1: 0, l2: 0, l3: 0, pendingIds: [], fieldPending: [], invalid: [], registryGaps: 0 };
|
|
189
|
-
|
|
190
|
-
for (const target of targets) {
|
|
191
|
-
batchSummary.files++;
|
|
192
|
-
if (targets.length > 1)
|
|
193
|
-
console.log(`\n════ 变更集 ${batchSummary.files}/${targets.length}:${path.basename(target)} ════`);
|
|
194
|
-
matchOne(target, dataset, batchSummary, { registryArg, filePath: target, verbose: args.includes("--verbose") });
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// 任一文件被拒(schema/版本/禁词校验失败)→ 进程退出码 1(v1.0 修正:单文件模式此前误返回 0)
|
|
198
|
-
if (batchSummary.invalid.length > 0)
|
|
199
|
-
process.exitCode = 1;
|
|
200
|
-
|
|
201
|
-
// 登记表覆盖率有缺口(子项被静默丢失)→ 也是「交付不完整」,退出码 1
|
|
202
|
-
if (batchSummary.registryGaps > 0)
|
|
203
|
-
process.exitCode = 1;
|
|
204
|
-
|
|
205
|
-
// 项目级汇总输出
|
|
206
|
-
if (targets.length > 1) {
|
|
207
|
-
console.log(`\n════════ 项目级汇总(${targets.length} 份变更集)════════`);
|
|
208
|
-
console.log(`L1=${batchSummary.l1}(100%) L2=${batchSummary.l2}(85-90%) L3=${batchSummary.l3}(70-80%,补映射后 100%)`);
|
|
209
|
-
if (batchSummary.invalid.length > 0) {
|
|
210
|
-
console.log(`✖ 校验失败文件:${batchSummary.invalid.join("、")}`);
|
|
211
|
-
}
|
|
212
|
-
if (batchSummary.pendingIds.length > 0) {
|
|
213
|
-
const uniq = [...new Set(batchSummary.pendingIds)];
|
|
214
|
-
console.log(`缺口清单 · 新增点位(去重 ${uniq.length},须当场落地 + 回写临时映射):`);
|
|
215
|
-
for (const id of uniq)
|
|
216
|
-
console.log(` - ${id}`);
|
|
217
|
-
}
|
|
218
|
-
if (batchSummary.fieldPending.length > 0) {
|
|
219
|
-
console.log(`缺口清单 · 字段级(优先当场落设计层,仅意图缺口回流):`);
|
|
220
|
-
for (const f of batchSummary.fieldPending)
|
|
221
|
-
console.log(` - ${f.id}: ${f.fields.join("、")}`);
|
|
222
|
-
}
|
|
223
|
-
if (batchSummary.registryGaps > 0)
|
|
224
|
-
console.log(`✖ 登记表覆盖率缺口合计 ${batchSummary.registryGaps} 条(子项级欠声明,详见各文件段落)`);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
/** 单份变更集匹配(cmdMatch 内部复用) */
|
|
229
|
-
function matchOne(file, dataset, batchSummary, opts = {}) {
|
|
230
|
-
let changeset;
|
|
231
|
-
try {
|
|
232
|
-
changeset = JSON.parse(readFileSync(file, "utf-8"));
|
|
233
|
-
}
|
|
234
|
-
catch (e) {
|
|
235
|
-
console.error(`✖ 变更集读取/解析失败:${e.message}`);
|
|
236
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
237
|
-
if (!batchSummary)
|
|
238
|
-
process.exit(1);
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// 协议校验(canonical:design.changeset/1.0,见 project-ui-design references/changeset-schema.md v1.0;
|
|
243
|
-
// 兼容历史值 design-changeset/v1,仅告警不阻断)
|
|
244
|
-
const SUPPORTED_SCHEMAS = ["design.changeset/1.0", "design-changeset/v1"];
|
|
245
|
-
if (!SUPPORTED_SCHEMAS.includes(changeset.schema)) {
|
|
246
|
-
console.error(`✖ schema 应为 "design.changeset/1.0",实际 "${changeset.schema ?? "缺失"}"`);
|
|
247
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
248
|
-
if (!batchSummary)
|
|
249
|
-
process.exit(1);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
if (changeset.schema === "design-changeset/v1") {
|
|
253
|
-
console.warn(` ⚠ schema "${changeset.schema}" 为历史值,建议设计侧升级为 "design.changeset/1.0"`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// G8 模式闸:变更集顶层 mode 声明交付模式。
|
|
257
|
-
// mode=restore(场景还原)→ oracle + coverage 强制齐备,缺失 = exit 1。
|
|
258
|
-
// 事故背景:还原项目后重跑技能,产出「只换主题色」的轻量变更集却以还原之名交付——
|
|
259
|
-
// 186 条登记表、oracle 基线、coverage 记账全部缺席,parser 只打提示不阻断,一路绿灯
|
|
260
|
-
// 落地后用户看到「只变了主题、布局细节零变化」。教训文档化 ≠ 机器闸门:
|
|
261
|
-
// 「场景还原应给」的软提示升级为「mode=restore 必须给」的硬闸。
|
|
262
|
-
// 强化一:oracle/coverage 须有「芯」——oracle.mockup 与 coverage.registry 非空,
|
|
263
|
-
// 堵住空壳伪基线(有对象无内容)继续蒙混。
|
|
264
|
-
// 强化二:降级通道废除——mode 唯一合法值 restore,显式 "design" 直接拒绝。
|
|
265
|
-
// mode 缺省按旧协议兼容(legacy 变更集无 scheme/page_types,由下方 G8b 基线命中闸兜底)。
|
|
266
|
-
const mode = changeset.mode === undefined || changeset.mode === null ? null : changeset.mode;
|
|
267
|
-
if (mode === "design") {
|
|
268
|
-
console.error(`✖ mode="design" 不是合法交付模式:还原任务的目标是把设计 100% 落地到项目——环境受限 / 覆盖不足 / 意图不明时唯一正确动作是停下向用户多轮询问确认,不得缩水交付;用户显式只要小改动时,不产出还原变更集(按普通需求直接修改)。基线三件套(oracle.mockup + coverage.registry)是还原交付的固有组成,任何 mode 形态都不得移除。`);
|
|
269
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
270
|
-
if (!batchSummary)
|
|
271
|
-
process.exit(1);
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
if (mode !== null && mode !== "restore") {
|
|
275
|
-
console.error(`✖ mode 应为 "restore"(唯一合法值),实际 "${mode}"`);
|
|
276
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
277
|
-
if (!batchSummary)
|
|
278
|
-
process.exit(1);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
if (mode === "restore") {
|
|
282
|
-
// G8 强化:不只看「有没有 oracle/coverage 这两个对象」,
|
|
283
|
-
// 还要看**芯**在不在——oracle.mockup 与 coverage.registry 是基线的两根承重梁:
|
|
284
|
-
// · oracle.mockup 空 → 效果图无从抽取,还原度不可证明;
|
|
285
|
-
// · coverage.registry 空 → A/C 无法以登记表为权威当场重算,自报数字即可蒙混,「完备性可数」形同虚设。
|
|
286
|
-
// 空壳写法({ oracle:{sids:[]}, coverage:{applicable:0,covered:0} })过去能一路绿灯,现予拦截。
|
|
287
|
-
const hasStr = v => typeof v === "string" && v.trim().length > 0;
|
|
288
|
-
const missing = [];
|
|
289
|
-
if (!hasStr(changeset.scheme))
|
|
290
|
-
missing.push("scheme(设计方案标识——缺它无法定位「方案 × 场景」的清单 / 登记表,还原范围失去锚点)");
|
|
291
|
-
if (!changeset.oracle || typeof changeset.oracle !== "object")
|
|
292
|
-
missing.push("oracle(验收基线:mockup + sids,效果图即 1:1 的验收凭证)");
|
|
293
|
-
else if (!hasStr(changeset.oracle.mockup))
|
|
294
|
-
missing.push("oracle.mockup(效果图路径——空则基线抽不出来,还原度无从证明)");
|
|
295
|
-
if (!changeset.coverage || typeof changeset.coverage !== "object")
|
|
296
|
-
missing.push("coverage(登记表覆盖率记账:registry + registrable/applicable/covered + gaps)");
|
|
297
|
-
else if (!hasStr(changeset.coverage.registry))
|
|
298
|
-
missing.push("coverage.registry(登记表路径——空则 parser 无法以登记表为权威重算 A/C,自报数字形同虚设)");
|
|
299
|
-
if (missing.length > 0) {
|
|
300
|
-
console.error(`\n✖ G8 模式闸:mode=restore(场景还原)但缺少 ${missing.join(" 与 ")} → 拒绝执行。`);
|
|
301
|
-
console.error(` 场景还原的完备性以登记表覆盖率为分母、以 oracle 为验收基线——缺任一项即「伪还原变更集」:`);
|
|
302
|
-
console.error(` 只换主题色也能一路绿灯落地,但布局细节点位从未被声明,还原度不可证明。`);
|
|
303
|
-
console.error(` 处置:补齐后重跑——mode=design 不是合法出口;范围确需缩小时停下向用户多轮询问确认。`);
|
|
304
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
305
|
-
if (!batchSummary)
|
|
306
|
-
process.exit(1);
|
|
307
|
-
return;
|
|
308
|
-
}
|
|
309
|
-
console.log(` ✔ G8 模式闸:mode=restore,oracle + coverage 齐备(基线三件套完整)`);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// G8 强化:基线命中 ⇒ 不得以缺 mode / 降级形态移除基线三件套。
|
|
313
|
-
// 事故形态:执行会话把「环境无浏览器」读成降级许可,以 6 条品牌色变更交差,
|
|
314
|
-
// notes 自述「未强制绑定基线登记表与 oracle.json」。显式 mode=design 已被上方硬拒,
|
|
315
|
-
// 本闸兜底「缺 mode 但带 scheme + page_types」的现代形态:三件套缺一即拒。
|
|
316
|
-
if (mode !== "restore") {
|
|
317
|
-
const covG8b = changeset.coverage && typeof changeset.coverage === "object" ? changeset.coverage : {};
|
|
318
|
-
const pageTypesG8b = Array.isArray(covG8b.page_types) ? covG8b.page_types : [];
|
|
319
|
-
const hasCoreG8b =
|
|
320
|
-
typeof changeset.oracle?.mockup === "string" && changeset.oracle.mockup.trim().length > 0 &&
|
|
321
|
-
typeof covG8b.registry === "string" && covG8b.registry.trim().length > 0;
|
|
322
|
-
if (pageTypesG8b.length && changeset.scheme && !hasCoreG8b) {
|
|
323
|
-
// 方案注册表(含 baseline 登记)在设计侧技能内;跨技能同机部署时按兄弟目录解析
|
|
324
|
-
const schemeRegistryG8b = path.resolve(__dirname, "..", "..", "project-ui-design", "references", "page-specs", "styles", "registry.json");
|
|
325
|
-
let baselineHitsG8b = [];
|
|
326
|
-
try {
|
|
327
|
-
const srG8b = JSON.parse(readFileSync(schemeRegistryG8b, "utf-8"));
|
|
328
|
-
for (const schG8b of srG8b.schemes || []) {
|
|
329
|
-
if (schG8b.id !== changeset.scheme) continue;
|
|
330
|
-
for (const [ptG8b, bG8b] of Object.entries(schG8b.baseline || {})) {
|
|
331
|
-
if (ptG8b.startsWith("$") || !bG8b || typeof bG8b !== "object" || !bG8b.mockup) continue;
|
|
332
|
-
if (pageTypesG8b.includes(ptG8b)) baselineHitsG8b.push(ptG8b);
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
} catch { /* 设计侧技能未检出(非同机部署)时跳过——设计侧 D15 为权威闸 */ }
|
|
336
|
-
if (baselineHitsG8b.length) {
|
|
337
|
-
console.error(`\n✖ G8 模式闸(基线命中判定):还原目标存在注册基线(scheme="${changeset.scheme}" × scene="${changeset.scene || "?"}" × 页型 ${baselineHitsG8b.join("/")}),却以 mode=${mode || "(缺省)"} 且缺基线三件套交付 → 拒绝执行。`);
|
|
338
|
-
console.error(` 环境受限 / 覆盖不足 / 意图不明时唯一正确动作是停下向用户多轮询问确认,基线三件套(oracle.mockup + coverage.registry)照交;`);
|
|
339
|
-
console.error(` 严禁借任何 mode 形态把还原缩水成主题色变更。`);
|
|
340
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
341
|
-
if (!batchSummary)
|
|
342
|
-
process.exit(1);
|
|
343
|
-
return;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
// G13 页面绑定闸:效果图页面是设计角色坐标系,不是页面增量。
|
|
349
|
-
// 事故背景:效果图演示页「运单管理」被误落地为项目新菜单/新页面——效果图标题只是
|
|
350
|
-
// 设计侧为承载设计语言虚构的演示名,落地动作恒为「映射已有页面 + 在其上同步结构与样式」,
|
|
351
|
-
// 「图上有这个页面」不构成新增依据。
|
|
352
|
-
// 机检范围(diff 级「不得出现新路由/菜单/页面文件」由第 5 步合规闸人工+AI 对账):
|
|
353
|
-
// ① page_bindings 结构校验(数组、mockup/target 必填);
|
|
354
|
-
// ② target:"new" 必须 notes 申报——未申报 = 效果图演示页被静默实体化,exit 1;
|
|
355
|
-
// ③ 每次输出页面绑定声明,让施工 AI 与交付说明都有映射口径可依。
|
|
356
|
-
const DEFAULT_BINDING_POLICY = "效果图页面默认映射已有页面(设计角色坐标系≠页面增量):禁止新增页面/路由/菜单,落地=在已有页面上同步结构与样式";
|
|
357
|
-
if (changeset.page_bindings !== undefined && changeset.page_bindings !== null) {
|
|
358
|
-
if (!Array.isArray(changeset.page_bindings)) {
|
|
359
|
-
console.error(`✖ G13 页面绑定闸:page_bindings 应为数组({ mockup, target, note }),实际 ${typeof changeset.page_bindings} → 拒绝执行。`);
|
|
360
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
361
|
-
if (!batchSummary)
|
|
362
|
-
process.exit(1);
|
|
363
|
-
return;
|
|
364
|
-
}
|
|
365
|
-
console.log(`\n=== 页面绑定声明(G13 · ${changeset.page_bindings.length} 条)===`);
|
|
366
|
-
for (const [i, b] of changeset.page_bindings.entries()) {
|
|
367
|
-
if (!b || typeof b !== "object" || !b.mockup || !b.target) {
|
|
368
|
-
console.error(`✖ G13 页面绑定闸:page_bindings[${i}] 缺 mockup 或 target → 拒绝执行。`);
|
|
369
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
370
|
-
if (!batchSummary)
|
|
371
|
-
process.exit(1);
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
if (b.target === "new") {
|
|
375
|
-
const notes = typeof changeset.notes === "string" ? changeset.notes : "";
|
|
376
|
-
if (!notes.includes("新增页面") && !notes.includes("new 页面") && !notes.includes("target:\"new\"")) {
|
|
377
|
-
console.error(`✖ G13 页面绑定闸:${b.mockup} 声明 target:"new"(新增业务页面)但 notes 未申报 → 拒绝执行。`);
|
|
378
|
-
console.error(` 处置:确需落成新业务页面时,在 notes 显式写明「新增页面:<mockup> → <路由名>,理由…」后重跑;`);
|
|
379
|
-
console.error(` 否则该效果图页面按默认口径映射已有页面落地。`);
|
|
380
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
381
|
-
if (!batchSummary)
|
|
382
|
-
process.exit(1);
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
console.log(` - ${b.mockup} → 新增页面(notes 已申报)`);
|
|
386
|
-
}
|
|
387
|
-
else {
|
|
388
|
-
console.log(` - ${b.mockup} → 已有页面 ${b.target}${b.note ? `(${b.note})` : ""}`);
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
else {
|
|
393
|
-
console.log(` ✔ G13 页面绑定闸:page_bindings 缺省 → ${DEFAULT_BINDING_POLICY}`);
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// 版本校验(mapping_version 缺失或不匹配 → 拒绝。版本恒定 1.0.0,此校验为破坏性升级保险丝:仅双侧同步升版时才会拦截)
|
|
397
|
-
const versions = new Set(
|
|
398
|
-
Object.values(dataset.files).map(d => d.meta?.version).filter(Boolean),
|
|
399
|
-
);
|
|
400
|
-
const required = changeset.mapping_version;
|
|
401
|
-
if (!required) {
|
|
402
|
-
console.error(`✖ 缺少 mapping_version(v1.0 协议必填;恒填 "1.0.0"——词典版本恒定策略)`);
|
|
403
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
404
|
-
if (!batchSummary)
|
|
405
|
-
process.exit(1);
|
|
406
|
-
return;
|
|
407
|
-
}
|
|
408
|
-
if (!versions.has(required)) {
|
|
409
|
-
console.error(`✖ 变更集依赖映射表版本 ${required},当前映射集版本为 ${[...versions].join("/")},不兼容 → 拒绝执行(正常应恒填 "1.0.0";出现此错误通常意味着词典已发生破坏性协议升级,请与项目侧核对)`);
|
|
410
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
411
|
-
if (!batchSummary)
|
|
412
|
-
process.exit(1);
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// 禁词校验(v1.0:双侧文档承诺「协议违规 = parser 阻断」,此处实现)
|
|
417
|
-
const banned = checkBannedWords(changeset);
|
|
418
|
-
if (banned.length > 0) {
|
|
419
|
-
console.error(`✖ 协议违规(禁词出现即阻断,共 ${banned.length} 处):`);
|
|
420
|
-
for (const b of banned)
|
|
421
|
-
console.error(` ✘ ${b}`);
|
|
422
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
423
|
-
if (!batchSummary)
|
|
424
|
-
process.exit(1);
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
const index = buildMergedIndex(dataset);
|
|
429
|
-
const valueDict = buildSemanticValueIndex(dataset);
|
|
430
|
-
const plan = [];
|
|
431
|
-
const pending = [];
|
|
432
|
-
const fieldPending = []; // 字段级待补充:ID 命中但 style 字段两层映射均未命中
|
|
433
|
-
const gateViolations = []; // 词典驱动硬闸:suppress 收口(G1)+ 几何语境(G5),违规 = 协议违规同级阻断
|
|
434
|
-
|
|
435
|
-
// global 令牌翻译(design-apply 第 3 步:走 basic/ 词典,主色另需 setEpThemeColor 链路)
|
|
436
|
-
if (changeset.global && Object.keys(changeset.global).length > 0) {
|
|
437
|
-
const tokenDict = buildTokenIndex(dataset);
|
|
438
|
-
console.log(`\n=== global 令牌翻译(basic/ 词典 + 语义值译表)===`);
|
|
439
|
-
const gPending = [];
|
|
440
|
-
for (const [field, value] of Object.entries(changeset.global)) {
|
|
441
|
-
const def = tokenDict[field];
|
|
442
|
-
const resolved = resolveSemanticValue(value, valueDict);
|
|
443
|
-
if (def)
|
|
444
|
-
console.log(` . ${field}: ${value} → 令牌 ${def.token}${resolved ? `(精确值 ${resolved})` : ""}${def.hook ? `(钩子 ${def.hook})` : ""} @ ${def.file}`);
|
|
445
|
-
else
|
|
446
|
-
gPending.push(field);
|
|
447
|
-
if (!resolved && def && !QUALITATIVE_RE.test(String(value).trim()) && !/^(#|rgb|hsl)/.test(String(value).trim()))
|
|
448
|
-
console.log(` ⚠ 值 "${value}" 未命中语义值译表且非 hex,施工前需确认精确值`);
|
|
449
|
-
}
|
|
450
|
-
if (gPending.length > 0) {
|
|
451
|
-
console.error(`\n✖ global 令牌缺口(共 ${gPending.length} 处 → 拒绝执行):词典 basic/ 无此令牌字段 —— ${gPending.join("、")}`);
|
|
452
|
-
console.error(` 处置:先在 mapping/basic/ 令牌词典补齐该字段的 css_vars/hook 映射,再重跑;主题基座令牌不走「未匹配即跳过」通道。`);
|
|
453
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
454
|
-
if (!batchSummary)
|
|
455
|
-
process.exit(1);
|
|
456
|
-
return;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// layout_type 枚举校验(shell-geometry layout_modes)
|
|
461
|
-
// 兼容读取:设计侧 v0.2 曾误用顶层 `layout` 键——读出来并告警,防止布局意图静默丢失
|
|
462
|
-
let layoutValue = changeset.layout_type;
|
|
463
|
-
if (!layoutValue && changeset.layout !== undefined) {
|
|
464
|
-
layoutValue = changeset.layout;
|
|
465
|
-
console.warn(` ⚠ 顶层字段名 "layout" 已废弃(v1.0 协议为 "layout_type"),本次按值 "${changeset.layout}" 兼容处理;请上游更正字段名`);
|
|
466
|
-
}
|
|
467
|
-
if (layoutValue) {
|
|
468
|
-
const layoutModes = collectLayoutModes(dataset);
|
|
469
|
-
if (layoutModes.size > 0 && !layoutModes.has(layoutValue)) {
|
|
470
|
-
console.warn(` ⚠ layout_type "${layoutValue}" 不在标准枚举 [${[...layoutModes].join("/")}] 内 → 按变体/定制布局流程处理(design-apply §4.3)`);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
for (const change of changeset.changes || []) {
|
|
475
|
-
const id = change.id;
|
|
476
|
-
// 一级:完整命中
|
|
477
|
-
if (index[id]) {
|
|
478
|
-
plan.push({ level: 1, id, entry: index[id], change });
|
|
479
|
-
continue;
|
|
480
|
-
}
|
|
481
|
-
// 二级:父模块命中
|
|
482
|
-
const parentId = id.split(".").slice(0, -1).join(".");
|
|
483
|
-
if (parentId && index[parentId]) {
|
|
484
|
-
plan.push({ level: 2, id, parent: parentId, entry: index[parentId], change });
|
|
485
|
-
continue;
|
|
486
|
-
}
|
|
487
|
-
// 三级:全新模块
|
|
488
|
-
plan.push({ level: 3, id, change });
|
|
489
|
-
pending.push(id);
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// pending[] 解析(v1.0:设计侧新 ID 通道,function/parent 供 L3 施工与临时映射生成)
|
|
493
|
-
if (Array.isArray(changeset.pending) && changeset.pending.length > 0) {
|
|
494
|
-
console.log(`\n=== pending 新增 ID(${changeset.pending.length} 条,归入 L3 施工,携带功能描述)===`);
|
|
495
|
-
for (const p of changeset.pending) {
|
|
496
|
-
if (!p || !p.proposed_id)
|
|
497
|
-
continue;
|
|
498
|
-
const hit = pending.includes(p.proposed_id);
|
|
499
|
-
console.log(` - ${p.proposed_id}${hit ? "" : "(注意:未出现在 changes[] 中,本次不落地)"}`);
|
|
500
|
-
console.log(` 功能:${p.function || "(未提供)"};父模块:${p.parent || "(未提供)"}`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
// oracle 验收基线(:设计侧给的效果图 + 待比对点位;项目侧落地后跑 verify 逐点 diff)
|
|
505
|
-
// mockup 相对路径解析以「变更集同目录」为第一基准(协议约定 oracle 产物与变更集同目录),
|
|
506
|
-
// 其次 cwd / 技能根(历史行为兼容)。
|
|
507
|
-
// G11 oracle 基线闸:restore 模式下返回值非 0 = 阻断(与 G8 同级)
|
|
508
|
-
const oracleGaps = checkOracle(changeset, plan, valueDict, { strict: mode === "restore", filePath: file }) || 0;
|
|
509
|
-
checkCoverage(changeset, { strict: mode === "restore" });
|
|
510
|
-
// 登记表覆盖率(子项级细闸):以登记表为权威、当场重算 A/C,设计侧自报的 coverage 不参与
|
|
511
|
-
// strict(mode=restore)同时驱动 G9 登记表身份闸与 G10 dims 兑现闸
|
|
512
|
-
const regGaps = checkRegistryCoverage(changeset, plan, { ...opts, strict: mode === "restore" });
|
|
513
|
-
if (batchSummary)
|
|
514
|
-
batchSummary.registryGaps += regGaps + oracleGaps;
|
|
515
|
-
else if (regGaps + oracleGaps > 0)
|
|
516
|
-
process.exitCode = 1;
|
|
517
|
-
|
|
518
|
-
// 输出转换计划
|
|
519
|
-
console.log(`\n=== 三级匹配结果(scene=${changeset.scene},${plan.length} 条)===`);
|
|
520
|
-
for (const p of plan) {
|
|
521
|
-
if (p.level === 1) {
|
|
522
|
-
console.log(`\n[L1 完整命中] ${p.id}`);
|
|
523
|
-
checkFormEnum(p.entry, p.change, gateViolations, p.id);
|
|
524
|
-
checkSuppress(p.change, p.entry, p.id, gateViolations);
|
|
525
|
-
checkGeometryContext(p.change, p.entry, p.id, gateViolations);
|
|
526
|
-
checkTokenChainContext(p.change, p.entry, p.id, gateViolations);
|
|
527
|
-
checkInnerOnlyProps(p.change, p.entry, p.id, gateViolations);
|
|
528
|
-
checkFieldForms(p.change, p.entry, p.id, gateViolations);
|
|
529
|
-
printApplyPlan(p.entry, p.change, p.id, index);
|
|
530
|
-
printStyleTranslation(p.entry, p.change, p.id, fieldPending, valueDict);
|
|
531
|
-
}
|
|
532
|
-
else if (p.level === 2) {
|
|
533
|
-
console.log(`\n[L2 父模块命中] ${p.id} → 继承 ${p.parent}(${p.entry.selector ?? p.entry.component ?? "?"})`);
|
|
534
|
-
console.log(` 结构:定位父容器按相对位置增量插入;样式:复用父模块映射 + 全局组件映射`);
|
|
535
|
-
checkFormEnum(p.entry, p.change, gateViolations, p.id);
|
|
536
|
-
checkSuppress(p.change, p.entry, p.id, gateViolations);
|
|
537
|
-
checkGeometryContext(p.change, p.entry, p.id, gateViolations);
|
|
538
|
-
checkTokenChainContext(p.change, p.entry, p.id, gateViolations);
|
|
539
|
-
checkFieldForms(p.change, p.entry, p.id, gateViolations);
|
|
540
|
-
printApplyPlan(p.entry, p.change, `${p.id}(继承 ${p.parent})`, index);
|
|
541
|
-
printStyleTranslation(p.entry, p.change, `${p.id}(继承 ${p.parent})`, fieldPending, valueDict);
|
|
542
|
-
}
|
|
543
|
-
else {
|
|
544
|
-
console.log(`\n[L3 全新模块] ${p.id} → 按场景规范生成默认结构,复用全局原子层映射`);
|
|
545
|
-
printStructureOps(p.change);
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
if (pending.length > 0) {
|
|
550
|
-
console.log(`\n=== 缺口清单 · 新增点位(${pending.length})→ exit 1(词典缺 ID = 交付不完整)===`);
|
|
551
|
-
console.log(` 处置:当场按 L3 落地并回写临时映射后重跑(词典缺 ID 属能力缺口,不是跳过理由);仅设计侧未给意图才回流。`);
|
|
552
|
-
for (const id of pending)
|
|
553
|
-
console.log(` - ${id}`);
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
if (fieldPending.length > 0) {
|
|
557
|
-
console.log(`\n=== 缺口清单 · 字段级(${fieldPending.length},ID 命中但 style 字段两层映射均未命中)→ exit 1(字段缺口 = 细节必丢)===`);
|
|
558
|
-
console.log(` 处置:在条目作用域内生成自定义规则落设计层文件并申报后重跑;仅「值非法 / 无法定位 / 设计侧未给值」才回流设计侧。`);
|
|
559
|
-
for (const f of fieldPending)
|
|
560
|
-
console.log(` - ${f.id}: ${f.fields.join("、")}`);
|
|
561
|
-
}
|
|
562
|
-
|
|
563
|
-
// 缺口清单 = 交付不完整,与词典硬闸同权重:新增点位 / 字段级缺口存在即 exit 1。
|
|
564
|
-
// 「点位在、细节丢」正是字段级缺口被静默放行的机制成因——缺口只有两种合法出口:补齐,或回流设计侧。
|
|
565
|
-
const gapCount = pending.length + fieldPending.length;
|
|
566
|
-
if (gapCount > 0) {
|
|
567
|
-
if (batchSummary) {
|
|
568
|
-
batchSummary.pendingIds.push(...pending);
|
|
569
|
-
batchSummary.fieldPending.push(...fieldPending);
|
|
570
|
-
batchSummary.invalid.push(path.basename(file));
|
|
571
|
-
return;
|
|
572
|
-
}
|
|
573
|
-
process.exit(1);
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
const l1 = plan.filter(p => p.level === 1).length;
|
|
577
|
-
const l2 = plan.filter(p => p.level === 2).length;
|
|
578
|
-
const l3 = plan.filter(p => p.level === 3).length;
|
|
579
|
-
console.log(`\n汇总:L1=${l1}(还原度 100%) L2=${l2}(85-90%) L3=${l3}(70-80%,补映射后可达 100%)`);
|
|
580
|
-
|
|
581
|
-
// 词典驱动硬闸(suppress 收口 / 几何语境):违规 = 交付不完整,与禁词同级阻断
|
|
582
|
-
if (gateViolations.length > 0) {
|
|
583
|
-
console.error(`\n✖ 词典硬闸违规(共 ${gateViolations.length} 处 → 拒绝执行):`);
|
|
584
|
-
for (const v of gateViolations)
|
|
585
|
-
console.error(` ✘ ${v}`);
|
|
586
|
-
console.error(` 处置:回变更集补齐 suppress / suppress_inner / layer 声明后重跑;判据均来自词典条目自身,不接受「我观感上对了」。`);
|
|
587
|
-
batchSummary?.invalid.push(path.basename(file));
|
|
588
|
-
if (!batchSummary)
|
|
589
|
-
process.exit(1);
|
|
590
|
-
return;
|
|
591
|
-
}
|
|
592
|
-
|
|
593
|
-
// 汇入跨文件统计(项目级批量模式)
|
|
594
|
-
if (batchSummary) {
|
|
595
|
-
batchSummary.l1 += l1;
|
|
596
|
-
batchSummary.l2 += l2;
|
|
597
|
-
batchSummary.l3 += l3;
|
|
598
|
-
batchSummary.pendingIds.push(...pending);
|
|
599
|
-
batchSummary.fieldPending.push(...fieldPending);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/** 禁词校验(v1.0 守门员):DOM/CSS/技术栈词与 hex 位置违规 → 违规描述数组(空 = 通过)
|
|
604
|
-
* 口径与 project-ui-design changeset-schema.md §禁词校验 一致:
|
|
605
|
-
* - hex 仅允许出现在 changes[].style 值与 notes 中
|
|
606
|
-
* - id/position/form/visible 等字段出现 hex、CSS 变量名(--el-* / --vts-* / --*)、
|
|
607
|
-
* 选择器写法(.xxx / #xxx 开头)、伪类(::v-deep / :deep())即阻断
|
|
608
|
-
* - 语义 ID 命名合法性(小写下划线 2~4 段) */
|
|
609
|
-
function checkBannedWords(changeset) {
|
|
610
|
-
const violations = [];
|
|
611
|
-
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/;
|
|
612
|
-
const CSSVAR_RE = /--[a-z][a-z0-9-]*/i;
|
|
613
|
-
const DEEP_RE = /::?(v-)?deep\(/;
|
|
614
|
-
const SEL_RE = /^[.#]|[\s]+[.][a-z]|[\s]+#[a-z0-9]/i;
|
|
615
|
-
const TECH_WORDS = [":deep", "::v-deep", "!important", "el-button", "el-menu", "el-input", "element-plus", "querySelector"];
|
|
616
|
-
const ID_RE = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$/;
|
|
617
|
-
|
|
618
|
-
const scan = (label, value) => {
|
|
619
|
-
const s = String(value);
|
|
620
|
-
if (HEX_RE.test(s))
|
|
621
|
-
violations.push(`${label}:出现 hex 色值 "${s}"(hex 仅允许在 style 值与 notes)`);
|
|
622
|
-
if (CSSVAR_RE.test(s))
|
|
623
|
-
violations.push(`${label}:出现 CSS 变量名 "${s}"`);
|
|
624
|
-
if (DEEP_RE.test(s))
|
|
625
|
-
violations.push(`${label}:出现穿透写法 "${s}"`);
|
|
626
|
-
if (SEL_RE.test(s))
|
|
627
|
-
violations.push(`${label}:疑似选择器写法 "${s}"`);
|
|
628
|
-
for (const w of TECH_WORDS) {
|
|
629
|
-
if (s.toLowerCase().includes(w.toLowerCase()))
|
|
630
|
-
violations.push(`${label}:出现技术栈词 "${w}"`);
|
|
631
|
-
}
|
|
632
|
-
};
|
|
633
|
-
|
|
634
|
-
for (const [i, c] of (changeset.changes || []).entries()) {
|
|
635
|
-
const label = `changes[${i}]`;
|
|
636
|
-
// global 为设计侧伪点位(对应变更集顶层 global 节点,同 match 路径口径),豁免 ID 命名规范
|
|
637
|
-
if (!c.id || (c.id !== GLOBAL_PSEUDO_ID && !ID_RE.test(c.id)))
|
|
638
|
-
violations.push(`${label}.id "${c.id}" 不符合语义 ID 命名规范(小写下划线、2~4 段点分层级)`);
|
|
639
|
-
if (c.position !== undefined && c.position !== null)
|
|
640
|
-
scan(`${label}.position`, c.position);
|
|
641
|
-
if (c.form !== undefined && c.form !== null)
|
|
642
|
-
scan(`${label}.form`, c.form);
|
|
643
|
-
if (c.id)
|
|
644
|
-
scan(`${label}.id`, c.id);
|
|
645
|
-
// 三个声明维度:禁词扫描 + 结构校验(apply_via / layer / structure_ops)
|
|
646
|
-
if (c.apply_via !== undefined && c.apply_via !== null)
|
|
647
|
-
scan(`${label}.apply_via`, c.apply_via);
|
|
648
|
-
if (typeof c.layer === "string")
|
|
649
|
-
scan(`${label}.layer`, c.layer);
|
|
650
|
-
if (c.structure_ops !== undefined && c.structure_ops !== null) {
|
|
651
|
-
if (Array.isArray(c.structure_ops)) {
|
|
652
|
-
for (const [j, op] of c.structure_ops.entries()) {
|
|
653
|
-
if (!op || typeof op !== "object")
|
|
654
|
-
continue;
|
|
655
|
-
for (const k of ["node", "source", "from", "to"]) {
|
|
656
|
-
if (op[k] !== undefined)
|
|
657
|
-
scan(`${label}.structure_ops[${j}].${k}`, op[k]);
|
|
658
|
-
}
|
|
659
|
-
}
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
checkApplyFields(c, label, violations);
|
|
663
|
-
}
|
|
664
|
-
return violations;
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
function extractForms(e) {
|
|
668
|
-
if (!e.forms)
|
|
669
|
-
return [];
|
|
670
|
-
if (Array.isArray(e.forms))
|
|
671
|
-
return e.forms.map(f => (typeof f === "string" ? f : f[0])).filter(Boolean);
|
|
672
|
-
// 对象形态:{ chrome: "说明", card: "说明" } 取键;{ mode: [a,b,c] } 取数组值
|
|
673
|
-
const out = [];
|
|
674
|
-
for (const v of Object.values(e.forms)) {
|
|
675
|
-
if (Array.isArray(v))
|
|
676
|
-
out.push(...v.filter(x => typeof x === "string"));
|
|
677
|
-
else if (typeof v === "string" && /^[a-z][a-z0-9_]*$/.test(v))
|
|
678
|
-
out.push(v);
|
|
679
|
-
}
|
|
680
|
-
return out.length > 0 ? out : Object.keys(e.forms);
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
/** 全局令牌索引:basic/ + layout/ 词典摊平为 { abstractField: {token, hook, file} } */
|
|
684
|
-
function buildTokenIndex(dataset) {
|
|
685
|
-
const dict = {};
|
|
686
|
-
for (const [file, data] of Object.entries(dataset.files)) {
|
|
687
|
-
if (!data.tokens)
|
|
688
|
-
continue;
|
|
689
|
-
for (const [field, def] of Object.entries(data.tokens)) {
|
|
690
|
-
if (field.startsWith("$") || typeof def !== "object")
|
|
691
|
-
continue;
|
|
692
|
-
dict[field] = {
|
|
693
|
-
token: Array.isArray(def.token) ? def.token.join(" → ") : (def.token || "(value token)"),
|
|
694
|
-
hook: def.hook || null,
|
|
695
|
-
file: `mapping/${file}`,
|
|
696
|
-
};
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
return dict;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/** 标准布局枚举集合:取 layout 域文件的 layout_modes 键 */
|
|
703
|
-
function collectLayoutModes(dataset) {
|
|
704
|
-
const modes = new Set();
|
|
705
|
-
for (const data of Object.values(dataset.files)) {
|
|
706
|
-
if (data.layout_modes) {
|
|
707
|
-
for (const k of Object.keys(data.layout_modes)) {
|
|
708
|
-
if (!k.startsWith("$"))
|
|
709
|
-
modes.add(k);
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
return modes;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
/** form 枚举校验:条目定义了 forms 时,form 值必须在其枚举内(词典硬闸:枚举外的形态无处落地,违规即阻断) */
|
|
717
|
-
function checkFormEnum(entry, change, gateViolations, idLabel) {
|
|
718
|
-
if (!change.form || !entry.forms)
|
|
719
|
-
return;
|
|
720
|
-
const formKeys = extractForms(entry);
|
|
721
|
-
if (formKeys.length > 0 && !formKeys.includes(change.form)) {
|
|
722
|
-
const msg = `${idLabel || change.id}:form "${change.form}" 不在条目枚举 [${formKeys.join("/")}] 内(G3 枚举闸)→ 报告不猜,回变更集改为枚举内形态或先扩词典 forms`;
|
|
723
|
-
if (gateViolations)
|
|
724
|
-
gateViolations.push(msg);
|
|
725
|
-
else
|
|
726
|
-
console.error(` ✘ ${msg}`);
|
|
727
|
-
}
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
/** 两层样式转换:css_vars 优先 → custom_style_map 兜底(含落点层级解析)→ 未匹配记缺口清单 */
|
|
731
|
-
function printStyleTranslation(entry, change, idLabel, fieldPending, valueDict) {
|
|
732
|
-
const styles = change.style || {};
|
|
733
|
-
if (Object.keys(styles).length === 0)
|
|
734
|
-
return;
|
|
735
|
-
const lines = [];
|
|
736
|
-
const unmatched = [];
|
|
737
|
-
for (const [field, value] of Object.entries(styles)) {
|
|
738
|
-
const resolved = resolveSemanticValue(value, valueDict);
|
|
739
|
-
const valueLabel = resolved ? `${value} → ${resolved}` : value;
|
|
740
|
-
if (entry.css_vars && entry.css_vars[field]) {
|
|
741
|
-
lines.push(` . ${field}: ${valueLabel} → var 覆盖 ${entry.css_vars[field]}(@ ${entry.style_scope})`);
|
|
742
|
-
}
|
|
743
|
-
else if (entry.custom_style_map && entry.custom_style_map[field]) {
|
|
744
|
-
// :按变更集 layer 指认落点(host 宿主盒 / inner 内层文本盒),从词典 anchors 取真实选择器
|
|
745
|
-
const spot = resolveAnchorTarget(entry, field, layerOf(change, field));
|
|
746
|
-
const layerLabel = spot.layer ? ` · ${spot.layer} 层` : "";
|
|
747
|
-
lines.push(` . ${field}: ${valueLabel} → 自定义 ${entry.custom_style_map[field]}(@ ${spot.target}${layerLabel})`);
|
|
748
|
-
}
|
|
749
|
-
else if (entry.data_channels && entry.data_channels[field]) {
|
|
750
|
-
// 数据/结构通道:值走业务页 prop / 数据 / 模板(禁 CSS 仿冒),非缺口
|
|
751
|
-
lines.push(` . ${field}: ${valueLabel} → 数据/结构通道(${entry.data_channels[field].slice(0, 60)}…)`);
|
|
752
|
-
}
|
|
753
|
-
else {
|
|
754
|
-
unmatched.push(field);
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
// 字段命中但值未消歧:非 hex、非定性值、不在译表 → 提示(不阻断,AI 施工前须确认)
|
|
758
|
-
if (!resolved && !QUALITATIVE_RE.test(String(value).trim()) && !/^(#|rgb|hsl|var\(|calc\()/.test(String(value).trim()))
|
|
759
|
-
lines.push(` ⚠ 值 "${value}" 未命中语义值译表且非 hex,施工前需确认精确值`);
|
|
760
|
-
}
|
|
761
|
-
for (const l of lines)
|
|
762
|
-
console.log(l);
|
|
763
|
-
if (unmatched.length > 0) {
|
|
764
|
-
console.log(` ⚠ 未匹配字段:${unmatched.join("、")}(按缺口清单处置:优先在设计层落地并申报,不静默跳过)`);
|
|
765
|
-
if (fieldPending)
|
|
766
|
-
fieldPending.push({ id: idLabel || change.id, fields: unmatched });
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
// ---- · 落点 / 结构施工 / 优先级 解析 ----
|
|
771
|
-
|
|
772
|
-
/** 取字段的落点层级:change.layer 支持字符串(整条生效)或对象(按字段指认) */
|
|
773
|
-
function layerOf(change, field) {
|
|
774
|
-
const l = change && change.layer;
|
|
775
|
-
if (!l)
|
|
776
|
-
return null;
|
|
777
|
-
if (typeof l === "string")
|
|
778
|
-
return l;
|
|
779
|
-
if (typeof l === "object" && !Array.isArray(l) && !field.startsWith("$"))
|
|
780
|
-
return l[field] || null;
|
|
781
|
-
return null;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
/** 按落点层级从词典 anchors 中挑承载选择器;无匹配回退条目 selector(此时 layer 为 null 表示未指定) */
|
|
785
|
-
function resolveAnchorTarget(entry, field, layer) {
|
|
786
|
-
const anchors = Array.isArray(entry.anchors) ? entry.anchors : [];
|
|
787
|
-
const classAnchors = anchors.filter(a => a && a.kind === "class" && typeof a.target === "string");
|
|
788
|
-
// 内层盒属性族保底路由:nowrap / text_align / line_height 等即使未显式声明 layer,
|
|
789
|
-
// 也强制落到 inner 锚点(EP 内层 .cell 出厂样式会覆盖 host 层声明,裸落 host = 竖排换行)。
|
|
790
|
-
if (INNER_ONLY_PROPS.has(field)) {
|
|
791
|
-
const innerHit = classAnchors.find(a => a.layer === "inner");
|
|
792
|
-
if (innerHit)
|
|
793
|
-
return { target: innerHit.target, layer: "inner" };
|
|
794
|
-
}
|
|
795
|
-
if (layer) {
|
|
796
|
-
const hit = classAnchors.find(a => a.layer === layer);
|
|
797
|
-
if (hit)
|
|
798
|
-
return { target: hit.target, layer };
|
|
799
|
-
}
|
|
800
|
-
const host = classAnchors.find(a => a.layer === "host" || a.layer === undefined);
|
|
801
|
-
if (host)
|
|
802
|
-
return { target: host.target, layer: host.layer || null };
|
|
803
|
-
return { target: entry.selector ?? null, layer: null };
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/** apply_via 解析:指认本声明引用的锚点来源条目(缺省 = 自身) */
|
|
807
|
-
function applyViaOf(change, index) {
|
|
808
|
-
const via = change && change.apply_via;
|
|
809
|
-
if (!via || typeof via !== "string")
|
|
810
|
-
return null;
|
|
811
|
-
return { id: via, resolved: index ? index[via] : undefined };
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/** 输出施工上下文:承载通道 anchors / 前置条件 / 覆写优先级 / 结构施工 */
|
|
815
|
-
function printApplyPlan(entry, change, idLabel, index) {
|
|
816
|
-
const anchors = Array.isArray(entry.anchors) ? entry.anchors : [];
|
|
817
|
-
if (anchors.length > 0) {
|
|
818
|
-
const host = anchors.find(a => a.kind === "class" && a.layer === "host");
|
|
819
|
-
const inner = anchors.find(a => a.kind === "class" && a.layer === "inner");
|
|
820
|
-
const parts = [];
|
|
821
|
-
if (host)
|
|
822
|
-
parts.push(`host=${host.target}`);
|
|
823
|
-
if (inner)
|
|
824
|
-
parts.push(`inner=${inner.target}`);
|
|
825
|
-
const vars = anchors.filter(a => a.kind === "css_var").map(a => a.target);
|
|
826
|
-
if (vars.length > 0)
|
|
827
|
-
parts.push(`css_var=${vars.join("、")}`);
|
|
828
|
-
if (parts.length > 0)
|
|
829
|
-
console.log(` ▸ 承载通道 anchors:${parts.join(" | ")}`);
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
const via = applyViaOf(change, index);
|
|
833
|
-
if (via) {
|
|
834
|
-
const ok = via.resolved ? "" : "(⚠ 词典中无此条目,锚点来源待补)";
|
|
835
|
-
console.log(` ▸ 承载通道来源 apply_via:${via.id}${ok}`);
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
printStructureOps(change);
|
|
839
|
-
|
|
840
|
-
if (entry.preconditions && Array.isArray(entry.preconditions) && entry.preconditions.length > 0) {
|
|
841
|
-
console.log(` ▸ 前置条件 preconditions(施工前必须满足,否则值对了也不生效):`);
|
|
842
|
-
for (const p of entry.preconditions)
|
|
843
|
-
console.log(` - ${p}`);
|
|
844
|
-
}
|
|
845
|
-
|
|
846
|
-
if (entry.priority && entry.priority.strategy) {
|
|
847
|
-
console.log(` ▸ 覆写优先级 priority:${entry.priority.strategy}${entry.priority.reason ? ` —— ${entry.priority.reason}` : ""}`);
|
|
848
|
-
console.log(` 落地要求:${priorityRequirement(entry.priority.strategy)}`);
|
|
849
|
-
}
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
/**
|
|
853
|
-
* 覆写优先级 → 落地写法要求
|
|
854
|
-
* 该维度不是「提示」,是硬要求:写错写法 = 规则写了也不生效(同权重被组件 scoped 后注入覆盖,或被出厂 !important 压制)。
|
|
855
|
-
*/
|
|
856
|
-
function priorityRequirement(strategy) {
|
|
857
|
-
switch (strategy) {
|
|
858
|
-
case "scope_qualified":
|
|
859
|
-
return "词典 selector 已含作用域限定,按原样落;若实测仍被覆盖,按 deep_qualified 处理。";
|
|
860
|
-
case "deep_qualified":
|
|
861
|
-
return "禁止裸类名。必须 ≥3 层作用域限定(如 html .a .b)或叠加属性/伪类,否则与组件 scoped 平手、被后注入覆盖 → 选择器“写了不生效”。";
|
|
862
|
-
case "self_plus_important":
|
|
863
|
-
return "对手带 !important,本点位也必须 !important(或改走通道变量);只抬特异度无法取胜。";
|
|
864
|
-
default:
|
|
865
|
-
return "(未知策略,按 deep_qualified 保守处理)";
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
|
|
869
|
-
/**
|
|
870
|
-
* oracle 验收基线校验
|
|
871
|
-
* 两类事故都出在「基线本身不可用」,而不是落地写法:
|
|
872
|
-
* ① 主题与基线图色板不一致:基线图是 A 色板、本次主题是 B → 未声明 oracle.theme_mapping 时
|
|
873
|
-
* 逐点比对必然全红(搬错基线)。注意:**基线图是「场景基线」、与主题无关,任何主题都读同一张**;
|
|
874
|
-
* 不一致只影响「色值归一」,正确动作是声明 theme_mapping 后继续读图,**不是换图、更不是跳过读取**。
|
|
875
|
-
* ② 覆盖度缺口:效果图里有、changes[] 却未声明的点位 → 永远不可能被还原(欠声明);
|
|
876
|
-
* 反之声明了却不在 sids 里的点位 → 落地后无法机器验收。
|
|
877
|
-
* 两者都属「交付不完整」,与颜色红线同级,必须回流设计侧补齐。
|
|
878
|
-
* 主题身份读取口径(与设计侧 mockup-extraction-spec.md §1.1 同源):
|
|
879
|
-
* 本次主题 = changeset.theme(优先)→ global.brand_primary(回退);
|
|
880
|
-
* 基线图色板 = 图内 data-mockup-theme(优先)→ 文件名主题词(回退)。
|
|
881
|
-
* 历史缺口:主题只能从 global.brand_primary 反推、基线图身份只能从文件名猜 → 「图名带钴蓝」
|
|
882
|
-
* 被读成「可还原主题白名单」,换橘橙时误判无基线 → 跳过读图、只改主题色。
|
|
883
|
-
*/
|
|
884
|
-
function checkOracle(changeset, plan, valueDict, opts = {}) {
|
|
885
|
-
const oracle = changeset.oracle;
|
|
886
|
-
if (!oracle || typeof oracle !== "object") {
|
|
887
|
-
if (opts.strict) {
|
|
888
|
-
console.error(`\n✖ mode=restore 下 oracle 缺失(G8 模式闸要求,见变更集校验段)——基线三件套不完整,先回设计侧补齐。`);
|
|
889
|
-
return 1; // G11:restore 下基线缺失 = 阻断,不再是提示后继续
|
|
890
|
-
}
|
|
891
|
-
console.log(`\n提示:变更集未提供 oracle 验收基线(场景还原应给)—— 落地后无法做 computed style 逐点比对,还原度不可证明,建议回流设计侧补齐。`);
|
|
892
|
-
return 0;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
const sids = Array.isArray(oracle.sids) ? oracle.sids : [];
|
|
896
|
-
const mockup = typeof oracle.mockup === "string" ? oracle.mockup : "";
|
|
897
|
-
console.log(`\n=== 验收基线 oracle(落地后跑 verify 逐点比对)===`);
|
|
898
|
-
console.log(` 效果图:${mockup || "(未给 mockup 路径)"}`);
|
|
899
|
-
console.log(` 待比对点位:${sids.length} 条${sids.length ? ` —— ${sids.join("、")}` : ""}`);
|
|
900
|
-
|
|
901
|
-
const issues = [];
|
|
902
|
-
|
|
903
|
-
// ① 主题一致性:本次主题(changeset.theme 优先,回退 global.brand_primary)
|
|
904
|
-
// vs 基线图色板(图内 data-mockup-theme 优先,回退文件名主题词)。
|
|
905
|
-
// 为什么要两端回退:主题身份以前只能从 global.brand_primary 反推、基线图身份只能从文件名猜——
|
|
906
|
-
// 于是「主题」被绑进 global 的取值收敛语义,而「图的名字」被读成了「可还原主题白名单」,
|
|
907
|
-
// 换非基线主题时被误判为无基线 → 跳过读图、只改主题色。
|
|
908
|
-
// 权威口径见设计侧 mockup-extraction-spec.md §1.1:基线图是场景基线,与主题无关。
|
|
909
|
-
const themes = new Set(
|
|
910
|
-
Object.keys(valueDict || {})
|
|
911
|
-
.map(k => /^(.+?)-\d+$/.exec(k))
|
|
912
|
-
.filter(Boolean)
|
|
913
|
-
.map(m => m[1]),
|
|
914
|
-
);
|
|
915
|
-
const themeFromValue = (v) => {
|
|
916
|
-
const m = typeof v === "string" ? /^(.+?)-\d+$/.exec(v.trim()) : null;
|
|
917
|
-
return m ? m[1] : null;
|
|
918
|
-
};
|
|
919
|
-
const themeDeclaredRaw = typeof changeset.theme === "string" ? changeset.theme.trim() : "";
|
|
920
|
-
const declaredTheme = themeDeclaredRaw || themeFromValue(changeset.global && changeset.global.brand_primary);
|
|
921
|
-
const themeSource = themeDeclaredRaw
|
|
922
|
-
? "changeset.theme"
|
|
923
|
-
: (declaredTheme ? "global.brand_primary(回退——建议改在顶层声明 changeset.theme)" : "未声明");
|
|
924
|
-
|
|
925
|
-
// 基线图色板:图内元数据为权威(基线图与主题无关,文件名主题词仅供参考)
|
|
926
|
-
const mockupResolvedEarly = resolveFirstExisting(mockup, opts.filePath);
|
|
927
|
-
let mockupTheme = null;
|
|
928
|
-
let mockupThemeSource = null;
|
|
929
|
-
if (mockupResolvedEarly) {
|
|
930
|
-
try {
|
|
931
|
-
const html = readFileSync(mockupResolvedEarly, "utf-8");
|
|
932
|
-
const m = /\bdata-mockup-theme\s*=\s*["']([^"']+)["']/i.exec(html);
|
|
933
|
-
if (m) { mockupTheme = m[1].trim(); mockupThemeSource = "图内 data-mockup-theme"; }
|
|
934
|
-
} catch { /* 图不可读,回退文件名 */ }
|
|
935
|
-
}
|
|
936
|
-
if (!mockupTheme) {
|
|
937
|
-
const byName = [...themes].find(t => mockup.includes(t)) || null;
|
|
938
|
-
if (byName) { mockupTheme = byName; mockupThemeSource = "文件名主题词(旧图回退——建议补 data-mockup-theme)"; }
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
console.log(` 本次主题:${declaredTheme || "(未声明)"}(来源:${themeSource})`);
|
|
942
|
-
console.log(` 基线图色板:${mockupTheme || "(未标注)"}${mockupThemeSource ? `(来源:${mockupThemeSource})` : ""}`);
|
|
943
|
-
|
|
944
|
-
// 同构异色归一:theme_mapping { "from": "<基线图色板>", "to": "<本次主题>" } 只描述
|
|
945
|
-
// 「基线图色值 → 本次主题语义槽位(-1..-10)」的归一映射,verify 用 --from-theme/--to-theme 执行;
|
|
946
|
-
// **它与「是否读取该图 / 是否逐条声明」无关**——任何主题都读同一张场景基线图并逐点抽取。
|
|
947
|
-
const tm = oracle.theme_mapping && typeof oracle.theme_mapping === "object" ? oracle.theme_mapping : null;
|
|
948
|
-
const declaredReuse = !!(tm && tm.from && tm.to
|
|
949
|
-
&& themes.has(tm.from) && themes.has(tm.to)
|
|
950
|
-
&& declaredTheme === tm.to && mockupTheme === tm.from);
|
|
951
|
-
|
|
952
|
-
if (declaredReuse) {
|
|
953
|
-
console.log(` ↺ 同构异色归一已声明:基线图色板「${tm.from}」→ 本次主题「${tm.to}」(oracle.theme_mapping)。verify 需带 --from-theme ${tm.from} --to-theme ${tm.to} 做色值归一;几何 / 结构 / 字号逐点照比。`);
|
|
954
|
-
}
|
|
955
|
-
else if (declaredTheme && mockupTheme && declaredTheme !== mockupTheme) {
|
|
956
|
-
if (!themes.has(declaredTheme)) {
|
|
957
|
-
// 库外临时主题:语义槽位表里没有它的槽位,theme_mapping 的按槽位归一不适用
|
|
958
|
-
console.log(` ℹ 本次为库外临时主题「${declaredTheme}」:基线图色板「${mockupTheme}」无对应语义槽位,verify 不做色值归一(几何 / 结构 / 字号仍逐点照比);色值按设计侧 notes 申报的派生口径落地。`);
|
|
959
|
-
} else {
|
|
960
|
-
issues.push(`主题错配(本次主题 ≠ 基线图色板):基线图是「${mockupTheme}」、本次主题是「${declaredTheme}」(来源:${themeSource})。**基线图是场景基线、与主题无关——正确动作是继续读这张图并逐点抽取**,同时声明 oracle.theme_mapping: { "from": "${mockupTheme}", "to": "${declaredTheme}" }(它只做色值归一,不减任何声明与读取动作);若结构 / 形态确实也不同,则另立基线图后登记 baseline。**不得把"主题不匹配"读成"该主题没有基线图"而跳过读取。**`);
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
if (!mockupTheme && mockup) {
|
|
964
|
-
const hint = themeDeclaredRaw
|
|
965
|
-
? `已在 changeset.theme 声明「${themeDeclaredRaw}」——基线图身份未标注不影响读取:按注册表 baseline.<页型>.mockup 取该页型基线图即可,请在该图 <html> 上补 data-mockup-theme(含 scene / page-type)后再验。`
|
|
966
|
-
: `无法确认基线图色板 —— 请核对该图是否为本页型的场景基线图,并在 <html> 上补 data-mockup-theme / data-mockup-scene / data-mockup-page-type。`;
|
|
967
|
-
console.log(` ℹ 基线图未带主题身份元数据:${hint}`);
|
|
968
|
-
}
|
|
969
|
-
|
|
970
|
-
// ② 覆盖度(双向)
|
|
971
|
-
const declaredIds = plan.map(p => p.id);
|
|
972
|
-
const sidSet = new Set(sids);
|
|
973
|
-
// 仅 visible:false 的隐藏点位在效果图上没有可见元素可标 data-sid —— 豁免「无基线」判定
|
|
974
|
-
const hiddenOnly = new Set(
|
|
975
|
-
plan.filter(p => p.change && p.change.visible === false).map(p => p.id),
|
|
976
|
-
);
|
|
977
|
-
// 实物闭合兜底(G3):带登记表时由 checkRegistryCoverage 做权威实物对账;
|
|
978
|
-
// 未带登记表时此处直接打开 mockup 数 data-sid,防止「sids 自报全绿、实物基线为空」。
|
|
979
|
-
// mockup 路径解析复用 resolveFirstExisting(变更集同目录 → cwd → 技能根)。
|
|
980
|
-
if (!changeset.coverage?.registry && mockup) {
|
|
981
|
-
const mockupPath = resolveFirstExisting(mockup, opts.filePath);
|
|
982
|
-
let physical = null;
|
|
983
|
-
if (mockupPath) {
|
|
984
|
-
try {
|
|
985
|
-
const stripped = readFileSync(mockupPath, "utf-8").replace(/<!--[\s\S]*?-->/g, "");
|
|
986
|
-
physical = new Set([...stripped.matchAll(/data-sid\s*=\s*["']([^"']+)["']/g)].map(m => m[1]));
|
|
987
|
-
}
|
|
988
|
-
catch { /* mockup 不可读,physical 保持 null */ }
|
|
989
|
-
}
|
|
990
|
-
if (physical) {
|
|
991
|
-
const sidNoPhysical = sids.filter(s => !physical.has(s) && !hiddenOnly.has(s));
|
|
992
|
-
if (sidNoPhysical.length > 0)
|
|
993
|
-
issues.push(`实物缺口 ${sidNoPhysical.length} 处:oracle.sids 声明了、效果图 HTML 实物里无对应 data-sid(剥注释后数点)→ 基线抽不出来。\n ${sidNoPhysical.join("、")}`);
|
|
994
|
-
}
|
|
995
|
-
}
|
|
996
|
-
const sidNotDeclared = sids.filter(s => !declaredIds.includes(s));
|
|
997
|
-
const declaredWithoutSid = declaredIds.filter(id => !sidSet.has(id) && !hiddenOnly.has(id));
|
|
998
|
-
if (hiddenOnly.size > 0)
|
|
999
|
-
console.log(` ℹ 隐藏点位(仅 visible:false)${hiddenOnly.size} 处豁免 sid 标注:${[...hiddenOnly].join("、")}`);
|
|
1000
|
-
if (sidNotDeclared.length > 0)
|
|
1001
|
-
issues.push(`欠声明 ${sidNotDeclared.length} 处:效果图里有、changes[] 未声明 → 该点位永远不可能被还原。\n ${sidNotDeclared.join("、")}`);
|
|
1002
|
-
if (declaredWithoutSid.length > 0)
|
|
1003
|
-
issues.push(`无基线 ${declaredWithoutSid.length} 处:changes[] 已声明、效果图 sids 未覆盖 → 落地后无法机器验收。\n ${declaredWithoutSid.join("、")}`);
|
|
1004
|
-
|
|
1005
|
-
if (issues.length === 0) {
|
|
1006
|
-
console.log(` ✔ oracle 校验通过:主题一致、覆盖度双向闭合`);
|
|
1007
|
-
return 0;
|
|
1008
|
-
}
|
|
1009
|
-
// G11 oracle 基线硬闸:restore 模式下基线问题从「⚠ 软提示」升格为阻断。
|
|
1010
|
-
// 历史缺口:主题错配 / 欠声明 / 无基线只打 ⚠ 就继续——基线断了仍绿灯交付,
|
|
1011
|
-
// verify 无从做 1:1 证明,「细节没还原到位」在交付时不可见。
|
|
1012
|
-
if (opts.strict) {
|
|
1013
|
-
console.error(`\n✖ oracle 基线问题(G11 · ${issues.length} 类 → 拒绝执行):`);
|
|
1014
|
-
for (const it of issues)
|
|
1015
|
-
console.error(` ✘ ${it}`);
|
|
1016
|
-
console.error(` 处置:按上列各条回设计侧补齐基线(补 sids 声明 / 声明 theme_mapping 做色值归一;基线图缺失时先补图)后重跑。注意「换主题」不等于「换图」——任何主题都读同一张场景基线图。`);
|
|
1017
|
-
return 1;
|
|
1018
|
-
}
|
|
1019
|
-
console.log(`\n⚠ oracle 基线问题(${issues.length} 类):`);
|
|
1020
|
-
for (const it of issues)
|
|
1021
|
-
console.log(` ✘ ${it}`);
|
|
1022
|
-
return 0;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
/**
|
|
1026
|
-
* 覆盖率校验(场景还原子项级细闸)
|
|
1027
|
-
* 设计侧用「登记表」把规范里每条带样式结论的子项变成必登项,交付时以 coverage 记账。
|
|
1028
|
-
* 这里只做结构一致性校验:covered 必须等于 applicable、gaps 必须为空。
|
|
1029
|
-
* 缺 coverage 时无法判断「是否有组内子项被静默丢失」——属交付完整性的信号缺口。
|
|
1030
|
-
*/
|
|
1031
|
-
function checkCoverage(changeset, opts = {}) {
|
|
1032
|
-
const cov = changeset.coverage;
|
|
1033
|
-
if (!cov || typeof cov !== "object") {
|
|
1034
|
-
if (opts.strict)
|
|
1035
|
-
console.error(`\n✖ mode=restore 下 coverage 缺失(G8 模式闸要求,见变更集校验段)——186 条登记表口径的完备性无从计数,先回设计侧补齐。`);
|
|
1036
|
-
else
|
|
1037
|
-
console.log(`\n提示:变更集未提供 coverage 覆盖率记账(场景还原应给)—— 无法判断是否有规范子项被静默丢失,完备性不可数,建议回流设计侧补齐。`);
|
|
1038
|
-
return;
|
|
1039
|
-
}
|
|
1040
|
-
const num = v => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
|
1041
|
-
const R = num(cov.registrable);
|
|
1042
|
-
const A = num(cov.applicable);
|
|
1043
|
-
const C = num(cov.covered);
|
|
1044
|
-
const gaps = Array.isArray(cov.gaps) ? cov.gaps : [];
|
|
1045
|
-
console.log(`\n=== 覆盖率 coverage(子项级细闸)===`);
|
|
1046
|
-
console.log(` 登记表:${cov.registry || "(未给路径)"}`);
|
|
1047
|
-
console.log(` 登记项 R=${R ?? "?"} / 适用 A=${A ?? "?"} / 已兑现 C=${C ?? "?"}`);
|
|
1048
|
-
|
|
1049
|
-
const issues = [];
|
|
1050
|
-
if (A === null || C === null)
|
|
1051
|
-
issues.push("缺 applicable / covered 计数,无法核验完备性。");
|
|
1052
|
-
else if (C < A)
|
|
1053
|
-
issues.push(`欠声明:已兑现 ${C} < 适用 ${A} —— 有 ${A - C} 条登记项未落进 changes[] / visible / pending。`);
|
|
1054
|
-
if (gaps.length > 0)
|
|
1055
|
-
issues.push(`gaps 非空(${gaps.length} 条):${gaps.join("、")}`);
|
|
1056
|
-
|
|
1057
|
-
if (issues.length === 0) {
|
|
1058
|
-
console.log(` ✔ 覆盖率校验通过:C = A 且无缺口`);
|
|
1059
|
-
return;
|
|
1060
|
-
}
|
|
1061
|
-
console.log(`\n⚠ 覆盖率问题(${issues.length} 类):`);
|
|
1062
|
-
for (const it of issues)
|
|
1063
|
-
console.log(` ✘ ${it}`);
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
/** 依次尝试候选路径,返回首个存在者(相对路径基准:变更集所在目录 → 当前工作目录 → 技能仓库根 → 兄弟设计技能 project-ui-design 根) */
|
|
1067
|
-
function resolveFirstExisting(raw, filePath) {
|
|
1068
|
-
if (!raw || typeof raw !== "string")
|
|
1069
|
-
return null;
|
|
1070
|
-
if (path.isAbsolute(raw))
|
|
1071
|
-
return existsSync(raw) ? raw : null;
|
|
1072
|
-
const bases = [];
|
|
1073
|
-
if (filePath)
|
|
1074
|
-
bases.push(path.dirname(filePath));
|
|
1075
|
-
bases.push(process.cwd(), ROOT);
|
|
1076
|
-
// 兄弟技能根:真实设计侧登记表/效果图沉淀在 project-ui-design(设计侧 page-specs),
|
|
1077
|
-
// 跨库引用时允许相对该根解析(两侧技能目录同级部署是标准布局)。
|
|
1078
|
-
const sibling = path.resolve(ROOT, "..", "project-ui-design");
|
|
1079
|
-
bases.push(sibling);
|
|
1080
|
-
for (const base of bases) {
|
|
1081
|
-
const candidate = path.resolve(base, raw);
|
|
1082
|
-
if (existsSync(candidate))
|
|
1083
|
-
return candidate;
|
|
1084
|
-
}
|
|
1085
|
-
return null;
|
|
1086
|
-
}
|
|
1087
|
-
|
|
1088
|
-
/** 词典条目的可用字段集:css_vars + custom_style_map + forms 键 + 结构/存在维度 */
|
|
1089
|
-
function entryFieldSet(entry) {
|
|
1090
|
-
const fields = new Set(["form", "visible", "position"]);
|
|
1091
|
-
for (const bag of ["css_vars", "custom_style_map", "forms"]) {
|
|
1092
|
-
for (const k of Object.keys(entry?.[bag] || {}))
|
|
1093
|
-
fields.add(k);
|
|
1094
|
-
}
|
|
1095
|
-
return fields;
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/** 登记项引用的语义 ID(支持单 sid 与多 sids 两种写法) */
|
|
1099
|
-
function registrySids(item) {
|
|
1100
|
-
if (Array.isArray(item?.sids))
|
|
1101
|
-
return item.sids;
|
|
1102
|
-
return item?.sid ? [item.sid] : [];
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
/**
|
|
1106
|
-
* 登记表覆盖率(子项级细闸,)
|
|
1107
|
-
* 组级台账(restore-checklist)只能防「整组忘光」;组内子项是否被静默丢失,必须由登记表
|
|
1108
|
-
* (规范里每条带样式结论的子项 → 一条登记项)来计数。这里以登记表为权威**当场重算**:
|
|
1109
|
-
* 适用 A = always 子项 ∪ 效果图已标注同名 data-sid 的子项
|
|
1110
|
-
* 兑现 C = A 中在 changes[] / visible:false / pending[].proposed_id 出现者
|
|
1111
|
-
* 设计侧自报的 coverage 数字不参与计算,因此无法用「把 applicable 写小」蒙混过关。
|
|
1112
|
-
* 有缺口 → 返回缺口数(调用方置退出码 1)。
|
|
1113
|
-
*/
|
|
1114
|
-
function checkRegistryCoverage(changeset, plan, opts = {}) {
|
|
1115
|
-
const raw = opts.registryArg || changeset.coverage?.registry || changeset.oracle?.registry;
|
|
1116
|
-
const registryPath = resolveFirstExisting(raw, opts.filePath);
|
|
1117
|
-
if (!registryPath) {
|
|
1118
|
-
if (opts.registryArg) {
|
|
1119
|
-
console.error(`\n✖ 登记表文件不存在:${opts.registryArg}`);
|
|
1120
|
-
return 1;
|
|
1121
|
-
}
|
|
1122
|
-
if (opts.strict) {
|
|
1123
|
-
// G9 前置缺口:mode=restore 却解析不到登记表实物(G8 只验了字符串非空)
|
|
1124
|
-
console.error(`\n✖ G9 登记表身份闸:mode=restore 但登记表实物不存在(coverage.registry="${raw}" 解析失败)→ 拒绝执行。`);
|
|
1125
|
-
console.error(` 覆盖率分母必须来自「方案 × 场景」的真实登记表,否则壳层/表格细粒度点位从分母消失,欠声明不可数。`);
|
|
1126
|
-
return 1;
|
|
1127
|
-
}
|
|
1128
|
-
console.log(`\n提示:未提供登记表(--registry 或 coverage.registry)—— 组级台账只防「整组忘光」,组内子项被静默丢失无法计数,建议场景还原带登记表。`);
|
|
1129
|
-
return 0;
|
|
1130
|
-
}
|
|
1131
|
-
|
|
1132
|
-
let registry;
|
|
1133
|
-
try {
|
|
1134
|
-
registry = JSON.parse(readFileSync(registryPath, "utf-8"));
|
|
1135
|
-
}
|
|
1136
|
-
catch (e) {
|
|
1137
|
-
console.error(`\n✖ 登记表读取/解析失败:${registryPath} —— ${e.message}`);
|
|
1138
|
-
return 1;
|
|
1139
|
-
}
|
|
1140
|
-
|
|
1141
|
-
// G9 登记表身份闸:restore 模式下,登记表必须属于「本变更集声明的方案 × 场景」,
|
|
1142
|
-
// 且必须是有 items 的真登记表。历史缺口:G8 只验 coverage.registry 是非空字符串——
|
|
1143
|
-
// 拿 6 条的回归夹具 registry-sample.json、甚至别的方案/场景的登记表都能过 G8,
|
|
1144
|
-
// 覆盖率分母(R=186 的壳层+表格子项)被偷换成小表,「面包屑/顶栏图标/表头 nowrap」这类点位
|
|
1145
|
-
// 从分母里消失 → 欠声明不可数 → 细节还原缺口一路绿灯。凡「身份对不上的表」视同没有表。
|
|
1146
|
-
const scheme = typeof changeset.scheme === "string" ? changeset.scheme : "";
|
|
1147
|
-
const scene = typeof changeset.scene === "string" ? changeset.scene : "";
|
|
1148
|
-
const regScheme = typeof registry.design_scheme === "string" ? registry.design_scheme : "";
|
|
1149
|
-
const regScene = typeof registry.scene === "string" ? registry.scene : "";
|
|
1150
|
-
const identityMismatch = (regScheme && scheme && regScheme !== scheme) || (regScene && scene && regScene !== scene);
|
|
1151
|
-
if (opts.strict && (identityMismatch || !regScheme || !regScene)) {
|
|
1152
|
-
console.error(`\n✖ G9 登记表身份闸:登记表 ${path.basename(registryPath)} 属于 scheme="${regScheme || "?"}"/scene="${regScene || "?"}",与变更集 scheme="${scheme || "?"}"/scene="${scene || "?"}" 不一致(或缺身份字段)→ 拒绝执行。`);
|
|
1153
|
-
console.error(` 处置:coverage.registry 改指「${scheme} × ${scene}」方案的登记表(如设计侧 page-specs/styles/<方案>/scenes/<场景>/restore-registry.json)。`);
|
|
1154
|
-
return 1;
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
const items = Array.isArray(registry.items) ? registry.items : [];
|
|
1158
|
-
if (items.length === 0) {
|
|
1159
|
-
console.error(`\n✖ 登记表不含 items:${registryPath}`);
|
|
1160
|
-
return 1;
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
// 兑现集合 = changes[] ∪ visible:false ∪ pending[].proposed_id ∪ 顶层 global 节点
|
|
1164
|
-
const declared = new Set(plan.map(p => p.id));
|
|
1165
|
-
for (const p of plan) {
|
|
1166
|
-
if (p.change && p.change.visible === false)
|
|
1167
|
-
declared.add(p.id);
|
|
1168
|
-
}
|
|
1169
|
-
for (const p of Array.isArray(changeset.pending) ? changeset.pending : []) {
|
|
1170
|
-
if (p && p.proposed_id)
|
|
1171
|
-
declared.add(p.proposed_id);
|
|
1172
|
-
}
|
|
1173
|
-
if (changeset.global && Object.keys(changeset.global).length > 0)
|
|
1174
|
-
declared.add(GLOBAL_PSEUDO_ID);
|
|
1175
|
-
const declaredSids = new Set(Array.isArray(changeset.oracle?.sids) ? changeset.oracle.sids : []);
|
|
1176
|
-
|
|
1177
|
-
// 实物闭合:打开 oracle.mockup 这个 HTML,数真实存在的 data-sid(不只看设计侧自报的 oracle.sids)
|
|
1178
|
-
// mockup 路径解析复用 resolveFirstExisting(变更集同目录 → cwd → 技能根)。
|
|
1179
|
-
const mockupRef = changeset.oracle?.mockup;
|
|
1180
|
-
let physicalSids = null;
|
|
1181
|
-
let mockupFile = null;
|
|
1182
|
-
const mockupResolved = resolveFirstExisting(typeof mockupRef === "string" ? mockupRef : null, opts.filePath);
|
|
1183
|
-
if (mockupResolved) {
|
|
1184
|
-
try {
|
|
1185
|
-
const html = readFileSync(mockupResolved, "utf-8");
|
|
1186
|
-
// 先剥离 HTML 注释再数 data-sid:注释内的同形串是说明文字、不是点位。
|
|
1187
|
-
// 不剥离 → 注释可「兜住」一个实际缺失的标注,闸门全绿而基线为空(详见 mockup-extraction-spec.md §7.1)。
|
|
1188
|
-
const stripped = html.replace(/<!--[\s\S]*?-->/g, "");
|
|
1189
|
-
physicalSids = new Set([...stripped.matchAll(/data-sid\s*=\s*["']([^"']+)["']/g)].map(m => m[1]));
|
|
1190
|
-
mockupFile = mockupResolved;
|
|
1191
|
-
}
|
|
1192
|
-
catch { /* mockup 不可读,physicalSids 保持 null */ }
|
|
1193
|
-
}
|
|
1194
|
-
// 比对基准:优先效果图实物;实物读不到时退化为自报清单(并在下方告警)
|
|
1195
|
-
const mockupSids = physicalSids || declaredSids;
|
|
1196
|
-
|
|
1197
|
-
const applicable = items.filter(it => it.always === true || registrySids(it).some(s => mockupSids.has(s)));
|
|
1198
|
-
|
|
1199
|
-
// G10 dims 兑现闸:「兑现」从「ID 出现」升格为「ID 出现 且 dims 维度全部出现」。
|
|
1200
|
-
// 历史缺口:只数 ID——声明了 admin.content.table.header_cell 却漏掉 nowrap / font_weight /
|
|
1201
|
-
// text_align 等关键维度也算「已兑现」→ 表头竖排(nowrap 丢失)、字重不对(font_weight 丢失)
|
|
1202
|
-
// 这类「点位在、细节丢」的症状一路绿灯。核对口径:dims 中的 form 看 change.form;
|
|
1203
|
-
// 其余维度看 change.style ∪ change.layer 的键;source:none(登记即兑现)、global 伪点位
|
|
1204
|
-
// (全局通道字段名与登记 dims 不同构)、visible:false 隐藏点位不做 dims 机检。
|
|
1205
|
-
const hiddenIds = new Set((changeset.changes || []).filter(c => c && c.visible === false).map(c => c.id));
|
|
1206
|
-
const missingDimsOf = (it, change) => {
|
|
1207
|
-
const dims = it.dims || [];
|
|
1208
|
-
const styleKeys = new Set([...Object.keys(change.style || {}), ...Object.keys(change.layer || {})]);
|
|
1209
|
-
const missing = [];
|
|
1210
|
-
if (dims.includes("form") && change.form === undefined)
|
|
1211
|
-
missing.push("form");
|
|
1212
|
-
for (const d of dims.filter(d => d !== "form")) {
|
|
1213
|
-
if (!styleKeys.has(d))
|
|
1214
|
-
missing.push(d);
|
|
1215
|
-
}
|
|
1216
|
-
return missing;
|
|
1217
|
-
};
|
|
1218
|
-
const dimsGaps = []; // { item, missing }
|
|
1219
|
-
const gaps = applicable.filter(it => {
|
|
1220
|
-
const sids = registrySids(it);
|
|
1221
|
-
if (!sids.every(s => declared.has(s)))
|
|
1222
|
-
return true; // ID 级欠声明(原有缺口口径)
|
|
1223
|
-
if (it.source === "none")
|
|
1224
|
-
return false;
|
|
1225
|
-
const primary = sids.find(s => s !== GLOBAL_PSEUDO_ID);
|
|
1226
|
-
if (!primary)
|
|
1227
|
-
return false; // global 伪点位条目
|
|
1228
|
-
if (hiddenIds.has(primary))
|
|
1229
|
-
return false; // visible:false 点位 dims 豁免(与 oracle 隐藏点位豁免同口径)
|
|
1230
|
-
const change = (changeset.changes || []).find(c => c.id === primary);
|
|
1231
|
-
if (!change)
|
|
1232
|
-
return false; // 经 pending/visible 兑现且无 style 意图条目 → dims 由台账人工闸承载
|
|
1233
|
-
const missing = missingDimsOf(it, change);
|
|
1234
|
-
if (missing.length === 0)
|
|
1235
|
-
return false;
|
|
1236
|
-
dimsGaps.push({ item: it, missing });
|
|
1237
|
-
return false;
|
|
1238
|
-
});
|
|
1239
|
-
|
|
1240
|
-
console.log(`\n=== 登记表覆盖率(子项级细闸 · 当场重算)===`);
|
|
1241
|
-
console.log(` 登记表:${registryPath}(登记项 R=${items.length})`);
|
|
1242
|
-
console.log(` 适用 A=${applicable.length} / 已兑现 C=${applicable.length - gaps.length} / 缺口 ${gaps.length}${dimsGaps.length > 0 ? ` / dims 缺口 ${dimsGaps.length}(G10)` : ""}`);
|
|
1243
|
-
if (dimsGaps.length > 0) {
|
|
1244
|
-
console.log(` ⚠ dims 兑现缺口 ${dimsGaps.length} 条(ID 已声明、dims 维度未给全):`);
|
|
1245
|
-
for (const { item, missing } of dimsGaps.slice(0, 10))
|
|
1246
|
-
console.log(` ${item.rule} ${registrySids(item).join(" + ")} 缺 dims=[${missing.join(",")}]`);
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
// 实物校验:标注缺口的权威来源是效果图 HTML 里的 data-sid,而不是自报的 oracle.sids。
|
|
1250
|
-
// 两类合法豁免(与 mockup-extraction-spec §7.1 / 设计侧 D3b 同源):
|
|
1251
|
-
// ① hiddenSids:visible:false 隐藏点设计本身不渲染,基线图上无元素可标;
|
|
1252
|
-
// ② 令牌通道 sid 无元素锚点,由同登记项内已标注的兄弟 sid 以 some 语义覆盖。
|
|
1253
|
-
let physicalGap = false;
|
|
1254
|
-
const hiddenSids = new Set(plan.filter(p => p.change && p.change.visible === false).map(p => p.id));
|
|
1255
|
-
const sidCoveredByItem = new Map();
|
|
1256
|
-
for (const it of items) {
|
|
1257
|
-
const sids = registrySids(it);
|
|
1258
|
-
const anyAnnotated = sids.some(s => mockupSids.has(s));
|
|
1259
|
-
for (const s of sids)
|
|
1260
|
-
sidCoveredByItem.set(s, anyAnnotated);
|
|
1261
|
-
}
|
|
1262
|
-
if (physicalSids) {
|
|
1263
|
-
console.log(` 效果图实物:${mockupFile}(实测 data-sid ${physicalSids.size} 个)`);
|
|
1264
|
-
const declaredNoPhysical = [...declaredSids].filter(
|
|
1265
|
-
s => !physicalSids.has(s) && !hiddenSids.has(s) && !sidCoveredByItem.get(s),
|
|
1266
|
-
);
|
|
1267
|
-
const physicalNoDeclared = [...physicalSids].filter(s => !declaredSids.has(s));
|
|
1268
|
-
if (declaredNoPhysical.length > 0) {
|
|
1269
|
-
console.log(` ⚠ oracle.sids 声明了 ${declaredNoPhysical.length} 个点位,但效果图实物里没有对应 data-sid(无基线):${declaredNoPhysical.join("、")}`);
|
|
1270
|
-
physicalGap = true;
|
|
1271
|
-
}
|
|
1272
|
-
if (physicalNoDeclared.length > 0) {
|
|
1273
|
-
console.log(` ✘ 效果图实物另有 ${physicalNoDeclared.length} 个 data-sid 未列入 oracle.sids(自报清单不全 → 基线中存在的细节不得排除在验收外):${physicalNoDeclared.join("、")}`);
|
|
1274
|
-
physicalGap = true;
|
|
1275
|
-
}
|
|
1276
|
-
}
|
|
1277
|
-
else if (opts.strict && changeset.oracle && changeset.oracle.mockup) {
|
|
1278
|
-
console.log(` ✘ 未能打开效果图实物(oracle.mockup 不可读)→ 基线不可用 = 不可验收,禁止退化为按自报 oracle.sids 校验`);
|
|
1279
|
-
physicalGap = true;
|
|
1280
|
-
}
|
|
1281
|
-
|
|
1282
|
-
// 效果图标注完整性:恒存在的子项必须能在效果图上找到同名 data-sid,否则基线根本抽不出来
|
|
1283
|
-
const unannotated = items.filter(
|
|
1284
|
-
it => it.always === true
|
|
1285
|
-
&& !registrySids(it).every(s => hiddenSids.has(s))
|
|
1286
|
-
&& !registrySids(it).some(s => mockupSids.has(s)),
|
|
1287
|
-
);
|
|
1288
|
-
if (unannotated.length > 0) {
|
|
1289
|
-
const bySid = new Map();
|
|
1290
|
-
for (const it of unannotated) {
|
|
1291
|
-
for (const s of registrySids(it))
|
|
1292
|
-
bySid.set(s, (bySid.get(s) || 0) + 1);
|
|
1293
|
-
}
|
|
1294
|
-
console.log(` ⚠ 效果图标注缺口 ${unannotated.length} 条:这些子项恒存在,却无同名 data-sid → 基线抽取不全`);
|
|
1295
|
-
console.log(` 涉及点位:${[...bySid.entries()].map(([s, n]) => `${s}(${n})`).join("、")}`);
|
|
1296
|
-
physicalGap = true;
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
if (gaps.length === 0 && dimsGaps.length === 0 && !physicalGap) {
|
|
1300
|
-
console.log(` ✔ 覆盖率通过:C = A,无子项被静默丢失;dims 维度全兑现(G10);效果图实物标注闭合`);
|
|
1301
|
-
return 0;
|
|
1302
|
-
}
|
|
1303
|
-
|
|
1304
|
-
if (gaps.length === 0 && dimsGaps.length === 0 && physicalGap) {
|
|
1305
|
-
console.log(`\n✘ 效果图实物标注不闭合 → exit 1(oracle.sids 与效果图 HTML 的 data-sid 不一致 / 恒存在子项缺标注)`);
|
|
1306
|
-
console.log(` 处置:给效果图对应元素补 data-sid,或把该登记项降级 source: spec(见 mockup-extraction-spec.md §7)`);
|
|
1307
|
-
return 1;
|
|
1308
|
-
}
|
|
1309
|
-
|
|
1310
|
-
if (dimsGaps.length > 0 && opts.strict) {
|
|
1311
|
-
console.log(`\n✘ dims 兑现缺口(G10 · ID 已声明但 dims 维度未给全,共 ${dimsGaps.length} 条 → exit 1):`);
|
|
1312
|
-
for (const { item, missing } of dimsGaps)
|
|
1313
|
-
console.log(` ${item.rule} ${registrySids(item).join(" + ")} 缺 dims=[${missing.join(",")}]`);
|
|
1314
|
-
console.log(` 处置:在对应 changes[] 条目的 style/layer 补齐所缺维度取值(值来源见 source 字段);「点位在、细节丢」正是样式细节不还原的机制成因。`);
|
|
1315
|
-
}
|
|
1316
|
-
|
|
1317
|
-
if (gaps.length > 0) {
|
|
1318
|
-
const byGroup = new Map();
|
|
1319
|
-
for (const it of gaps) {
|
|
1320
|
-
const g = it.group || "(未分组)";
|
|
1321
|
-
if (!byGroup.has(g))
|
|
1322
|
-
byGroup.set(g, []);
|
|
1323
|
-
byGroup.get(g).push(it);
|
|
1324
|
-
}
|
|
1325
|
-
console.log(`\n✘ 登记表覆盖率缺口(子项级欠声明,共 ${gaps.length} 条 → exit 1):`);
|
|
1326
|
-
for (const [group, list] of byGroup) {
|
|
1327
|
-
console.log(` · ${group}(${list.length})`);
|
|
1328
|
-
const shown = opts.verbose ? list : list.slice(0, 6);
|
|
1329
|
-
for (const it of shown)
|
|
1330
|
-
console.log(` ${it.rule} ${registrySids(it).join(" + ")} [${(it.dims || []).join(",")}]`);
|
|
1331
|
-
if (!opts.verbose && list.length > shown.length)
|
|
1332
|
-
console.log(` …(同组其余 ${list.length - shown.length} 条,加 --verbose 展开)`);
|
|
1333
|
-
}
|
|
1334
|
-
}
|
|
1335
|
-
// G10 dims 缺口计入返回值:strict 模式下 =「交付不完整」与欠声明同级
|
|
1336
|
-
return gaps.length + (opts.strict ? dimsGaps.length : 0);
|
|
1337
|
-
}
|
|
1338
|
-
|
|
1339
|
-
/**
|
|
1340
|
-
* 登记表通道核验
|
|
1341
|
-
* 与 match 的覆盖率校验互补:不需要变更集,直接拿登记表对词典,回答一件事——
|
|
1342
|
-
* 「这张登记表里的必登项,有多少在项目侧真有承载字段」。这是 1:1 对接的**通道缺口清单**:
|
|
1343
|
-
* · sid 词典无此 ID 且未标 pending → 硬缺口(登记表引用了不存在的点位,exit 1)
|
|
1344
|
-
* · dims 是设计侧通用维度(adjustable-elements 口径),词典无对应字段 → 通道缺口(列出,供补词典)
|
|
1345
|
-
*/
|
|
1346
|
-
function cmdRegistry(args, dataset) {
|
|
1347
|
-
const verbose = args.includes("--verbose");
|
|
1348
|
-
const file = args.find(a => !a.startsWith("--"));
|
|
1349
|
-
if (!file) {
|
|
1350
|
-
console.error("✖ 缺少登记表文件参数:node parser/index.js registry <restore-registry.json> [--verbose]");
|
|
1351
|
-
process.exit(1);
|
|
1352
|
-
}
|
|
1353
|
-
const p = path.resolve(file);
|
|
1354
|
-
let registry;
|
|
1355
|
-
try {
|
|
1356
|
-
registry = JSON.parse(readFileSync(p, "utf-8"));
|
|
1357
|
-
}
|
|
1358
|
-
catch (e) {
|
|
1359
|
-
console.error(`✖ 登记表读取/解析失败:${e.message}`);
|
|
1360
|
-
process.exit(1);
|
|
1361
|
-
}
|
|
1362
|
-
const items = Array.isArray(registry.items) ? registry.items : [];
|
|
1363
|
-
if (items.length === 0) {
|
|
1364
|
-
console.error(`✖ 登记表不含 items:${file}`);
|
|
1365
|
-
process.exit(1);
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
const index = buildMergedIndex(dataset);
|
|
1369
|
-
const SOURCES = new Set(["spec", "mockup", "both", "none"]);
|
|
1370
|
-
const CHANNELS = new Set(["form", "style", "layer", "structure_ops", "apply_via"]);
|
|
1371
|
-
|
|
1372
|
-
const hard = [];
|
|
1373
|
-
const chanMissing = [];
|
|
1374
|
-
const seenRule = new Set();
|
|
1375
|
-
const pendingIds = new Set();
|
|
1376
|
-
let sidOk = 0;
|
|
1377
|
-
let dimTotal = 0;
|
|
1378
|
-
let dimOk = 0;
|
|
1379
|
-
|
|
1380
|
-
for (const it of items) {
|
|
1381
|
-
const rule = it.rule || "(无 rule)";
|
|
1382
|
-
if (seenRule.has(rule))
|
|
1383
|
-
hard.push(`rule 重复:${rule}`);
|
|
1384
|
-
seenRule.add(rule);
|
|
1385
|
-
if (it.source && !SOURCES.has(it.source))
|
|
1386
|
-
hard.push(`${rule}: source "${it.source}" 非法(应为 ${[...SOURCES].join("/")})`);
|
|
1387
|
-
for (const c of Array.isArray(it.channel) ? it.channel : []) {
|
|
1388
|
-
if (!CHANNELS.has(c))
|
|
1389
|
-
hard.push(`${rule}: channel "${c}" 非法(应为 ${[...CHANNELS].join("/")})`);
|
|
1390
|
-
}
|
|
1391
|
-
const sids = registrySids(it);
|
|
1392
|
-
if (sids.length === 0)
|
|
1393
|
-
hard.push(`${rule}: 未给 sid / sids`);
|
|
1394
|
-
for (const sid of sids) {
|
|
1395
|
-
if (sid === GLOBAL_PSEUDO_ID) {
|
|
1396
|
-
sidOk++;
|
|
1397
|
-
continue; // 全局令牌通道:对应变更集顶层 global 节点,不入词典
|
|
1398
|
-
}
|
|
1399
|
-
if (it.pending) {
|
|
1400
|
-
pendingIds.add(sid);
|
|
1401
|
-
if (index[sid])
|
|
1402
|
-
hard.push(`${rule}: ${sid} 已标 pending,但词典已有该 ID → 应撤掉 pending 标注`);
|
|
1403
|
-
}
|
|
1404
|
-
const entry = index[sid];
|
|
1405
|
-
if (!entry) {
|
|
1406
|
-
if (!it.pending)
|
|
1407
|
-
hard.push(`${rule}: ${sid} 词典无此 ID 且未标 pending → 须补词典或改标 pending`);
|
|
1408
|
-
continue;
|
|
1409
|
-
}
|
|
1410
|
-
sidOk++;
|
|
1411
|
-
const fields = entryFieldSet(entry);
|
|
1412
|
-
for (const d of Array.isArray(it.dims) ? it.dims : []) {
|
|
1413
|
-
dimTotal++;
|
|
1414
|
-
if (fields.has(d))
|
|
1415
|
-
dimOk++;
|
|
1416
|
-
else
|
|
1417
|
-
chanMissing.push(`${rule} ${sid}.${d}`);
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
|
|
1422
|
-
console.log(`=== 登记表通道核验(${path.basename(p)})===`);
|
|
1423
|
-
console.log(` 登记项 ${items.length} 条 / 引用点位 ${sidOk} 个命中词典${pendingIds.size ? ` / ${pendingIds.size} 个标 pending(待补词典)` : ""}`);
|
|
1424
|
-
console.log(` 维度落点:${dimOk}/${dimTotal} 在词典有对应字段`);
|
|
1425
|
-
|
|
1426
|
-
if (pendingIds.size > 0) {
|
|
1427
|
-
console.log(`\n 待补词典的新点位(设计侧须 changes[] + pending 双写):`);
|
|
1428
|
-
for (const id of pendingIds)
|
|
1429
|
-
console.log(` - ${id}`);
|
|
1430
|
-
}
|
|
1431
|
-
if (chanMissing.length > 0) {
|
|
1432
|
-
const bySid = new Map();
|
|
1433
|
-
for (const line of chanMissing) {
|
|
1434
|
-
const sid = line.split(/\s+/)[1].split(".").slice(0, -1).join(".") || "?";
|
|
1435
|
-
if (!bySid.has(sid))
|
|
1436
|
-
bySid.set(sid, []);
|
|
1437
|
-
bySid.get(sid).push(line.split(".").pop());
|
|
1438
|
-
}
|
|
1439
|
-
console.log(`\n 通道缺口 ${chanMissing.length} 处(设计侧通用维度在词典无字段 → 须补字段或换用已有字段):`);
|
|
1440
|
-
for (const [sid, dims] of bySid) {
|
|
1441
|
-
const uniq = [...new Set(dims)];
|
|
1442
|
-
console.log(` - ${sid}: ${uniq.slice(0, verbose ? uniq.length : 8).join("、")}${!verbose && uniq.length > 8 ? ` …+${uniq.length - 8}` : ""}`);
|
|
1443
|
-
}
|
|
1444
|
-
if (verbose) {
|
|
1445
|
-
console.log(`\n 明细:`);
|
|
1446
|
-
for (const line of chanMissing)
|
|
1447
|
-
console.log(` ${line}`);
|
|
1448
|
-
}
|
|
1449
|
-
}
|
|
1450
|
-
|
|
1451
|
-
if (hard.length === 0 && chanMissing.length === 0) {
|
|
1452
|
-
console.log(`\n✔ 登记表通道核验通过:点位全部命中、维度全部有落点`);
|
|
1453
|
-
return;
|
|
1454
|
-
}
|
|
1455
|
-
if (hard.length > 0) {
|
|
1456
|
-
console.error(`\n✖ 硬缺口 ${hard.length} 处(登记表引用了不存在的点位 / 元数据非法)→ exit 1:`);
|
|
1457
|
-
for (const h of hard)
|
|
1458
|
-
console.error(` ✘ ${h}`);
|
|
1459
|
-
process.exit(1);
|
|
1460
|
-
}
|
|
1461
|
-
console.log(`\n⚠ 存在通道缺口(属「项目侧补词典」事项,不阻断匹配;补齐后还原度才可达 100%)。`);
|
|
1462
|
-
}
|
|
1463
|
-
|
|
1464
|
-
/** 输出结构施工声明(structure_ops):数据 / 结构驱动点位,只写 CSS 是无效声明 */
|
|
1465
|
-
function printStructureOps(change) {
|
|
1466
|
-
const ops = change && change.structure_ops;
|
|
1467
|
-
if (!Array.isArray(ops) || ops.length === 0)
|
|
1468
|
-
return;
|
|
1469
|
-
console.log(` ▸ 结构施工 structure_ops(${ops.length} 项,必须改数据 / 模板结构,不能只写 CSS):`);
|
|
1470
|
-
for (const op of ops) {
|
|
1471
|
-
if (!op || typeof op !== "object") {
|
|
1472
|
-
console.log(` - (非法条目)`);
|
|
1473
|
-
continue;
|
|
1474
|
-
}
|
|
1475
|
-
const parts = [`op=${op.op || "?"}`];
|
|
1476
|
-
if (op.node)
|
|
1477
|
-
parts.push(`node=${op.node}`);
|
|
1478
|
-
if (op.source)
|
|
1479
|
-
parts.push(`source=${op.source}`);
|
|
1480
|
-
if (op.from)
|
|
1481
|
-
parts.push(`from=${op.from}`);
|
|
1482
|
-
if (op.to)
|
|
1483
|
-
parts.push(`to=${op.to}`);
|
|
1484
|
-
console.log(` - ${parts.join(" ")}`);
|
|
1485
|
-
}
|
|
1486
|
-
}
|
|
1487
|
-
|
|
1488
|
-
/** 结构施工 / 落点 / 优先级 声明的协议校验(parse 期,追加进 checkBannedWords 违规列表) */
|
|
1489
|
-
function checkApplyFields(change, label, violations) {
|
|
1490
|
-
const STRUCT_OPS = ["add_node", "remove_node", "move_node", "render_branch", "bind_source"];
|
|
1491
|
-
if (change.layer !== undefined && change.layer !== null) {
|
|
1492
|
-
const lv = change.layer;
|
|
1493
|
-
if (typeof lv === "string") {
|
|
1494
|
-
if (!LAYER_VALUES.includes(lv))
|
|
1495
|
-
violations.push(`${label}.layer "${lv}" 不在枚举 [${LAYER_VALUES.join("/")}] 内`);
|
|
1496
|
-
}
|
|
1497
|
-
else if (typeof lv === "object" && !Array.isArray(lv)) {
|
|
1498
|
-
for (const [f, v] of Object.entries(lv)) {
|
|
1499
|
-
if (!LAYER_VALUES.includes(v))
|
|
1500
|
-
violations.push(`${label}.layer.${f} "${v}" 不在枚举 [${LAYER_VALUES.join("/")}] 内`);
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
else {
|
|
1504
|
-
violations.push(`${label}.layer 应为字符串或「字段 → 层级」对象`);
|
|
1505
|
-
}
|
|
1506
|
-
}
|
|
1507
|
-
if (change.apply_via !== undefined && change.apply_via !== null) {
|
|
1508
|
-
if (typeof change.apply_via !== "string" || !ID_RE_STRICT.test(change.apply_via))
|
|
1509
|
-
violations.push(`${label}.apply_via "${change.apply_via}" 应为语义锚点名(小写下划线、2~4 段),不是选择器 / 变量名`);
|
|
1510
|
-
}
|
|
1511
|
-
if (change.structure_ops !== undefined && change.structure_ops !== null) {
|
|
1512
|
-
if (!Array.isArray(change.structure_ops)) {
|
|
1513
|
-
violations.push(`${label}.structure_ops 必须为数组`);
|
|
1514
|
-
}
|
|
1515
|
-
else {
|
|
1516
|
-
for (const [i, op] of change.structure_ops.entries()) {
|
|
1517
|
-
const at = `${label}.structure_ops[${i}]`;
|
|
1518
|
-
if (!op || typeof op !== "object") {
|
|
1519
|
-
violations.push(`${at} 必须为对象`);
|
|
1520
|
-
continue;
|
|
1521
|
-
}
|
|
1522
|
-
if (!STRUCT_OPS.includes(op.op))
|
|
1523
|
-
violations.push(`${at}.op "${op.op}" 不在枚举 [${STRUCT_OPS.join("/")}] 内`);
|
|
1524
|
-
if (op.node !== undefined && (typeof op.node !== "string" || op.node.trim() === ""))
|
|
1525
|
-
violations.push(`${at}.node 应为非空语义节点名`);
|
|
1526
|
-
if (op.source !== undefined && (typeof op.source !== "string" || op.source.trim() === ""))
|
|
1527
|
-
violations.push(`${at}.source 应为非空语义数据源名`);
|
|
1528
|
-
}
|
|
1529
|
-
}
|
|
1530
|
-
}
|
|
1531
|
-
}
|
|
1532
|
-
|
|
1533
|
-
// ---- 词典驱动硬闸:suppress 收口(G1)+ 几何语境(G5) ----
|
|
1534
|
-
// 与 checkApplyFields(协议自洽性校验)不同,这两个闸的判据来自**词典条目自身**:
|
|
1535
|
-
// · suppress_rules:条目声明某 form 时必须收口的出厂互斥通道(声明 form = 换掉旧形态,不是叠加)
|
|
1536
|
-
// · preconditions 含「几何语境随值迁移」:几何值落宿主时必须同步清零内层盒同向属性
|
|
1537
|
-
// 缺失即「新旧形态叠加 / 两层 padding 相加」——值都对、观感错,是「还原度连乘公式」里
|
|
1538
|
-
// 通道正确率的天花板级杀手,因此与禁词同级(阻断,不是提示)。
|
|
1539
|
-
|
|
1540
|
-
/** 提取条目 suppress_rules:优先结构化字段;回落解析 forms 描述与 $note 中的「suppress 必含 xxx」内联标记 */
|
|
1541
|
-
function extractSuppressRules(entry) {
|
|
1542
|
-
const rules = {};
|
|
1543
|
-
if (entry && typeof entry.suppress_rules === "object" && entry.suppress_rules !== null) {
|
|
1544
|
-
for (const [form, channels] of Object.entries(entry.suppress_rules)) {
|
|
1545
|
-
rules[form] = Array.isArray(channels) ? channels.filter(c => typeof c === "string") : [];
|
|
1546
|
-
}
|
|
1547
|
-
}
|
|
1548
|
-
// 兜底:从 forms 描述文本提取「suppress 必含 名1/名2」标记(历史条目未结构化时仍能机检)
|
|
1549
|
-
const forms = entry?.forms;
|
|
1550
|
-
if (forms && typeof forms === "object" && !Array.isArray(forms)) {
|
|
1551
|
-
for (const [form, desc] of Object.entries(forms)) {
|
|
1552
|
-
if (typeof desc !== "string")
|
|
1553
|
-
continue;
|
|
1554
|
-
// 通道名 = 小写下划线标识符(可多项、以 、/, 分隔);遇非标识符字符(——、。等)即止
|
|
1555
|
-
for (const m of desc.matchAll(/suppress\s*必含\s*([a-z][a-z0-9_]*(?:[、,/][a-z][a-z0-9_]*)*)/gi)) {
|
|
1556
|
-
const channels = m[1].split(/[、,/]/).map(s => s.trim()).filter(Boolean);
|
|
1557
|
-
rules[form] = [...new Set([...(rules[form] || []), ...channels])];
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
return rules;
|
|
1562
|
-
}
|
|
1563
|
-
|
|
1564
|
-
/** G1:声明 form 时,词典 suppress_rules 要求的出厂互斥通道必须逐项声明在条目 suppress 数组 */
|
|
1565
|
-
function checkSuppress(change, entry, idLabel, violations) {
|
|
1566
|
-
if (typeof change.form !== "string" || !change.form || !entry)
|
|
1567
|
-
return;
|
|
1568
|
-
const rules = extractSuppressRules(entry);
|
|
1569
|
-
const required = rules[change.form];
|
|
1570
|
-
if (!required || required.length === 0)
|
|
1571
|
-
return;
|
|
1572
|
-
const declared = Array.isArray(change.suppress) ? change.suppress : [];
|
|
1573
|
-
const missing = required.filter(ch => !declared.includes(ch));
|
|
1574
|
-
if (missing.length > 0) {
|
|
1575
|
-
violations.push(
|
|
1576
|
-
`${idLabel}.suppress 缺口:词典要求 form="${change.form}" 必须收口出厂互斥通道 [${required.join("、")}],`
|
|
1577
|
-
+ `实际声明 [${declared.join("、") || "无"}],缺 [${missing.join("、")}]`
|
|
1578
|
-
+ ` —— 声明 form = 换掉旧形态而不是叠加;不收口 = 出厂旧装饰残留(失效模式:旧形态未收口时新旧装饰并存,如侧栏 pill 残留右竖条 → 双竖条)。`
|
|
1579
|
-
+ `通道名取该条目 forms/css_vars 的键名。`,
|
|
1580
|
-
);
|
|
1581
|
-
}
|
|
1582
|
-
}
|
|
1583
|
-
|
|
1584
|
-
/**
|
|
1585
|
-
* G5:几何语境自检 —— 宿主 + 内层之和必须等于设计值。
|
|
1586
|
-
* 词典 preconditions 含「几何语境随值迁移」标记的条目:style 声明几何字段且 layer 未显式
|
|
1587
|
-
* 指到 inner(即默认落 host)时,必须同步声明 suppress_inner(内层清零口径,纯语义描述),
|
|
1588
|
-
* 否则实际内边距 = 设计值 + 出厂内层自带值(失效模式:内层出厂 padding 未清零时设计值与出厂值相加,如 16px + 12px = 28px/侧 → 列挤坏)。
|
|
1589
|
-
*/
|
|
1590
|
-
function checkGeometryContext(change, entry, idLabel, violations) {
|
|
1591
|
-
if (!entry || !Array.isArray(entry.preconditions))
|
|
1592
|
-
return;
|
|
1593
|
-
if (!entry.preconditions.some(p => typeof p === "string" && p.includes("几何语境随值迁移")))
|
|
1594
|
-
return;
|
|
1595
|
-
const style = change.style && typeof change.style === "object" ? change.style : null;
|
|
1596
|
-
if (!style)
|
|
1597
|
-
return;
|
|
1598
|
-
const layer = change.layer && typeof change.layer === "object" && !Array.isArray(change.layer) ? change.layer : {};
|
|
1599
|
-
const geometryFields = Object.keys(style).filter(k => GEOMETRY_PAD_RE.test(k));
|
|
1600
|
-
if (geometryFields.length === 0)
|
|
1601
|
-
return;
|
|
1602
|
-
// 内层属性族字段(white-space/text-align 等)已逐字段声明 inner → 施工方已知内层结构,不重复拦截
|
|
1603
|
-
const hasInnerAware = Object.keys(layer).some(f => layer[f] === "inner") || geometryFields.every(f => layer[f] === "inner");
|
|
1604
|
-
if (hasInnerAware)
|
|
1605
|
-
return;
|
|
1606
|
-
const hostGeometry = geometryFields.filter(f => layer[f] !== "inner");
|
|
1607
|
-
if (hostGeometry.length === 0)
|
|
1608
|
-
return;
|
|
1609
|
-
const declaredInner = typeof change.suppress_inner === "string" ? change.suppress_inner.trim() : "";
|
|
1610
|
-
if (!declaredInner) {
|
|
1611
|
-
violations.push(
|
|
1612
|
-
`${idLabel}.suppress_inner 缺口:条目 preconditions 标记「几何语境随值迁移」(出厂内层文本盒自带内边距),`
|
|
1613
|
-
+ `style 几何字段 [${hostGeometry.join("、")}] 落宿主时必须同步声明 suppress_inner(内层清零口径)`
|
|
1614
|
-
+ ` —— 否则实际间距 = 设计值 + 出厂内层值,两层之和 ≠ 设计值(失效模式:设计值与出厂内层值相加,如 16px + 12px = 28px/侧 → 列宽被挤、标题截断)。`,
|
|
1615
|
-
);
|
|
1616
|
-
}
|
|
1617
|
-
}
|
|
1618
|
-
|
|
1619
|
-
/**
|
|
1620
|
-
* G6:层间令牌链对账 —— 跨条目的父子两层盒共享同一条令牌链时,同一间距只允许一个权威来源。
|
|
1621
|
-
* 词典 preconditions 含「层间令牌链对账」标记的条目:style 声明几何字段(padding/margin 族)
|
|
1622
|
-
* 时必须同步声明 suppress_inner(同链邻层接管/清零口径),否则父层设计值 + 子层同链令牌值
|
|
1623
|
-
* 物理叠加(失效模式:设计 24px 落父层 .app-main,子层 .main-content 的 --vts-margin 出厂 8px
|
|
1624
|
-
* 原样保留 → 24+8=32px/侧,「边距很大」且与效果图不符)。与 G5 的分工:G5 管「同一条目内
|
|
1625
|
-
* host+inner 两层盒」,本闸管「跨条目的父子两层盒 + 同链令牌」。
|
|
1626
|
-
*/
|
|
1627
|
-
function checkTokenChainContext(change, entry, idLabel, violations) {
|
|
1628
|
-
if (!entry || !Array.isArray(entry.preconditions))
|
|
1629
|
-
return;
|
|
1630
|
-
if (!entry.preconditions.some(p => typeof p === "string" && p.includes("层间令牌链对账")))
|
|
1631
|
-
return;
|
|
1632
|
-
const style = change.style && typeof change.style === "object" ? change.style : null;
|
|
1633
|
-
if (!style)
|
|
1634
|
-
return;
|
|
1635
|
-
const geometryFields = Object.keys(style).filter(k => GEOMETRY_PAD_RE.test(k));
|
|
1636
|
-
if (geometryFields.length === 0)
|
|
1637
|
-
return;
|
|
1638
|
-
const declaredInner = typeof change.suppress_inner === "string" ? change.suppress_inner.trim() : "";
|
|
1639
|
-
if (!declaredInner) {
|
|
1640
|
-
violations.push(
|
|
1641
|
-
`${idLabel}.suppress_inner 缺口:条目 preconditions 标记「层间令牌链对账」(父子两层盒共享同一条间距令牌链,同一间距只允许一个权威来源),`
|
|
1642
|
-
+ `style 几何字段 [${geometryFields.join("、")}] 必须同步声明 suppress_inner(同链邻层接管/清零口径:写明间距由哪层承载、另一层如何归零)`
|
|
1643
|
-
+ ` —— 否则父层设计值 + 子层同链令牌值物理叠加(失效模式:设计 24px 落父层,子层 --vts-margin 出厂 8px 原样保留 → 24+8=32px/侧,内容区边距显著大于效果图)。`
|
|
1644
|
-
+ `施工建议:优先走令牌通道(在消费点作用域重映射同链令牌,两层同变),不抢写物理 padding。`,
|
|
1645
|
-
);
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
|
|
1649
|
-
/**
|
|
1650
|
-
* G5b:内层盒属性族 · 词典完整性闸 —— INNER_ONLY_PROPS 字段(nowrap/text_align/line_height 等)
|
|
1651
|
-
* 必须能落到 inner 层,否则这些文本控制属性无处可落、必然被 EP 内层 .cell 出厂样式吞掉。
|
|
1652
|
-
* 触发条件(零误报):仅当条目本身「声明了 INNER_ONLY_PROPS 字段」却「没有 inner 锚点」时报警
|
|
1653
|
-
* —— 这是词典侧的能力缺口(缺 inner 锚点),须补 anchors 里的 inner 条目,而非设计侧能补救。
|
|
1654
|
-
* 条目带 inner 锚点时由 resolveAnchorTarget 自动路由兜底,本闸不重复拦截(避免噪音)。
|
|
1655
|
-
*/
|
|
1656
|
-
function checkInnerOnlyProps(change, entry, idLabel, violations) {
|
|
1657
|
-
if (!entry || !Array.isArray(entry.anchors))
|
|
1658
|
-
return;
|
|
1659
|
-
const hasInner = entry.anchors.some(a => a && a.kind === "class" && a.layer === "inner");
|
|
1660
|
-
if (hasInner)
|
|
1661
|
-
return;
|
|
1662
|
-
const style = change.style && typeof change.style === "object" ? change.style : null;
|
|
1663
|
-
if (!style)
|
|
1664
|
-
return;
|
|
1665
|
-
const offenders = Object.keys(style).filter(f => INNER_ONLY_PROPS.has(f));
|
|
1666
|
-
if (offenders.length === 0)
|
|
1667
|
-
return;
|
|
1668
|
-
violations.push(
|
|
1669
|
-
`${idLabel}.anchors 完整性缺口:条目 style 声明了内层盒属性族 [${offenders.join("、")}]`
|
|
1670
|
-
+ `(文本控制/行高/对齐类,必须落 inner 层才不被 EP 内层 .cell 出厂样式覆盖),`
|
|
1671
|
-
+ `但本条目 anchors 无 inner 锚点 —— 这些字段无处可落,将出现「竖排换行 / 对齐失效」。`
|
|
1672
|
-
+ `修复:在 anchors 补 { kind:"class", layer:"inner", target:"… > .cell" } 承载选择器。`,
|
|
1673
|
-
);
|
|
1674
|
-
}
|
|
1675
|
-
|
|
1676
|
-
/**
|
|
1677
|
-
* G7:field_forms 声明-消费闭合 —— style 字段必须被当前 form 消费。
|
|
1678
|
-
* 词典条目声明 field_forms(字段 → 合法 form 枚举)后:变更集 style 里的字段若不在
|
|
1679
|
-
* 当前 form 的合法集内 = 字段无消费者、将被静默丢弃(失效模式:变更集声明 pill +
|
|
1680
|
-
* active_indicator_* 三字段,设计层按 pill 施工把竖条令牌置透明,效果图要求的左竖条消失——
|
|
1681
|
-
* 「声明了却不消费」此前无任何闸门拦截)。修复路径:换 form(含组合形态)或删字段。
|
|
1682
|
-
*/
|
|
1683
|
-
function checkFieldForms(change, entry, idLabel, violations) {
|
|
1684
|
-
if (!entry || typeof entry.field_forms !== "object" || entry.field_forms === null)
|
|
1685
|
-
return;
|
|
1686
|
-
const style = change.style && typeof change.style === "object" ? change.style : null;
|
|
1687
|
-
if (!style)
|
|
1688
|
-
return;
|
|
1689
|
-
const form = typeof change.form === "string" && change.form ? change.form : null;
|
|
1690
|
-
// 无 form 声明时跳过(纯样式条目,字段消费不依赖形态);无该字段映射时跳过
|
|
1691
|
-
if (!form)
|
|
1692
|
-
return;
|
|
1693
|
-
for (const [field, legalForms] of Object.entries(entry.field_forms)) {
|
|
1694
|
-
if (!(field in style))
|
|
1695
|
-
continue;
|
|
1696
|
-
if (!Array.isArray(legalForms) || legalForms.length === 0)
|
|
1697
|
-
continue;
|
|
1698
|
-
if (!legalForms.includes(form)) {
|
|
1699
|
-
violations.push(
|
|
1700
|
-
`${idLabel}.field_forms 闭合违规:style 字段「${field}」仅在 form [${legalForms.join("、")}] 下有消费者,`
|
|
1701
|
-
+ `当前 form="${form}" 不消费该字段 → 声明将被静默丢弃`
|
|
1702
|
-
+ ` —— 修复:换用消费该字段的 form(含组合形态),或删除该字段。禁止「声明字段 + 互斥 form」的组合(失效模式:pill + 指示条通道未收口 → 指示条被设计层置透明,视觉消失)。`,
|
|
1703
|
-
);
|
|
1704
|
-
}
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
// 本节的模块级常量(AUDIT_SKIP_DIRS / STRUCT_EXT / STYLE_EXT / DESIGN_LAYER_RE /
|
|
1710
|
-
// BUILTIN_BARE_CLASSES)已在文件顶部声明——模块级常量必须在 main() 调用前初始化,勿下移。
|
|
1711
|
-
|
|
1712
|
-
function escapeRe(s) {
|
|
1713
|
-
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1714
|
-
}
|
|
1715
|
-
|
|
1716
|
-
/** 递归收集目标项目源码(剔除依赖 / 产物目录),返回 [{ file, text, kind }] */
|
|
1717
|
-
function collectProjectSources(root) {
|
|
1718
|
-
const out = [];
|
|
1719
|
-
const walk = (dir, depth) => {
|
|
1720
|
-
if (depth > 10)
|
|
1721
|
-
return;
|
|
1722
|
-
let items;
|
|
1723
|
-
try {
|
|
1724
|
-
items = readdirSync(dir, { withFileTypes: true });
|
|
1725
|
-
}
|
|
1726
|
-
catch {
|
|
1727
|
-
return;
|
|
1728
|
-
}
|
|
1729
|
-
for (const it of items) {
|
|
1730
|
-
const name = it.name;
|
|
1731
|
-
if (name.startsWith("."))
|
|
1732
|
-
continue;
|
|
1733
|
-
const p = path.join(dir, name);
|
|
1734
|
-
if (it.isDirectory()) {
|
|
1735
|
-
if (AUDIT_SKIP_DIRS.has(name))
|
|
1736
|
-
continue;
|
|
1737
|
-
walk(p, depth + 1);
|
|
1738
|
-
continue;
|
|
1739
|
-
}
|
|
1740
|
-
const ext = path.extname(name).toLowerCase();
|
|
1741
|
-
const kind = STRUCT_EXT.has(ext) ? "struct" : (STYLE_EXT.has(ext) ? "style" : null);
|
|
1742
|
-
if (!kind)
|
|
1743
|
-
continue;
|
|
1744
|
-
try {
|
|
1745
|
-
out.push({ file: path.relative(root, p), text: readFileSync(p, "utf-8"), kind });
|
|
1746
|
-
}
|
|
1747
|
-
catch { /* 跳过不可读文件 */ }
|
|
1748
|
-
}
|
|
1749
|
-
};
|
|
1750
|
-
walk(root, 0);
|
|
1751
|
-
return out;
|
|
1752
|
-
}
|
|
1753
|
-
|
|
1754
|
-
/** 从选择器串提取需「挂载」的 token(类名 / 属性名);剔除伪类与组件库内置前缀 */
|
|
1755
|
-
function extractMountTokens(selector) {
|
|
1756
|
-
const tokens = new Set();
|
|
1757
|
-
if (typeof selector !== "string" || selector.trim() === "")
|
|
1758
|
-
return [];
|
|
1759
|
-
const cleaned = selector.replace(/::?[a-zA-Z-]+(\([^)]*\))?/g, " ");
|
|
1760
|
-
let m;
|
|
1761
|
-
const classRe = /\.([a-zA-Z_][\w-]*)/g;
|
|
1762
|
-
while ((m = classRe.exec(cleaned)) !== null) {
|
|
1763
|
-
const cls = m[1];
|
|
1764
|
-
if (/^(el-|is-|v-modal)/.test(cls) || BUILTIN_BARE_CLASSES.has(cls))
|
|
1765
|
-
continue;
|
|
1766
|
-
tokens.add(cls);
|
|
1767
|
-
}
|
|
1768
|
-
const attrRe = /\[([a-zA-Z_][\w-]*)/g;
|
|
1769
|
-
while ((m = attrRe.exec(cleaned)) !== null)
|
|
1770
|
-
tokens.add(`[${m[1]}]`);
|
|
1771
|
-
return [...tokens];
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
/** 在源码集中查找 token 的挂载情况 */
|
|
1775
|
-
function findMount(token, sources) {
|
|
1776
|
-
const needle = token.startsWith("[") ? token.slice(1, -1) : token;
|
|
1777
|
-
const re = new RegExp(`(^|[^\\w-])${escapeRe(needle)}([^\\w-]|$)`);
|
|
1778
|
-
const hits = sources.filter(s => re.test(s.text));
|
|
1779
|
-
return {
|
|
1780
|
-
struct: hits.filter(h => h.kind === "struct" && !DESIGN_LAYER_RE.test(h.file)),
|
|
1781
|
-
style: hits.filter(h => h.kind === "style" && !DESIGN_LAYER_RE.test(h.file)),
|
|
1782
|
-
designLayer: hits.filter(h => DESIGN_LAYER_RE.test(h.file)),
|
|
1783
|
-
};
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
/** 死通道检查:CSS 变量在项目中是否有 var() 消费者 */
|
|
1787
|
-
function findVariableConsumer(varName, sources) {
|
|
1788
|
-
const re = new RegExp(`var\\(\\s*${escapeRe(varName)}\\s*[,)]`);
|
|
1789
|
-
return sources.filter(s => re.test(s.text)).map(s => s.file);
|
|
1790
|
-
}
|
|
1791
|
-
|
|
1792
|
-
/** 依据命中情况判定 token 的挂载状态 */
|
|
1793
|
-
function judgeMount(token, where, hit) {
|
|
1794
|
-
if (hit.struct.length > 0)
|
|
1795
|
-
return [];
|
|
1796
|
-
const fmt = arr => arr.map(h => (typeof h === "string" ? h : `${h.file}(${(h.reasons || []).join(";")})`)).slice(0, 2).join("、");
|
|
1797
|
-
if (hit.style.length > 0)
|
|
1798
|
-
return [`挂载点可疑:${where} 的「${token}」只出现在样式文件(${fmt(hit.style)}),未在模板 / 脚本里挂载 —— 规则可能永不命中(历史缺口:列未绑 class-name,金额列右对齐 / 缩略图尺寸失效)`];
|
|
1799
|
-
if (hit.designLayer.length > 0)
|
|
1800
|
-
return [`挂载点缺失:${where} 的「${token}」只出现在设计层样式(${hit.designLayer.slice(0, 2).join("、")}),项目里既无结构挂载也无出厂对应 —— 规则不会生效`];
|
|
1801
|
-
return [`挂载点缺失:${where} 的「${token}」在项目中未找到任何出现位置(选择器永不命中)`];
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
/** 单条目审计:selector / anchors 挂载点 + css_var 消费者 */
|
|
1805
|
-
function auditEntry(entry, sources) {
|
|
1806
|
-
const issues = [];
|
|
1807
|
-
const miss = (token) => {
|
|
1808
|
-
// 设计层已承载同类名 → 挂载点可疑降级为提示(不阻断):C 通道由 design-shell.scss 兜底
|
|
1809
|
-
if (hasDesignLayerTakeover(token, sources))
|
|
1810
|
-
return null;
|
|
1811
|
-
return true;
|
|
1812
|
-
};
|
|
1813
|
-
for (const t of extractMountTokens(entry.selector)) {
|
|
1814
|
-
if (miss(t))
|
|
1815
|
-
issues.push(...judgeMount(t, "selector", findMount(t, sources)));
|
|
1816
|
-
}
|
|
1817
|
-
|
|
1818
|
-
const anchors = Array.isArray(entry.anchors) ? entry.anchors : [];
|
|
1819
|
-
for (const a of anchors) {
|
|
1820
|
-
if (!a || typeof a.target !== "string")
|
|
1821
|
-
continue;
|
|
1822
|
-
if (a.kind === "css_var") {
|
|
1823
|
-
const consumers = findVariableConsumer(a.target, sources);
|
|
1824
|
-
if (consumers.length === 0) {
|
|
1825
|
-
// 两类合法豁免(防误报):
|
|
1826
|
-
// ① --el-* 组件库变量:消费者在 element-plus 主题库(node_modules 不在扫描范围),
|
|
1827
|
-
// 项目源码无 var() 属正常 —— EP runtime 必然消费。
|
|
1828
|
-
// ② 设计层已接管变量定义(design-shell 显式赋值):suppress / 接管属故意行为
|
|
1829
|
-
// (词典 preconditions 有明示的条目,如 --vts-theme-menu-active-before)。
|
|
1830
|
-
if (a.target.startsWith("--el-")) {
|
|
1831
|
-
console.log(` ℹ ${a.target} 项目源码无 var() 消费者 —— 由组件库运行时消费(node_modules 不在扫描范围),不按死通道处理`);
|
|
1832
|
-
}
|
|
1833
|
-
else if (designLayerDefines(a.target, sources)) {
|
|
1834
|
-
console.log(` ℹ ${a.target} 项目源码无 var() 消费者 —— 设计层已接管该变量定义(suppress / 接管属故意行为),不按死通道处理`);
|
|
1835
|
-
}
|
|
1836
|
-
else {
|
|
1837
|
-
issues.push(`死通道:CSS 变量 ${a.target} 在项目中无 var() 消费者(只定义不消费 = 通道死路,翻译再准也不生效)`);
|
|
1838
|
-
}
|
|
1839
|
-
}
|
|
1840
|
-
}
|
|
1841
|
-
else {
|
|
1842
|
-
for (const t of extractMountTokens(a.target)) {
|
|
1843
|
-
if (miss(t))
|
|
1844
|
-
issues.push(...judgeMount(t, `anchors(${a.layer || "未标层级"})`, findMount(t, sources)));
|
|
1845
|
-
}
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
return [...new Set(issues)];
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
/** 判定「设计层已承载」的豁免:条目 anchors/selector 的类词元在设计层文件出现时,
|
|
1852
|
-
* 挂载点可疑可降级为提示 —— 设计层覆写本身就是合法落点(design-shell.scss 是 C 通道终点)。 */
|
|
1853
|
-
function hasDesignLayerTakeover(token, sources) {
|
|
1854
|
-
const needle = token.startsWith("[") ? token.slice(1, -1) : token;
|
|
1855
|
-
const re = new RegExp(`(^|[^\\w-])${escapeRe(needle)}([^\\w-]|$)`);
|
|
1856
|
-
return sources.some(s => DESIGN_LAYER_RE.test(s.file) && re.test(s.text));
|
|
1857
|
-
}
|
|
1858
|
-
|
|
1859
|
-
/** 设计层是否显式定义了该 CSS 变量(suppress / 接管动作),定义在非设计层的令牌宿主不算 */
|
|
1860
|
-
function designLayerDefines(varName, sources) {
|
|
1861
|
-
const re = new RegExp(`${escapeRe(varName)}\\s*:`);
|
|
1862
|
-
return sources.some(s => DESIGN_LAYER_RE.test(s.file) && re.test(s.text));
|
|
1863
|
-
}
|
|
1864
|
-
|
|
1865
|
-
function cmdAudit(args, dataset) {
|
|
1866
|
-
const projIdx = args.indexOf("--project");
|
|
1867
|
-
const projectRoot = projIdx > -1 ? args[projIdx + 1] : null;
|
|
1868
|
-
if (!projectRoot) {
|
|
1869
|
-
console.error("✖ 缺少目标项目路径:node parser/index.js audit --project <项目根目录> [<changeset.json…>]");
|
|
1870
|
-
process.exit(1);
|
|
1871
|
-
}
|
|
1872
|
-
const root = path.resolve(projectRoot);
|
|
1873
|
-
let stat;
|
|
1874
|
-
try {
|
|
1875
|
-
stat = statSync(root);
|
|
1876
|
-
}
|
|
1877
|
-
catch {
|
|
1878
|
-
console.error(`✖ 目标项目路径不存在:${root}`);
|
|
1879
|
-
process.exit(1);
|
|
1880
|
-
}
|
|
1881
|
-
if (!stat.isDirectory()) {
|
|
1882
|
-
console.error(`✖ --project 参数应为目录:${root}`);
|
|
1883
|
-
process.exit(1);
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
const files = args.filter((a, i) => !a.startsWith("--") && i !== projIdx + 1);
|
|
1887
|
-
const sources = collectProjectSources(root);
|
|
1888
|
-
const index = buildMergedIndex(dataset);
|
|
1889
|
-
|
|
1890
|
-
// 给了变更集 → 只审被引用的点位(含 apply_via 指向的锚点来源);否则审词典全量
|
|
1891
|
-
let targetIds;
|
|
1892
|
-
const loadedChangesets = []; // G2/G4:保留完整变更集供施工证据与 T 通道对账
|
|
1893
|
-
if (files.length > 0) {
|
|
1894
|
-
const wanted = new Set();
|
|
1895
|
-
for (const f of files) {
|
|
1896
|
-
let cs;
|
|
1897
|
-
try {
|
|
1898
|
-
cs = JSON.parse(readFileSync(path.resolve(f), "utf-8"));
|
|
1899
|
-
}
|
|
1900
|
-
catch (e) {
|
|
1901
|
-
console.error(`✖ 变更集读取失败 ${f}:${e.message}`);
|
|
1902
|
-
process.exit(1);
|
|
1903
|
-
}
|
|
1904
|
-
loadedChangesets.push(cs);
|
|
1905
|
-
for (const c of cs.changes || []) {
|
|
1906
|
-
if (c.id)
|
|
1907
|
-
wanted.add(c.id);
|
|
1908
|
-
if (typeof c.apply_via === "string")
|
|
1909
|
-
wanted.add(c.apply_via);
|
|
1910
|
-
}
|
|
1911
|
-
}
|
|
1912
|
-
targetIds = [...wanted];
|
|
1913
|
-
}
|
|
1914
|
-
else {
|
|
1915
|
-
targetIds = Object.keys(index);
|
|
1916
|
-
}
|
|
1917
|
-
|
|
1918
|
-
console.log(`审计目标:${root}`);
|
|
1919
|
-
console.log(`源码文件:${sources.length}(已剔除 node_modules / dist 等产物目录)`);
|
|
1920
|
-
console.log(`检查条目:${targetIds.length}${files.length > 0 ? "(仅变更集引用点位)" : "(词典全量)"}`);
|
|
1921
|
-
|
|
1922
|
-
// ---- G2:structure_ops 施工证据(台账落地断言)----
|
|
1923
|
-
// 数据 / 结构驱动点位必须在项目源码里找到施工痕迹,纯 CSS 落点声明 structure_ops = 冒充施工:
|
|
1924
|
-
// add_node/remove_node/move_node/render_branch/bind_source → 按条目 selector/anchors 的语义类名
|
|
1925
|
-
// 在**非设计层**源码(含模板与数据脚本)里查证该节点/绑定的存在性(结构文件命中才算兑现)。
|
|
1926
|
-
// 失效模式:结构操作只声明不施工、以 CSS 覆写冒充 → 按钮间距 / 列宽永不达标。
|
|
1927
|
-
let structIssues = 0;
|
|
1928
|
-
if (loadedChangesets.length > 0) {
|
|
1929
|
-
console.log(`\n=== structure_ops 施工证据(G2 · 模板 diff 取证)===`);
|
|
1930
|
-
for (const cs of loadedChangesets) {
|
|
1931
|
-
for (const [i, c] of (cs.changes || []).entries()) {
|
|
1932
|
-
const ops = Array.isArray(c.structure_ops) ? c.structure_ops : [];
|
|
1933
|
-
if (ops.length === 0 || !c.id)
|
|
1934
|
-
continue;
|
|
1935
|
-
const entry = index[c.id];
|
|
1936
|
-
const probes = collectStructProbes(entry, c);
|
|
1937
|
-
const evidence = probes
|
|
1938
|
-
.map(p => ({ probe: p, hits: findStructEvidence(p, sources) }))
|
|
1939
|
-
.filter(x => x.hits.length > 0);
|
|
1940
|
-
if (probes.length === 0) {
|
|
1941
|
-
console.log(` ⚠ changes[${i}] ${c.id}:structure_ops ${ops.length} 项,但词典条目无 selector/anchors 可提取证据探针 → 人工核验施工台账(design-apply §施工台账)`);
|
|
1942
|
-
continue;
|
|
1943
|
-
}
|
|
1944
|
-
if (evidence.length === 0) {
|
|
1945
|
-
console.log(` ✘ ${c.id}:structure_ops 声明 [${ops.map(o => o.op).join("/")}],但源码中找不到任何施工证据探针 [${probes.join("、")}](非设计层结构文件零命中)`);
|
|
1946
|
-
console.log(` → 纯 CSS 覆写不能兑现结构操作;须改模板/数据并在交付说明附施工台账,或从变更集移除该声明`);
|
|
1947
|
-
structIssues++;
|
|
1948
|
-
}
|
|
1949
|
-
else {
|
|
1950
|
-
const files1 = [...new Set(evidence.flatMap(x => x.hits.map(h => h.file)))];
|
|
1951
|
-
console.log(` ✔ ${c.id}:structure_ops 证据命中 ${evidence.length}/${probes.length} 探针(${files1.slice(0, 3).join("、")})`);
|
|
1952
|
-
}
|
|
1953
|
-
}
|
|
1954
|
-
}
|
|
1955
|
-
if (structIssues === 0)
|
|
1956
|
-
console.log(` ✔ 全部 structure_ops 均有源码施工证据`);
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
// ---- G4:T 通道对账(运行时令牌 ↔ 变更集主题/形态声明)----
|
|
1960
|
-
// T 通道(platform-config.json / 运行时令牌)优先级最高、也最易被遗忘:
|
|
1961
|
-
// 主题色不一致 → 全局主色错;页签 ShowModel 与 form 叠加 → 新旧形态并存。
|
|
1962
|
-
let tIssues = 0;
|
|
1963
|
-
if (loadedChangesets.length > 0) {
|
|
1964
|
-
console.log(`\n=== T 通道对账(G4 · platform-config ↔ 变更集声明)===`);
|
|
1965
|
-
const pcPath = path.join(root, "public", "platform-config.json");
|
|
1966
|
-
let pc = null;
|
|
1967
|
-
try {
|
|
1968
|
-
pc = JSON.parse(readFileSync(pcPath, "utf-8"));
|
|
1969
|
-
console.log(` platform-config:${pcPath}`);
|
|
1970
|
-
}
|
|
1971
|
-
catch {
|
|
1972
|
-
console.log(` ⚠ 未能读取 ${pcPath}(无 public/platform-config.json)→ 跳过 T 通道对账`);
|
|
1973
|
-
}
|
|
1974
|
-
if (pc) {
|
|
1975
|
-
const tChecks = [];
|
|
1976
|
-
// ① 主题色:global.brand_primary(语义值经译表解析为 hex)↔ EpThemeColor
|
|
1977
|
-
const declaredBrand = loadedChangesets.map(cs => cs.global?.brand_primary).find(Boolean);
|
|
1978
|
-
if (declaredBrand && typeof declaredBrand === "string") {
|
|
1979
|
-
const allDict = buildSemanticValueIndex(dataset);
|
|
1980
|
-
const hex = resolveSemanticValue(declaredBrand, allDict) || (/^#/.test(declaredBrand) ? declaredBrand : null);
|
|
1981
|
-
if (hex && pc.EpThemeColor && String(pc.EpThemeColor).toLowerCase() !== String(hex).toLowerCase()) {
|
|
1982
|
-
tChecks.push(`✘ 主题色不一致:变更集 brand_primary=${declaredBrand}(解析 ${hex}),platform-config.EpThemeColor=${pc.EpThemeColor} → 改 platform-config 或运行时令牌持久化(T 通道先于 C 通道)`);
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
// ② 页签形态:tabs form ↔ ShowModel(出厂 flat/chrome 形态都运行在 ShowModel=card 结构上)
|
|
1986
|
-
const tabsForm = loadedChangesets
|
|
1987
|
-
.flatMap(cs => (cs.changes || []).filter(c => c.id === "admin.layout.tabs" && typeof c.form === "string").map(c => c.form))
|
|
1988
|
-
.find(Boolean);
|
|
1989
|
-
if (tabsForm && pc.ShowModel && !["flat", "chrome", "card"].includes(pc.ShowModel) === false) {
|
|
1990
|
-
// flat 与 card 共用 card 结构,chrome 形态要求 ShowModel=chrome
|
|
1991
|
-
if (tabsForm === "chrome" && pc.ShowModel !== "chrome") {
|
|
1992
|
-
tChecks.push(`✘ 页签形态不一致:变更集 form=chrome 需 ShowModel=chrome,platform-config.ShowModel=${pc.ShowModel}`);
|
|
1993
|
-
}
|
|
1994
|
-
else if (tabsForm !== "chrome" && pc.ShowModel === "chrome") {
|
|
1995
|
-
tChecks.push(`✘ 页签形态不一致:变更集 form=${tabsForm}(card/flat 结构),platform-config.ShowModel=chrome → 运行时仍是梯形页签,设计层覆写不改变结构形态`);
|
|
1996
|
-
}
|
|
1997
|
-
}
|
|
1998
|
-
// ③ 显隐类 D 通道开关:变更集 visible:false 的点位若词典标了 visible_prop,必须核对开关
|
|
1999
|
-
const visibleProps = [];
|
|
2000
|
-
for (const cs of loadedChangesets) {
|
|
2001
|
-
for (const c of (cs.changes || [])) {
|
|
2002
|
-
if (c.visible !== false || !c.id)
|
|
2003
|
-
continue;
|
|
2004
|
-
const entry = index[c.id] || index[String(c.id).split(".").slice(0, -1).join(".")];
|
|
2005
|
-
if (entry?.visible_prop)
|
|
2006
|
-
visibleProps.push({ id: c.id, prop: entry.visible_prop });
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
for (const vp of visibleProps) {
|
|
2010
|
-
const m = /^(\w+)(platform-config\.json)$/.exec(vp.prop) || /^(\w+)$/.exec(vp.prop.split("(")[0].trim());
|
|
2011
|
-
const key = m ? m[1] : null;
|
|
2012
|
-
if (key && key in pc && pc[key] === true) {
|
|
2013
|
-
tChecks.push(`✘ 显隐不一致:${vp.id} 声明 visible:false,但词典 visible_prop=${vp.prop} 且 platform-config.${key}=${pc[key]} → 开关未关,点位仍渲染(D 通道先于 S/C 通道)`);
|
|
2014
|
-
}
|
|
2015
|
-
}
|
|
2016
|
-
if (tChecks.length === 0) {
|
|
2017
|
-
console.log(` ✔ T/D 通道一致:主题色、页签形态、显隐开关均与变更集声明对齐`);
|
|
2018
|
-
}
|
|
2019
|
-
else {
|
|
2020
|
-
for (const t of tChecks) {
|
|
2021
|
-
console.log(` ${t}`);
|
|
2022
|
-
tIssues++;
|
|
2023
|
-
}
|
|
2024
|
-
console.log(` 处置:T 通道(platform-config / 运行时令牌持久化)优先级最高,先清 T 再谈 S/C;存量 localStorage 持久化值须一并断言清零。`);
|
|
2025
|
-
}
|
|
2026
|
-
}
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
let problems = 0;
|
|
2030
|
-
const clean = [];
|
|
2031
|
-
for (const id of targetIds) {
|
|
2032
|
-
if (id === GLOBAL_PSEUDO_ID) {
|
|
2033
|
-
// 与 match 路径同口径:伪点位对应变更集顶层 global 节点(brand_primary 等基座令牌),
|
|
2034
|
-
// 走 :root/design-tokens 令牌通道对账,不入词典、不做词典点检。
|
|
2035
|
-
console.log(`\nℹ ${id} —— 设计侧伪点位(变更集顶层 global 节点,不入词典):基座令牌走 T 通道对账,词典点检豁免`);
|
|
2036
|
-
continue;
|
|
2037
|
-
}
|
|
2038
|
-
const entry = index[id];
|
|
2039
|
-
if (!entry) {
|
|
2040
|
-
console.log(`\n✘ ${id} —— 词典中无此条目(L3 新增点位,须当场落地并回写临时映射)`);
|
|
2041
|
-
problems++;
|
|
2042
|
-
continue;
|
|
2043
|
-
}
|
|
2044
|
-
const issues = auditEntry(entry, sources);
|
|
2045
|
-
if (issues.length === 0) {
|
|
2046
|
-
clean.push(id);
|
|
2047
|
-
continue;
|
|
2048
|
-
}
|
|
2049
|
-
console.log(`\n✘ ${id}`);
|
|
2050
|
-
for (const it of issues)
|
|
2051
|
-
console.log(` - ${it}`);
|
|
2052
|
-
problems += issues.length;
|
|
2053
|
-
}
|
|
2054
|
-
if (clean.length > 0)
|
|
2055
|
-
console.log(`\n✔ 通过:${clean.join("、")}`);
|
|
2056
|
-
|
|
2057
|
-
console.log(`\n汇总:${targetIds.length} 条点位,${problems} 个挂载 / 通道问题`);
|
|
2058
|
-
if (problems > 0) {
|
|
2059
|
-
console.log(`处置口径:挂载点缺失 / 死通道属**能力缺口** —— 当场按 structure_ops 补挂载或补消费者,交付说明单列申报;不得以「词典没建」为由跳过。`);
|
|
2060
|
-
process.exitCode = 1;
|
|
2061
|
-
}
|
|
2062
|
-
else {
|
|
2063
|
-
console.log(`✔ 无挂载 / 通道问题(渲染态是否一致仍需 §验收比对确认:静态审计查不出「值对了、位置不对」)`);
|
|
2064
|
-
}
|
|
2065
|
-
// G2/G4 违规同样置退出码(与挂载/通道问题同级 —— 施工没兑现、T 通道没清零 = 交付不完整)
|
|
2066
|
-
if (structIssues > 0) {
|
|
2067
|
-
console.log(`✖ structure_ops 施工证据缺失 ${structIssues} 处(G2)`);
|
|
2068
|
-
process.exitCode = 1;
|
|
2069
|
-
}
|
|
2070
|
-
if (tIssues > 0) {
|
|
2071
|
-
console.log(`✖ T 通道对账不一致 ${tIssues} 处(G4)`);
|
|
2072
|
-
process.exitCode = 1;
|
|
2073
|
-
}
|
|
2074
|
-
}
|
|
2075
|
-
|
|
2076
|
-
/** G2 辅助:从词典条目与 structure_ops 声明中提取「施工证据探针」(语义类名 / 节点名,不含技术词)。
|
|
2077
|
-
* 探针来源优先级:条目 anchors 的 class target 词元 → selector 词元 → op.node 语义名。 */
|
|
2078
|
-
function collectStructProbes(entry, change) {
|
|
2079
|
-
const probes = new Set();
|
|
2080
|
-
const pushTokens = (sel) => {
|
|
2081
|
-
if (typeof sel !== "string")
|
|
2082
|
-
return;
|
|
2083
|
-
for (const m of sel.matchAll(/\.([a-zA-Z][\w-]*__(?:divider|tag|icon|close|title|badge|arrow)[\w-]*|[a-zA-Z][\w-]*-divider[\w-]*|vts-[\w-]+|re-table-operate__[\w-]+)/g))
|
|
2084
|
-
probes.add(m[1]);
|
|
2085
|
-
};
|
|
2086
|
-
if (entry) {
|
|
2087
|
-
for (const a of (Array.isArray(entry.anchors) ? entry.anchors : [])) {
|
|
2088
|
-
if (a && a.kind === "class" && typeof a.target === "string")
|
|
2089
|
-
pushTokens(a.target);
|
|
2090
|
-
}
|
|
2091
|
-
pushTokens(entry.selector);
|
|
2092
|
-
}
|
|
2093
|
-
// op.node 语义名(如 tab_divider):既可能是设计层伪元素名(tab_divider → ::before 不可探测),
|
|
2094
|
-
// 也可能是真实类名(status_tag);转词元时保留下划线形式,匹配源码任意出现(类名/常量名/注释均可)
|
|
2095
|
-
for (const op of (Array.isArray(change.structure_ops) ? change.structure_ops : [])) {
|
|
2096
|
-
if (op && typeof op.node === "string" && op.node.trim())
|
|
2097
|
-
probes.add(op.node.trim());
|
|
2098
|
-
}
|
|
2099
|
-
return [...probes];
|
|
2100
|
-
}
|
|
2101
|
-
|
|
2102
|
-
/** G2 辅助:在非设计层源码中查找探针出现(结构文件命中 = 施工痕迹;仅样式文件命中不算) */
|
|
2103
|
-
function findStructEvidence(probe, sources) {
|
|
2104
|
-
const re = new RegExp(`(?:class|className|classList|node|name|id|label|key|prop)["']?\\s*[:=]\\s*["']${escapeRe(probe)}["']|${escapeRe(probe)}`);
|
|
2105
|
-
return sources.filter(h => h.kind === "struct" && !DESIGN_LAYER_RE.test(h.file) && re.test(h.text))
|
|
2106
|
-
.map(h => ({ file: h.file }));
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
// ---- verify:渲染态逐点验收( 补丁 3 · 闭环验收回路) ----
|
|
2110
|
-
/**
|
|
2111
|
-
* 比对两份 computed style 转储:oracle(效果图期望)vs actual(项目实测)。
|
|
2112
|
-
* 转储格式:{ "<语义ID>": { "<css属性>": "<值>", ... }, ... }
|
|
2113
|
-
* 期望与实测由**浏览器链路**抽取(效果图元素带 data-sid、项目页面同名 data-sid)——
|
|
2114
|
-
* 本命令不做抽取,只做归一化 + 逐点 diff + 三类归因。
|
|
2115
|
-
*
|
|
2116
|
-
* CLI:node parser/index.js verify --oracle <oracle.json> --actual <actual.json> [--sids a,b,c]
|
|
2117
|
-
* 实测端定位计划:node parser/index.js verify --oracle <oracle.json> --resolve [--sids a,b,c]
|
|
2118
|
-
* 快照回归:… --snapshot <path> 导出本次实测 + 判定结果为快照(json),供下轮 --baseline 比对
|
|
2119
|
-
* … --baseline <path> 与上一轮快照比对:上轮命中、本轮缺口的属性 = 基线漂移(红),治「上一轮改对的这一轮漂回」
|
|
2120
|
-
* 退出码:存在缺口(含存在性缺口 G12 / 基线漂移)→ 1;全部命中 → 0。
|
|
2121
|
-
*
|
|
2122
|
-
* G12 存在性闸(oracle.absent):效果图上**不存在的**出厂点位(面包屑 / 设置铃铛等收口项)
|
|
2123
|
-
* 列入 oracle.absent: ["sid", …],verify 逐个断言「实测转储中不存在,或 display:none / visibility:hidden」。
|
|
2124
|
-
* 治「该消失的没消失」——这类缺口在效果图上无可见元素 → 无 data-sid → 无期望值,逐点 diff 永远验不到。
|
|
2125
|
-
*
|
|
2126
|
-
* G14 状态断言闸(oracle.states):交互态(disabled / active / hover…)在静态页面快照上取不到——
|
|
2127
|
-
* 效果图上「批量按钮置灰」标注了 opacity:.4,页面静态抽取却是激活态值 → 状态性差异逐点 diff 永远验不到。
|
|
2128
|
-
* 设计侧在 oracle.json 顶层声明 states: { "<sid>@<state>": { "<css属性>": "<期望值>" } }(state 取值见
|
|
2129
|
-
* 设计侧 mockup-extraction-spec §3.1:disabled / active / hover / focus / checked),
|
|
2130
|
-
* verify 逐键断言;actual 侧同键形态抽取(sid 查表 + state 属性覆盖/筛选,见下文 G14 段)。
|
|
2131
|
-
*
|
|
2132
|
-
* G15 孤儿点位对账闸:actual 中存在、oracle 全集(sids ∪ absent ∪ residual_accepted ∪ states)均无申报
|
|
2133
|
-
* 的点位 = 孤儿 ——「效果图没有、验收范围外」的页面元素。G12 只断言「已申报的该消失」,孤儿闸补齐
|
|
2134
|
-
* 「未申报的出现了」——这是效果图级完备性检查(申报缺一个 = 漏网一个),存在孤儿 → exit 1,逼迫设计侧
|
|
2135
|
-
* 把该点位收进 absent(该消失)或补基线(该还原)。
|
|
2136
|
-
*
|
|
2137
|
-
* 残差豁免(oracle.residual_accepted):机制推算值 / 业务资产等**申报过的固有残差**点位不计缺口,
|
|
2138
|
-
* 每条 { "sid": …, "reason": … },reason 必填——防止把真缺口装进接受清单蒙混过关。
|
|
2139
|
-
*/
|
|
2140
|
-
function cmdVerify(args, dataset) {
|
|
2141
|
-
// 几何 / 盒模型属性:值对不上 = 语境错位(宿主盒 / 内层盒模型 / 层叠上下文不同)
|
|
2142
|
-
const GEOMETRY_PROPS = new Set([
|
|
2143
|
-
"display", "position", "top", "right", "bottom", "left", "box-sizing",
|
|
2144
|
-
"width", "min-width", "max-width", "height", "min-height", "max-height",
|
|
2145
|
-
"padding", "padding-top", "padding-right", "padding-bottom", "padding-left",
|
|
2146
|
-
"padding-block", "padding-inline", "padding-block-start", "padding-block-end",
|
|
2147
|
-
"padding-inline-start", "padding-inline-end",
|
|
2148
|
-
"margin", "margin-top", "margin-right", "margin-bottom", "margin-left",
|
|
2149
|
-
"margin-block", "margin-inline", "gap", "row-gap", "column-gap",
|
|
2150
|
-
"text-align", "vertical-align", "line-height", "letter-spacing", "font-size",
|
|
2151
|
-
"font-family", "white-space", "text-overflow", "overflow",
|
|
2152
|
-
"border-radius", "border-top-left-radius", "border-top-right-radius",
|
|
2153
|
-
"border-bottom-left-radius", "border-bottom-right-radius",
|
|
2154
|
-
"flex", "flex-direction", "flex-grow", "flex-shrink", "flex-basis", "flex-wrap",
|
|
2155
|
-
"justify-content", "align-items", "align-self", "transform", "inset",
|
|
2156
|
-
]);
|
|
2157
|
-
|
|
2158
|
-
const get = flag => {
|
|
2159
|
-
const i = args.indexOf(flag);
|
|
2160
|
-
return i > -1 ? args[i + 1] : null;
|
|
2161
|
-
};
|
|
2162
|
-
const oraclePath = get("--oracle");
|
|
2163
|
-
const actualPath = get("--actual");
|
|
2164
|
-
const resolveOnly = args.includes("--resolve");
|
|
2165
|
-
const snapshotPath = get("--snapshot");
|
|
2166
|
-
const baselinePath = get("--baseline");
|
|
2167
|
-
if (!oraclePath || (!resolveOnly && !actualPath)) {
|
|
2168
|
-
console.error("✖ 缺少参数:node parser/index.js verify --oracle <oracle.json> --actual <actual.json> [--sids a,b,c]");
|
|
2169
|
-
console.error(" 或输出实测端定位计划:node parser/index.js verify --oracle <oracle.json> --resolve [--sids a,b,c]");
|
|
2170
|
-
process.exit(1);
|
|
2171
|
-
}
|
|
2172
|
-
const sidsArg = get("--sids");
|
|
2173
|
-
const sidFilter = sidsArg ? new Set(sidsArg.split(",").map(s => s.trim()).filter(Boolean)) : null;
|
|
2174
|
-
|
|
2175
|
-
const readDump = (p, label) => {
|
|
2176
|
-
let raw;
|
|
2177
|
-
try {
|
|
2178
|
-
raw = readFileSync(path.resolve(p), "utf-8");
|
|
2179
|
-
}
|
|
2180
|
-
catch (e) {
|
|
2181
|
-
console.error(`✖ ${label} 读取失败 ${p}:${e.message}`);
|
|
2182
|
-
process.exit(1);
|
|
2183
|
-
}
|
|
2184
|
-
let json;
|
|
2185
|
-
try {
|
|
2186
|
-
json = JSON.parse(raw);
|
|
2187
|
-
}
|
|
2188
|
-
catch (e) {
|
|
2189
|
-
console.error(`✖ ${label} 不是合法 JSON ${p}:${e.message}`);
|
|
2190
|
-
process.exit(1);
|
|
2191
|
-
}
|
|
2192
|
-
// 允许 { sids: {…} } 或直接 { "<sid>": {…} } 两种外形
|
|
2193
|
-
const map = json && json.sids && typeof json.sids === "object" ? json.sids : json;
|
|
2194
|
-
if (!map || typeof map !== "object" || Array.isArray(map)) {
|
|
2195
|
-
console.error(`✖ ${label} 结构非法:应为 { "<语义ID>": { "<属性>": "<值>" } }`);
|
|
2196
|
-
process.exit(1);
|
|
2197
|
-
}
|
|
2198
|
-
return map;
|
|
2199
|
-
};
|
|
2200
|
-
|
|
2201
|
-
const oracle = readDump(oraclePath, "效果图期望");
|
|
2202
|
-
// oracle 顶层扩展字段(readDump 摊平后丢顶层,这里从原始文件补读)
|
|
2203
|
-
const oracleRaw = JSON.parse(readFileSync(path.resolve(oraclePath), "utf-8"));
|
|
2204
|
-
const absentSids = Array.isArray(oracleRaw.absent) ? oracleRaw.absent.filter(s => typeof s === "string" && s.trim()) : [];
|
|
2205
|
-
const residualAccepted = Array.isArray(oracleRaw.residual_accepted)
|
|
2206
|
-
? oracleRaw.residual_accepted
|
|
2207
|
-
.map(r => (typeof r === "string" ? { sid: r, reason: "(未填 reason)" } : r))
|
|
2208
|
-
.filter(r => r && typeof r.sid === "string" && r.sid.trim())
|
|
2209
|
-
: [];
|
|
2210
|
-
// G14 状态断言闸:oracle.states = { "<sid>@<state>": { 属性: 期望值 } }(state ∈ disabled/active/hover/focus/checked)
|
|
2211
|
-
const stateEntries = [];
|
|
2212
|
-
if (oracleRaw.states && typeof oracleRaw.states === "object" && !Array.isArray(oracleRaw.states)) {
|
|
2213
|
-
for (const [key, exp] of Object.entries(oracleRaw.states)) {
|
|
2214
|
-
const m = /^(.+)@(disabled|active|hover|focus|checked)$/.exec(String(key).trim());
|
|
2215
|
-
if (!m || !exp || typeof exp !== "object" || Array.isArray(exp)) {
|
|
2216
|
-
console.error(`✖ oracle.states 键非法:「${key}」——应为 "<sid>@<state>"(state ∈ disabled/active/hover/focus/checked),值为属性对象`);
|
|
2217
|
-
process.exit(1);
|
|
2218
|
-
}
|
|
2219
|
-
stateEntries.push({ key: String(key).trim(), sid: m[1].trim(), state: m[2], expected: exp });
|
|
2220
|
-
}
|
|
2221
|
-
}
|
|
2222
|
-
else if (oracleRaw.states !== undefined) {
|
|
2223
|
-
console.error(`✖ oracle.states 应为对象 { "<sid>@<state>": { 属性: 期望值 } },实际类型:${Array.isArray(oracleRaw.states) ? "array" : typeof oracleRaw.states}`);
|
|
2224
|
-
process.exit(1);
|
|
2225
|
-
}
|
|
2226
|
-
|
|
2227
|
-
let sids = Object.keys(oracle);
|
|
2228
|
-
if (sidFilter)
|
|
2229
|
-
sids = sids.filter(s => sidFilter.has(s));
|
|
2230
|
-
|
|
2231
|
-
// 实测端定位闭合:--resolve 输出「sid → 词典 selector」抽取计划。
|
|
2232
|
-
// 效果图端用 data-sid(设计侧自控);项目页端不要求打 data-sid —— 浏览器按本计划的 selector 抽取。
|
|
2233
|
-
if (resolveOnly) {
|
|
2234
|
-
const index = buildMergedIndex(dataset || {});
|
|
2235
|
-
console.log(`\n=== 实测端定位计划(sid → 词典 selector)===`);
|
|
2236
|
-
console.log(`用途:项目页不要求打 data-sid —— 浏览器抽取脚本按下列 selector 定位元素取 computed style 生成 actual.json`);
|
|
2237
|
-
console.log(`selector 挂载点存在性由 audit 静态校验;本计划只回答「按什么定位」。\n`);
|
|
2238
|
-
let missing = 0;
|
|
2239
|
-
for (const sid of sids) {
|
|
2240
|
-
if (sid === GLOBAL_PSEUDO_ID)
|
|
2241
|
-
continue; // 伪点位不入词典(同 match / audit 路径口径),基座令牌不走 selector 抽取
|
|
2242
|
-
const entry = index[sid];
|
|
2243
|
-
if (!entry) {
|
|
2244
|
-
console.log(` ✘ ${sid} —— 词典无此条目(L3 新点位,先落地并回写临时映射)`);
|
|
2245
|
-
missing++;
|
|
2246
|
-
continue;
|
|
2247
|
-
}
|
|
2248
|
-
const sels = [];
|
|
2249
|
-
if (Array.isArray(entry.anchors)) {
|
|
2250
|
-
for (const a of entry.anchors) {
|
|
2251
|
-
if (a && a.kind === "class" && a.target)
|
|
2252
|
-
sels.push(`[${a.layer || "host"}] ${a.target}`);
|
|
2253
|
-
}
|
|
2254
|
-
}
|
|
2255
|
-
if (sels.length === 0 && entry.selector)
|
|
2256
|
-
sels.push(`[host] ${entry.selector}`);
|
|
2257
|
-
if (sels.length === 0) {
|
|
2258
|
-
console.log(` ✘ ${sid} —— 词典条目无 selector / anchors,无法定位`);
|
|
2259
|
-
missing++;
|
|
2260
|
-
continue;
|
|
2261
|
-
}
|
|
2262
|
-
console.log(` · ${sid}`);
|
|
2263
|
-
for (const s of sels)
|
|
2264
|
-
console.log(` ${s}`);
|
|
2265
|
-
}
|
|
2266
|
-
console.log(`\n汇总:${sids.length} 点位,${missing} 个无法定位`);
|
|
2267
|
-
console.log(`抽取属性白名单两端必须一致(padding / font-size / font-weight / text-align / color / background-color / border-radius / gap 等,见 mockup-extraction-spec §3);主题色板与基线图不同时再加 --from-theme/--to-theme 做色值归一(几何 / 结构 / 字号照比)。`);
|
|
2268
|
-
if (missing > 0)
|
|
2269
|
-
process.exitCode = 1;
|
|
2270
|
-
return;
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
const actual = readDump(actualPath, "项目实测");
|
|
2274
|
-
// actual 顶层扩展字段:states 子对象(G14 实测端状态值,键形态 <sid>@<state>,与 oracle.states 对称)
|
|
2275
|
-
const actualRawFile = JSON.parse(readFileSync(path.resolve(actualPath), "utf-8"));
|
|
2276
|
-
const actualStatesMap = actualRawFile && actualRawFile.states && typeof actualRawFile.states === "object" && !Array.isArray(actualRawFile.states)
|
|
2277
|
-
? actualRawFile.states
|
|
2278
|
-
: null;
|
|
2279
|
-
|
|
2280
|
-
const normProp = p => String(p).trim().toLowerCase().replace(/_/g, "-");
|
|
2281
|
-
const normValue = v => {
|
|
2282
|
-
let s = String(v == null ? "" : v).trim().toLowerCase().replace(/\s+/g, " ");
|
|
2283
|
-
s = s.replace(/\s*,\s*/g, ",");
|
|
2284
|
-
// 3 位 hex 展开为 6 位,消除 "#fff" vs "#ffffff" 假缺口
|
|
2285
|
-
s = s.replace(/#([0-9a-f]{3})\b/g, (_, h) => `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`);
|
|
2286
|
-
return s;
|
|
2287
|
-
};
|
|
2288
|
-
|
|
2289
|
-
// 同构异色主题归一:--from-theme 钴蓝 --to-theme 橘橙 时,把效果图的主题色族色值
|
|
2290
|
-
// 按语义译表的槽位(-1..-10)映射为本主题同槽位色值再比对;译表之外的值原样保留。
|
|
2291
|
-
const fromTheme = get("--from-theme");
|
|
2292
|
-
const toTheme = get("--to-theme");
|
|
2293
|
-
let themeAlias = null;
|
|
2294
|
-
if (fromTheme && toTheme) {
|
|
2295
|
-
try {
|
|
2296
|
-
const sv = JSON.parse(readFileSync(path.join(ROOT, "mapping", "basic", "semantic-values.json"), "utf-8"));
|
|
2297
|
-
const slotOf = {};
|
|
2298
|
-
const hexOf = {};
|
|
2299
|
-
for (const t of Object.values(sv.themes || {})) {
|
|
2300
|
-
for (const [name, hex] of Object.entries(t.values || {})) {
|
|
2301
|
-
const m = /^(.+?)-(\d+)$/.exec(name);
|
|
2302
|
-
if (!m)
|
|
2303
|
-
continue;
|
|
2304
|
-
const key = String(hex).toLowerCase();
|
|
2305
|
-
slotOf[`${t.label}|${key}`] = m[2];
|
|
2306
|
-
hexOf[`${t.label}|${m[2]}`] = key;
|
|
2307
|
-
}
|
|
2308
|
-
}
|
|
2309
|
-
themeAlias = (v) => {
|
|
2310
|
-
let s = String(v == null ? "" : v).toLowerCase();
|
|
2311
|
-
s = s.replace(/#([0-9a-f]{3})\b/g, (_, h) => `#${h[0]}${h[0]}${h[1]}${h[1]}${h[2]}${h[2]}`);
|
|
2312
|
-
return s.replace(/#[0-9a-f]{6}/g, (hex) => {
|
|
2313
|
-
const lvl = slotOf[`${fromTheme}|${hex}`];
|
|
2314
|
-
return lvl ? (hexOf[`${toTheme}|${lvl}`] || hex) : hex;
|
|
2315
|
-
});
|
|
2316
|
-
};
|
|
2317
|
-
console.log(`主题归一:效果图「${fromTheme}」→ 本主题「${toTheme}」(按语义槽位映射色值后比对,几何 / 结构照比)`);
|
|
2318
|
-
}
|
|
2319
|
-
catch (e) {
|
|
2320
|
-
console.log(`⚠ 主题译表读取失败(${e.message})—— 本轮不做主题色归一,色值类属性可能出现假缺口`);
|
|
2321
|
-
}
|
|
2322
|
-
}
|
|
2323
|
-
|
|
2324
|
-
console.log(`渲染态验收(computed style 逐点比对)`);
|
|
2325
|
-
console.log(`效果图基线:${path.resolve(oraclePath)}`);
|
|
2326
|
-
console.log(`项目实测:${path.resolve(actualPath)}`);
|
|
2327
|
-
console.log(`比对点位:${sids.length}${sidFilter ? `(--sids 过滤后)` : "(oracle 全量)"}`);
|
|
2328
|
-
if (absentSids.length > 0)
|
|
2329
|
-
console.log(`存在性闸(G12):${absentSids.length} 点位应已收口 —— ${absentSids.join("、")}`);
|
|
2330
|
-
if (stateEntries.length > 0)
|
|
2331
|
-
console.log(`状态断言闸(G14):${stateEntries.length} 个交互态期望 —— ${stateEntries.map(s => s.key).join("、")}`);
|
|
2332
|
-
if (residualAccepted.length > 0)
|
|
2333
|
-
console.log(`残差豁免:${residualAccepted.length} 点位已申报接受 —— ${residualAccepted.map(r => r.sid).join("、")}`);
|
|
2334
|
-
|
|
2335
|
-
// G15 孤儿点位对账:actual 顶层(原始文件外形)中存在、oracle 全集(sids ∪ absent ∪ residual ∪ states)均无
|
|
2336
|
-
// 申报的点位 = 孤儿。readDump 会把 { sids: {…} } 外形摊平成 sids 子对象,所以孤儿必须从原始文件算,
|
|
2337
|
-
// 排除结构性键($ 前缀注释 / sids / states 子对象)。G12 断言「已申报的该消失」;孤儿闸补齐「未申报的出现了」。
|
|
2338
|
-
const oracleCovered = new Set([
|
|
2339
|
-
...Object.keys(oracle),
|
|
2340
|
-
...absentSids,
|
|
2341
|
-
...residualAccepted.map(r => r.sid.trim()),
|
|
2342
|
-
...stateEntries.map(s => s.sid),
|
|
2343
|
-
]);
|
|
2344
|
-
const orphans = Object.keys(actualRawFile).filter(k => !k.startsWith("$") && k !== "sids" && k !== "states" && !oracleCovered.has(k));
|
|
2345
|
-
|
|
2346
|
-
// 残差豁免索引:sid → reason。命中豁免的属性缺口降级为「已申报残差」提示行,不进 missProps。
|
|
2347
|
-
const residualMap = new Map(residualAccepted.map(r => [r.sid.trim(), r.reason || "(未填 reason)"]));
|
|
2348
|
-
|
|
2349
|
-
let hitProps = 0;
|
|
2350
|
-
let missProps = 0;
|
|
2351
|
-
let residualProps = 0;
|
|
2352
|
-
const attr = { selector_miss: 0, weight_override: 0, context_mismatch: 0 };
|
|
2353
|
-
const clean = [];
|
|
2354
|
-
// 快照收集:sid → { 属性: { expected, actual, verdict } };verdict: hit | miss | residual | absent-hit | absent-miss | selector-miss
|
|
2355
|
-
const snapshot = { generated_at: new Date().toISOString(), oracle: path.resolve(oraclePath), actual: path.resolve(actualPath), points: {} };
|
|
2356
|
-
|
|
2357
|
-
for (const sid of sids) {
|
|
2358
|
-
const exp = oracle[sid];
|
|
2359
|
-
if (!exp || typeof exp !== "object") {
|
|
2360
|
-
console.log(`\n⚠ ${sid} —— oracle 条目非法(应为属性对象),跳过`);
|
|
2361
|
-
continue;
|
|
2362
|
-
}
|
|
2363
|
-
const actRaw = actual[sid];
|
|
2364
|
-
if (!actRaw || typeof actRaw !== "object") {
|
|
2365
|
-
console.log(`\n✘ ${sid}\n - 项目页面未抽取到该点位 —— 选择器落空(挂载点缺失 / 元素未渲染 / data-sid 未标注)`);
|
|
2366
|
-
attr.selector_miss++;
|
|
2367
|
-
missProps++;
|
|
2368
|
-
snapshot.points[sid] = { verdict: "selector-miss" };
|
|
2369
|
-
continue;
|
|
2370
|
-
}
|
|
2371
|
-
const act = {};
|
|
2372
|
-
for (const [k, v] of Object.entries(actRaw))
|
|
2373
|
-
act[normProp(k)] = v;
|
|
2374
|
-
|
|
2375
|
-
const lines = [];
|
|
2376
|
-
const snapProps = {};
|
|
2377
|
-
for (const [propRaw, expVal] of Object.entries(exp)) {
|
|
2378
|
-
const prop = normProp(propRaw);
|
|
2379
|
-
if (!(prop in act)) {
|
|
2380
|
-
lines.push(` - ${prop}: 期望 ${expVal} / 实测未取到 —— 选择器落空(该属性所在规则未命中)`);
|
|
2381
|
-
attr.selector_miss++;
|
|
2382
|
-
snapProps[prop] = { expected: expVal, actual: null, verdict: "selector-miss" };
|
|
2383
|
-
continue;
|
|
2384
|
-
}
|
|
2385
|
-
const expNorm = themeAlias ? themeAlias(expVal) : expVal;
|
|
2386
|
-
if (normValue(expNorm) === normValue(act[prop])) {
|
|
2387
|
-
hitProps++;
|
|
2388
|
-
snapProps[prop] = { expected: expVal, actual: act[prop], verdict: "hit" };
|
|
2389
|
-
continue;
|
|
2390
|
-
}
|
|
2391
|
-
const isGeom = GEOMETRY_PROPS.has(prop);
|
|
2392
|
-
const kind = isGeom ? "语境错位" : "权重被压";
|
|
2393
|
-
if (isGeom)
|
|
2394
|
-
attr.context_mismatch++;
|
|
2395
|
-
else
|
|
2396
|
-
attr.weight_override++;
|
|
2397
|
-
snapProps[prop] = { expected: expVal, actual: act[prop], verdict: "miss", kind };
|
|
2398
|
-
if (residualMap.has(sid)) {
|
|
2399
|
-
// 申报过的固有残差:降级为提示行,不进缺口计数
|
|
2400
|
-
residualProps++;
|
|
2401
|
-
snapProps[prop].verdict = "residual";
|
|
2402
|
-
lines.push(` ○ ${prop}: 期望 ${expVal} / 实得 ${act[prop]} —— 已申报固有残差(${residualMap.get(sid)}),不计缺口`);
|
|
2403
|
-
continue;
|
|
2404
|
-
}
|
|
2405
|
-
lines.push(` - ${prop}: 期望 ${expVal} / 实得 ${act[prop]} —— ${kind}(${
|
|
2406
|
-
isGeom ? "几何值对不上:宿主盒 / 内层盒模型或层叠上下文不同" : "同权重被组件 scoped / 行内样式反压,或令牌未生效"
|
|
2407
|
-
})`);
|
|
2408
|
-
missProps++;
|
|
2409
|
-
}
|
|
2410
|
-
|
|
2411
|
-
if (lines.length === 0) {
|
|
2412
|
-
clean.push(sid);
|
|
2413
|
-
snapshot.points[sid] = { verdict: "hit", props: snapProps };
|
|
2414
|
-
}
|
|
2415
|
-
else {
|
|
2416
|
-
console.log(`\n✘ ${sid}`);
|
|
2417
|
-
for (const l of lines)
|
|
2418
|
-
console.log(l);
|
|
2419
|
-
snapshot.points[sid] = { verdict: "miss", props: snapProps };
|
|
2420
|
-
}
|
|
2421
|
-
}
|
|
2422
|
-
|
|
2423
|
-
// ---- G12 存在性闸:oracle.absent 点位必须「不存在或已隐藏」----
|
|
2424
|
-
let absentMiss = 0;
|
|
2425
|
-
if (absentSids.length > 0) {
|
|
2426
|
-
console.log(`\n── 存在性闸(G12):以下点位效果图上不存在,页面必须收口 ──`);
|
|
2427
|
-
for (const sid of absentSids) {
|
|
2428
|
-
if (sidFilter && !sidFilter.has(sid))
|
|
2429
|
-
continue;
|
|
2430
|
-
const actRaw = actual[sid];
|
|
2431
|
-
let gone = true;
|
|
2432
|
-
let evidence = "页面无此元素(未抽取到)";
|
|
2433
|
-
if (actRaw && typeof actRaw === "object") {
|
|
2434
|
-
const disp = normValue(actRaw["display"] ?? actRaw["display_style"] ?? "");
|
|
2435
|
-
const vis = normValue(actRaw["visibility"] ?? "");
|
|
2436
|
-
if (disp === "none" || vis === "hidden") {
|
|
2437
|
-
evidence = `display=${disp || "-"} visibility=${vis || "-"}`;
|
|
2438
|
-
}
|
|
2439
|
-
else {
|
|
2440
|
-
gone = false;
|
|
2441
|
-
evidence = `仍渲染可见(display=${disp || "?"})`;
|
|
2442
|
-
}
|
|
2443
|
-
}
|
|
2444
|
-
if (gone) {
|
|
2445
|
-
console.log(` ✔ ${sid} —— 已收口(${evidence})`);
|
|
2446
|
-
snapshot.points[sid] = { verdict: "absent-hit", evidence };
|
|
2447
|
-
}
|
|
2448
|
-
else {
|
|
2449
|
-
absentMiss++;
|
|
2450
|
-
console.log(` ✘ ${sid} —— ${evidence}:效果图无此元素,应 visible:false 收口(G12 存在性缺口)`);
|
|
2451
|
-
snapshot.points[sid] = { verdict: "absent-miss", evidence };
|
|
2452
|
-
}
|
|
2453
|
-
}
|
|
2454
|
-
}
|
|
2455
|
-
|
|
2456
|
-
// ---- G14 状态断言闸:oracle.states(<sid>@<state>)逐键比对交互态 computed style ----
|
|
2457
|
-
// actual 侧两种合法形态:① states 子对象同名键(推荐,浏览器脚本对 hover/focus 等态主动触发后抽取);
|
|
2458
|
-
// ② sid 主条目属性覆盖(仅 disabled/checked 等「属性可静态反映」的态——设计侧未给 states 子对象时的降级口径)。
|
|
2459
|
-
// 计数口径:stateMiss = 键级(每个状态键最多计 1 次缺口,供「状态断言 X/Y 命中」);
|
|
2460
|
-
// 属性级归因仍进统一四类(选择器落空 / 权重被压 / 语境错位),处置口径不因状态位分裂。
|
|
2461
|
-
let stateMiss = 0;
|
|
2462
|
-
if (stateEntries.length > 0) {
|
|
2463
|
-
console.log(`\n── 状态断言闸(G14):交互态期望逐键比对 ──`);
|
|
2464
|
-
for (const st of stateEntries) {
|
|
2465
|
-
if (sidFilter && !sidFilter.has(st.sid))
|
|
2466
|
-
continue;
|
|
2467
|
-
let actVal = null;
|
|
2468
|
-
const stDump = actualStatesMap ? actualStatesMap[st.key] : null;
|
|
2469
|
-
if (stDump && typeof stDump === "object" && !Array.isArray(stDump)) {
|
|
2470
|
-
actVal = stDump; // 形态①:该状态下的完整属性转储
|
|
2471
|
-
}
|
|
2472
|
-
else {
|
|
2473
|
-
// 形态②降级:sid 主条目里的属性值(disabled/checked 可静态反映;hover/active 等瞬态在此口径下取不到)
|
|
2474
|
-
const main = actual[st.sid];
|
|
2475
|
-
if (main && typeof main === "object") {
|
|
2476
|
-
const covered = {};
|
|
2477
|
-
let any = false;
|
|
2478
|
-
for (const [p, e] of Object.entries(st.expected)) {
|
|
2479
|
-
const np = normProp(p);
|
|
2480
|
-
if (np in main) {
|
|
2481
|
-
covered[p] = main[np];
|
|
2482
|
-
any = true;
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
if (any)
|
|
2486
|
-
actVal = covered;
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
if (!actVal) {
|
|
2490
|
-
stateMiss++;
|
|
2491
|
-
const why = (st.state === "hover" || st.state === "focus" || st.state === "active")
|
|
2492
|
-
? `瞬态(${st.state})须由浏览器脚本触发后抽取到 actual.states["${st.key}"]——主条目静态值不能代表交互态`
|
|
2493
|
-
: `actual 未抽取到该状态(缺 actual.states["${st.key}"] 或 sid 主条目)`;
|
|
2494
|
-
console.log(` ✘ ${st.key} —— 状态期望未取到实测值:${why}`);
|
|
2495
|
-
snapshot.points[st.key] = { verdict: "state-miss", state: st.state };
|
|
2496
|
-
continue;
|
|
2497
|
-
}
|
|
2498
|
-
const stLines = [];
|
|
2499
|
-
const snapProps = {};
|
|
2500
|
-
let stKeyMiss = false;
|
|
2501
|
-
for (const [propRaw, expVal] of Object.entries(st.expected)) {
|
|
2502
|
-
const prop = normProp(propRaw);
|
|
2503
|
-
const actV = actVal[propRaw] ?? actVal[prop];
|
|
2504
|
-
if (actV === undefined || actV === null) {
|
|
2505
|
-
stLines.push(` - ${prop}: 期望 ${expVal} / 实测未取到 —— 状态抽取缺属性`);
|
|
2506
|
-
attr.selector_miss++;
|
|
2507
|
-
stKeyMiss = true;
|
|
2508
|
-
snapProps[prop] = { expected: expVal, actual: null, verdict: "selector-miss" };
|
|
2509
|
-
continue;
|
|
2510
|
-
}
|
|
2511
|
-
const expNorm = themeAlias ? themeAlias(expVal) : expVal;
|
|
2512
|
-
if (normValue(expNorm) === normValue(actV)) {
|
|
2513
|
-
snapProps[prop] = { expected: expVal, actual: actV, verdict: "hit" };
|
|
2514
|
-
}
|
|
2515
|
-
else {
|
|
2516
|
-
const isGeom = GEOMETRY_PROPS.has(prop);
|
|
2517
|
-
stLines.push(` - ${prop}: 期望 ${expVal} / 实得 ${actV} —— ${isGeom ? "语境错位" : "权重被压"}(${st.state} 态)`);
|
|
2518
|
-
if (isGeom)
|
|
2519
|
-
attr.context_mismatch++;
|
|
2520
|
-
else
|
|
2521
|
-
attr.weight_override++;
|
|
2522
|
-
stKeyMiss = true;
|
|
2523
|
-
snapProps[prop] = { expected: expVal, actual: actV, verdict: "miss", kind: "状态缺口" };
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
if (stKeyMiss) {
|
|
2527
|
-
stateMiss++;
|
|
2528
|
-
console.log(`\n ✘ ${st.key}`);
|
|
2529
|
-
for (const l of stLines)
|
|
2530
|
-
console.log(l);
|
|
2531
|
-
snapshot.points[st.key] = { verdict: "miss", state: st.state, props: snapProps };
|
|
2532
|
-
}
|
|
2533
|
-
else {
|
|
2534
|
-
console.log(` ✔ ${st.key} —— 状态期望全部命中`);
|
|
2535
|
-
snapshot.points[st.key] = { verdict: "hit", state: st.state, props: snapProps };
|
|
2536
|
-
}
|
|
2537
|
-
}
|
|
2538
|
-
}
|
|
2539
|
-
|
|
2540
|
-
// ---- G15 孤儿点位对账:actual 有、oracle 全集无申报 = 效果图级完备性缺口 ----
|
|
2541
|
-
let orphanMiss = 0;
|
|
2542
|
-
if (orphans.length > 0) {
|
|
2543
|
-
console.log(`\n── 孤儿点位对账(G15):以下点位页面渲染了、oracle 全集(sids/absent/residual/states)均无申报 ──`);
|
|
2544
|
-
for (const sid of orphans) {
|
|
2545
|
-
orphanMiss++;
|
|
2546
|
-
console.log(` ✘ ${sid} —— 效果图上无此角色坐标:要么应 visible:false 收口(补进 oracle.absent),要么该还原(回流设计侧补基线 oracle.sids + 效果图 data-sid)`);
|
|
2547
|
-
snapshot.points[sid] = { verdict: "orphan" };
|
|
2548
|
-
}
|
|
2549
|
-
}
|
|
2550
|
-
|
|
2551
|
-
if (clean.length > 0)
|
|
2552
|
-
console.log(`\n✔ 逐点命中:${clean.join("、")}`);
|
|
2553
|
-
|
|
2554
|
-
// ---- 快照回归:--snapshot 导出 / --baseline 漂移比对 ----
|
|
2555
|
-
let driftCount = 0;
|
|
2556
|
-
if (baselinePath) {
|
|
2557
|
-
let baseline;
|
|
2558
|
-
try {
|
|
2559
|
-
baseline = JSON.parse(readFileSync(path.resolve(baselinePath), "utf-8"));
|
|
2560
|
-
}
|
|
2561
|
-
catch (e) {
|
|
2562
|
-
console.error(`✖ 基线快照读取失败 ${baselinePath}:${e.message}`);
|
|
2563
|
-
process.exit(1);
|
|
2564
|
-
}
|
|
2565
|
-
const basePoints = baseline.points || {};
|
|
2566
|
-
const drifts = [];
|
|
2567
|
-
for (const [sid, cur] of Object.entries(snapshot.points)) {
|
|
2568
|
-
const prev = basePoints[sid];
|
|
2569
|
-
if (!prev)
|
|
2570
|
-
continue;
|
|
2571
|
-
// 点位级漂移:上轮命中/收口/状态命中、本轮缺口(G12/G14/G15 各 verdict 均纳入红绿判定)
|
|
2572
|
-
const okVerdicts = new Set(["hit", "absent-hit", "orphan-absolved"]);
|
|
2573
|
-
const prevOk = okVerdicts.has(prev.verdict);
|
|
2574
|
-
const curOk = okVerdicts.has(cur.verdict);
|
|
2575
|
-
if (prevOk && !curOk) {
|
|
2576
|
-
drifts.push(`${sid}(${prev.verdict} → ${cur.verdict})`);
|
|
2577
|
-
}
|
|
2578
|
-
else if (prevOk && curOk && prev.props && cur.props) {
|
|
2579
|
-
for (const [prop, pv] of Object.entries(prev.props)) {
|
|
2580
|
-
if (pv.verdict === "hit" && cur.props[prop] && cur.props[prop].verdict !== "hit" && cur.props[prop].verdict !== "residual")
|
|
2581
|
-
drifts.push(`${sid}.${prop}(hit → ${cur.props[prop].verdict})`);
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
2584
|
-
}
|
|
2585
|
-
driftCount = drifts.length;
|
|
2586
|
-
if (driftCount > 0) {
|
|
2587
|
-
console.log(`\n✘ 基线漂移 ${driftCount} 处(上一轮已命中、本轮退步——每轮动工前先跑 verify 存快照,交付前比对):`);
|
|
2588
|
-
for (const d of drifts)
|
|
2589
|
-
console.log(` - ${d}`);
|
|
2590
|
-
}
|
|
2591
|
-
else {
|
|
2592
|
-
console.log(`\n✔ 基线快照比对:无漂移(上轮命中点位本轮全部保持)`);
|
|
2593
|
-
}
|
|
2594
|
-
}
|
|
2595
|
-
if (snapshotPath) {
|
|
2596
|
-
try {
|
|
2597
|
-
writeFileSync(path.resolve(snapshotPath), JSON.stringify(snapshot, null, 2), "utf-8");
|
|
2598
|
-
console.log(`\n快照已导出:${path.resolve(snapshotPath)}(下轮验证带 --baseline <本文件> 比对漂移)`);
|
|
2599
|
-
}
|
|
2600
|
-
catch (e) {
|
|
2601
|
-
console.error(`✖ 快照写入失败 ${snapshotPath}:${e.message}`);
|
|
2602
|
-
process.exitCode = 1;
|
|
2603
|
-
}
|
|
2604
|
-
}
|
|
2605
|
-
|
|
2606
|
-
console.log(`\n汇总:${sids.length} 点位 · ${hitProps} 属性命中 · ${missProps} 缺口${residualProps > 0 ? ` · ${residualProps} 已申报残差` : ""}${absentSids.length > 0 ? ` · 存在性 ${absentSids.length - absentMiss}/${absentSids.length} 收口` : ""}${stateEntries.length > 0 ? ` · 状态断言 ${stateEntries.length - stateMiss}/${stateEntries.length} 命中` : ""}${orphans.length > 0 ? ` · 孤儿 ${orphans.length}` : ""}`);
|
|
2607
|
-
console.log(`归因:选择器落空 ${attr.selector_miss} / 权重被压 ${attr.weight_override} / 语境错位 ${attr.context_mismatch}${absentSids.length > 0 ? ` / 存在性缺口 ${absentMiss}` : ""}${stateEntries.length > 0 ? ` / 状态缺口 ${stateMiss}` : ""}${orphanMiss > 0 ? ` / 孤儿点位 ${orphanMiss}` : ""}${driftCount > 0 ? ` / 基线漂移 ${driftCount}` : ""}`);
|
|
2608
|
-
if (missProps > 0 || absentMiss > 0 || stateMiss > 0 || orphanMiss > 0 || driftCount > 0) {
|
|
2609
|
-
console.log(`处置口径:按归因修正 —— 选择器落空补挂载点(属能力缺口,走 structure_ops / 补通道并申报);权重被压按 priority 声明提权(禁止 !important 掩盖);语境错位校准 layer(host / inner);存在性缺口补 visible:false 收口并按词典 priority 提权;状态缺口由浏览器脚本触发交互态后抽到 actual.states(G14);孤儿点位回流设计侧收进 oracle.absent 或补基线(G15);基线漂移逐条回归上轮修法。`);
|
|
2610
|
-
process.exitCode = 1;
|
|
2611
|
-
}
|
|
2612
|
-
else {
|
|
2613
|
-
console.log(`✔ 渲染态与效果图基线逐点一致(验收通过)`);
|
|
2614
|
-
}
|
|
2615
|
-
}
|
|
2616
|
-
|
|
2617
|
-
/** 语义值译表索引:mapping 中 semantic-values.json 摊平为 { 语义名(中/英): 精确值 } */
|
|
2618
|
-
function buildSemanticValueIndex(dataset) {
|
|
2619
|
-
const dict = {};
|
|
2620
|
-
for (const data of Object.values(dataset.files)) {
|
|
2621
|
-
if (!data.themes)
|
|
2622
|
-
continue;
|
|
2623
|
-
for (const theme of Object.values(data.themes)) {
|
|
2624
|
-
if (theme && theme.values && typeof theme.values === "object" && !theme.values.$comment) {
|
|
2625
|
-
const label = theme.label || "";
|
|
2626
|
-
for (const [name, hex] of Object.entries(theme.values)) {
|
|
2627
|
-
dict[name] = hex;
|
|
2628
|
-
// 英文别名:钴蓝-5 ↔ cobalt-5(主题 label 的拼音对应 themes 键名)
|
|
2629
|
-
}
|
|
2630
|
-
}
|
|
2631
|
-
}
|
|
2632
|
-
// 英文拼写别名:theme 键名(cobalt/emerald/...)+ 级别号
|
|
2633
|
-
for (const [key, theme] of Object.entries(data.themes)) {
|
|
2634
|
-
if (key.startsWith("$") || !theme || !theme.values)
|
|
2635
|
-
continue;
|
|
2636
|
-
for (let i = 1; i <= 10; i++)
|
|
2637
|
-
dict[`${key}-${i}`] = theme.values[`${theme.label}-${i}`] || "";
|
|
2638
|
-
dict[`${key}-success`] = theme.values["成功色"] || "";
|
|
2639
|
-
dict[`${key}-error`] = theme.values["错误色"] || "";
|
|
2640
|
-
dict[`${key}-warning`] = theme.values["警告色"] || "";
|
|
2641
|
-
dict[`${key}-info`] = theme.values["信息色"] || "";
|
|
2642
|
-
dict[`${key}-link`] = theme.values["链接色"] || "";
|
|
2643
|
-
}
|
|
2644
|
-
}
|
|
2645
|
-
// 清空串
|
|
2646
|
-
for (const k of Object.keys(dict))
|
|
2647
|
-
if (!dict[k])
|
|
2648
|
-
delete dict[k];
|
|
2649
|
-
return dict;
|
|
2650
|
-
}
|
|
2651
|
-
|
|
2652
|
-
/** 语义值解析:hex/rgb/hsl 透传 → 数值/复合字面量透传 → 中文名查表 → 英文名查表 → null(未命中) */
|
|
2653
|
-
function resolveSemanticValue(value, valueDict) {
|
|
2654
|
-
const v = String(value).trim();
|
|
2655
|
-
if (/^#([0-9a-fA-F]{3,8})$/.test(v) || /^(rgb|hsl)a?\(/i.test(v))
|
|
2656
|
-
return v;
|
|
2657
|
-
if (NUMERIC_VALUE_RE.test(v) || COMPOSITE_VALUE_RE.test(v))
|
|
2658
|
-
return v;
|
|
2659
|
-
if (valueDict && valueDict[v])
|
|
2660
|
-
return valueDict[v];
|
|
2661
|
-
return null;
|
|
2662
|
-
}
|
|
2663
|
-
|
|
2664
|
-
// ---- list ----
|
|
2665
|
-
function cmdList(args, dataset) {
|
|
2666
|
-
const sceneIdx = args.indexOf("--scene");
|
|
2667
|
-
const scene = sceneIdx > -1 ? args[sceneIdx + 1] : null;
|
|
2668
|
-
const verbose = args.includes("--verbose") || args.includes("-v");
|
|
2669
|
-
const index = buildMergedIndex(dataset);
|
|
2670
|
-
const ids = Object.keys(index).sort();
|
|
2671
|
-
const filtered = scene ? ids.filter(id => id.startsWith(`${scene}.`) || !/^(admin|portal|dashboard)\./.test(id)) : ids;
|
|
2672
|
-
// v1.0:头部输出映射集版本(版本恒定 1.0.0,由 SKILL.md 体系版本统一管理;此处仅健康检查展示)
|
|
2673
|
-
const versions = [...new Set(Object.values(dataset.files).map(d => d.meta?.version).filter(Boolean))];
|
|
2674
|
-
console.log(`映射集版本:${versions.join(" / ")}(变更集 mapping_version 恒填 1.0.0)`);
|
|
2675
|
-
console.log(`共 ${filtered.length} 条语义 ID${scene ? `(scene=${scene} + 全局)` : ""}:`);
|
|
2676
|
-
for (const id of filtered) {
|
|
2677
|
-
const e = index[id];
|
|
2678
|
-
console.log(` ${id.padEnd(46)} [${e.type}] ${e.selector ?? e.component ?? ""}`);
|
|
2679
|
-
if (verbose) {
|
|
2680
|
-
// 词汇约束模式:输出设计侧可用的 style 字段与 form 形态枚举(供下发词汇约束)
|
|
2681
|
-
const fields = [...Object.keys(e.css_vars || {}), ...Object.keys(e.custom_style_map || {})];
|
|
2682
|
-
const forms = extractForms(e);
|
|
2683
|
-
if (fields.length > 0)
|
|
2684
|
-
console.log(` style 字段:${fields.join(" ")}`);
|
|
2685
|
-
if (forms.length > 0)
|
|
2686
|
-
console.log(` form 形态:${forms.join(" / ")}`);
|
|
2687
|
-
// :落点层级与覆写优先级(设计侧声明 layer / 项目侧选提权策略时可见)
|
|
2688
|
-
const anchors = Array.isArray(e.anchors) ? e.anchors : [];
|
|
2689
|
-
const layers = [...new Set(anchors.filter(a => a.kind === "class" && a.layer).map(a => a.layer))];
|
|
2690
|
-
if (layers.length > 0 || (e.priority && e.priority.strategy))
|
|
2691
|
-
console.log(` 落点层级:${layers.length > 0 ? layers.join(" / ") : "—"};覆写优先级:${(e.priority && e.priority.strategy) || "—"}`);
|
|
2692
|
-
}
|
|
2693
|
-
}
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
// ---- query ----
|
|
2697
|
-
function cmdQuery(args, dataset) {
|
|
2698
|
-
const id = args.find(a => !a.startsWith("--"));
|
|
2699
|
-
const index = buildMergedIndex(dataset);
|
|
2700
|
-
const entry = index[id];
|
|
2701
|
-
if (!entry) {
|
|
2702
|
-
console.error(`✖ 语义 ID「${id}」未在映射表中定义`);
|
|
2703
|
-
process.exit(1);
|
|
2704
|
-
}
|
|
2705
|
-
console.log(JSON.stringify({ id, ...entry }, null, 2));
|
|
2706
|
-
}
|