@qilitt-mickey/vue3-temp-skill 1.1.65 → 1.1.67
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/package.json +1 -1
- package/parser/fixtures/changeset-fullpage-restore.json +1 -1
- package/parser/fixtures/changeset-pos-theme-neutral.json +264 -0
- package/parser/fixtures/changeset-pos-theme-nometa.json +260 -0
- package/parser/fixtures/verify/mockup-neutral.html +26 -0
- package/parser/fixtures/verify/mockup-nometa.html +26 -0
- package/parser/fixtures/verify/snapshot-latest.json +1 -1
- package/parser/index.js +60 -15
- package/parser/regression.js +16 -2
- package/references/design-apply.md +3 -1
- package/references/steps/maintenance.md +15 -0
- package/references/steps/regression.md +4 -2
- package/references/steps/step1-match.md +6 -1
- package/references/steps/step3-style.md +6 -0
- package/references/steps/step3b-shell.md +6 -1
- package/references/steps/step5-gates.md +8 -2
- package/references/steps/step6-verify.md +2 -2
- package/scripts/check.mjs +1 -1
- package/scripts/design-audit.mjs +442 -12
package/scripts/design-audit.mjs
CHANGED
|
@@ -27,6 +27,11 @@
|
|
|
27
27
|
* E8 浮层残留 form 要求收口下拉浮层,但组件仍消费 #dropdown
|
|
28
28
|
* E9 形态收口缺件 form=icon_only 只显图标、未在同一作用域内收口文案(且不被兄弟变体规则骗过)
|
|
29
29
|
* E10 scoped 挂空 组件 scoped 规则依赖运行时并不存在的类(重构残留,永不命中)
|
|
30
|
+
* E11 交付完整性 mode=restore 时 covered<applicable / gaps 非空 / 缺口未台账化 / 分母漂移 / notes 与字段矛盾
|
|
31
|
+
* E12 设计层累积性 变更集点位或设计层行数相对历史骤降(上一轮成果被覆盖)→ 写交付台账 .codebuddy/design/ledger.json
|
|
32
|
+
* E13 固定尺寸内容度量 固定 width 小于内容需求且无收口兜底 → 文字换行/压缩/重叠
|
|
33
|
+
* E14 列宽预算链自洽 列宽(含公式)扣掉实际 td 内边距后装不下内容 → 操作列按钮被压
|
|
34
|
+
* E16 引用完整性 变更集引用的实物不存在(改名未同步)/ restore 缺顶层 theme
|
|
30
35
|
*/
|
|
31
36
|
|
|
32
37
|
import fs from "fs";
|
|
@@ -249,19 +254,42 @@ const designRules = DESIGN_FILES.flatMap((f) =>
|
|
|
249
254
|
const PRIMARY_LAYER = path.resolve(PROJECT, argValue("--design-layer", "src/styles/design-shell.scss"));
|
|
250
255
|
const isPrimary = (file) => path.resolve(file) === PRIMARY_LAYER;
|
|
251
256
|
|
|
252
|
-
//
|
|
253
|
-
|
|
257
|
+
// 变更集发现:落点历史上不唯一(.codebuddy/design 与项目根 design/ 都出现过)。
|
|
258
|
+
// 硬编码单一目录的代价是「变更集搬家 → 闸门静默跳过」(2026-09-23 实证:E6/E8/E9 全跳过而无人察觉)。
|
|
259
|
+
const CHANGESET_DIRS = [".codebuddy/design", "design", ".changeset"];
|
|
260
|
+
const relp = (p) => path.relative(PROJECT, p).replace(/\\/g, "/");
|
|
261
|
+
const changesetCandidates = [];
|
|
262
|
+
{
|
|
263
|
+
const explicit = argValue("--changeset", "");
|
|
264
|
+
if (explicit) {
|
|
265
|
+
const p = path.resolve(PROJECT, explicit);
|
|
266
|
+
if (fs.existsSync(p)) changesetCandidates.push(p);
|
|
267
|
+
else err("E0", `--changeset 指定的变更集不存在:${p}`);
|
|
268
|
+
} else {
|
|
269
|
+
for (const d of CHANGESET_DIRS) {
|
|
270
|
+
const dir = path.join(PROJECT, d);
|
|
271
|
+
if (!fs.existsSync(dir)) continue;
|
|
272
|
+
for (const f of fs.readdirSync(dir).filter((x) => /changeset.*\.json$/i.test(x)).sort()) {
|
|
273
|
+
const full = path.join(dir, f);
|
|
274
|
+
if (!changesetCandidates.includes(full)) changesetCandidates.push(full);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
254
279
|
let changeset = null;
|
|
255
280
|
let changesetFile = null;
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
281
|
+
if (changesetCandidates.length > 1) {
|
|
282
|
+
warn(
|
|
283
|
+
"E11",
|
|
284
|
+
`多份变更集并存:${changesetCandidates.map(relp).join("、")} —— 无法判定哪份是「本轮」交付物,暂取最近修改的一份。落点应唯一为 .codebuddy/design/ 并在交付台账登记。`,
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
if (changesetCandidates.length) {
|
|
288
|
+
changesetFile = changesetCandidates.map((p) => ({ p, t: fs.statSync(p).mtimeMs })).sort((a, b) => b.t - a.t)[0].p;
|
|
289
|
+
try {
|
|
290
|
+
changeset = JSON.parse(fs.readFileSync(changesetFile, "utf-8"));
|
|
291
|
+
} catch (e) {
|
|
292
|
+
err("E0", `变更集解析失败:${changesetFile} — ${e.message}`);
|
|
265
293
|
}
|
|
266
294
|
}
|
|
267
295
|
|
|
@@ -584,7 +612,29 @@ function matchArrayLiteral(text, anchorRe) {
|
|
|
584
612
|
}
|
|
585
613
|
}
|
|
586
614
|
|
|
587
|
-
|
|
615
|
+
// 模板里按 prop 条件绑定的列宽(如
|
|
616
|
+
// :min-width="column.prop === 'minCategoryName' ? '120' : undefined"
|
|
617
|
+
// )会覆盖列定义数组里那次「无宽」——不识别就会把已有下限的列误报成无宽列。
|
|
618
|
+
const tplBind = new Map(); // prop → { width?, minWidth? }
|
|
619
|
+
for (const attr of ["width", "min-width"]) {
|
|
620
|
+
for (const m of text.matchAll(new RegExp(`:${attr}="([^"]+)"`, "g"))) {
|
|
621
|
+
for (const c of m[1].matchAll(/[\w.]*prop\s*===\s*['"]([^'"]+)['"]\s*\?\s*['"]?([\d.]+)(?:px)?['"]?/g)) {
|
|
622
|
+
const cur = tplBind.get(c[1]) || {};
|
|
623
|
+
if (attr === "width") cur.width = Number(c[2]);
|
|
624
|
+
else cur.minWidth = Number(c[2]);
|
|
625
|
+
tplBind.set(c[1], cur);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
for (const [prop, v] of tplBind) {
|
|
630
|
+
if (noWidth.some((c) => c.prop === prop)) {
|
|
631
|
+
const label = (noWidth.find((c) => c.prop === prop) || {}).label || prop;
|
|
632
|
+
if (v.width != null) declared.push({ label, prop, w: v.width });
|
|
633
|
+
else if (v.minWidth != null) declared.push({ label, prop, mw: v.minWidth });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const realNoWidth = noWidth.filter((c) => !OPS.includes(c.prop) && !tplBind.has(c.prop));
|
|
588
638
|
for (const c of realNoWidth) {
|
|
589
639
|
err(
|
|
590
640
|
"E5",
|
|
@@ -792,6 +842,381 @@ const lastClassOutsideNot = (sel) => {
|
|
|
792
842
|
})();
|
|
793
843
|
|
|
794
844
|
// ── 输出 ────────────────────────────────────────────────────
|
|
845
|
+
// ── E11 交付完整性 ──────────────────────────────────────────
|
|
846
|
+
// 「落地后只剩主题色」不是落点问题,是上游交付残缺口 + 下游挑拣落地。本项把这两件事都变成 ERROR。
|
|
847
|
+
(function checkE11() {
|
|
848
|
+
if (!changeset) {
|
|
849
|
+
warn(
|
|
850
|
+
"E11",
|
|
851
|
+
`未发现变更集(搜索 ${CHANGESET_DIRS.join(" / ")})—— 本轮交付完整性、收口断言、浮层残留、形态收口均无法判定(闸门静默跳过)。设计还原项目跑不出变更集,本身就是交付缺失。`,
|
|
852
|
+
);
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
const mode = changeset.mode || "design";
|
|
856
|
+
const cov = changeset.coverage;
|
|
857
|
+
const gaps = (cov && cov.gaps) || [];
|
|
858
|
+
const pending = changeset.pending || [];
|
|
859
|
+
const changes = changeset.changes || [];
|
|
860
|
+
const notesText = JSON.stringify(changeset.notes || []);
|
|
861
|
+
|
|
862
|
+
if (mode !== "restore") {
|
|
863
|
+
info("E11", `交付模式 mode=${mode}(规范设计 / 显式轻量调整),跳过覆盖率硬闸;但若本次本意是场景还原,则「还原被降级为 design」= 交付失败。`);
|
|
864
|
+
return;
|
|
865
|
+
}
|
|
866
|
+
if (!cov) {
|
|
867
|
+
err("E11", "mode=restore 但缺少 coverage 通道 —— 还原交付必须有覆盖率记账;缺失即无法判定登记项是否逐条兑现,落地只会剩下主题色一类全局项。");
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
const R = cov.registrable;
|
|
871
|
+
const A = cov.applicable;
|
|
872
|
+
const C = cov.covered;
|
|
873
|
+
const numeric = typeof A === "number" && typeof C === "number";
|
|
874
|
+
if (numeric && C < A) {
|
|
875
|
+
err(
|
|
876
|
+
"E11",
|
|
877
|
+
`欠声明:coverage.covered=${C} < applicable=${A}(登记项 registrable=${R})—— 有 ${A - C} 条登记项未落进 changes[] / visible / pending。落地端只能兑现已声明的那部分,其余点位零变化,这就是「和设计对不上、只变了主题色」的机制成因。`,
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (gaps.length) {
|
|
881
|
+
err("E11", `coverage.gaps 非空(${gaps.length} 条)—— 交付契约要求 gaps 为空才可交付(自由文本理由不算闭合资质)。`);
|
|
882
|
+
if (!pending.length) {
|
|
883
|
+
err(
|
|
884
|
+
"E11",
|
|
885
|
+
`缺口未台账化:gaps 有 ${gaps.length} 条理由,但 pending[] 为空 —— 缺口没有变成可执行工作项,所以「继续」时无任何东西可做,复跑也不可能收敛(实证症状:多轮继续后效果不变)。`,
|
|
886
|
+
);
|
|
887
|
+
} else {
|
|
888
|
+
const declared = new Set(changes.map((c) => c.id));
|
|
889
|
+
const orphan = pending.filter((p) => p && p.proposed_id && !declared.has(p.proposed_id));
|
|
890
|
+
if (orphan.length) {
|
|
891
|
+
err("E11", `pending 中 ${orphan.length} 条未在 changes[] 双写(${orphan.slice(0, 5).map((p) => p.proposed_id).join("、")})—— 只写 pending 的点位不会被施工。`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
// 分母漂移:自报 registrable 必须与设计侧登记表实物一致
|
|
896
|
+
if (typeof R === "number" && cov.registry) {
|
|
897
|
+
const cands = [
|
|
898
|
+
path.join(SKILL_ROOT, "..", "project-ui-design", "references", "page-specs", String(cov.registry)),
|
|
899
|
+
path.join(SKILL_ROOT, "..", "project-ui-design", String(cov.registry)),
|
|
900
|
+
].map((p) => path.resolve(p));
|
|
901
|
+
const real = cands.find((p) => fs.existsSync(p));
|
|
902
|
+
if (real) {
|
|
903
|
+
try {
|
|
904
|
+
const reg = JSON.parse(fs.readFileSync(real, "utf-8"));
|
|
905
|
+
const items = reg.items || reg.registry || [];
|
|
906
|
+
if (Array.isArray(items) && items.length && items.length !== R) {
|
|
907
|
+
err("E11", `登记表分母漂移:coverage.registrable=${R},登记表实物为 ${items.length} 条 —— 自报数与实物不一致(曾发生 186 对 190)。分母错则该报的欠声明数也不可信。`);
|
|
908
|
+
} else if (Array.isArray(items) && items.length) {
|
|
909
|
+
info("E11", `登记表分母对账:registrable=${R} = 实物 ${items.length} 条 ✓`);
|
|
910
|
+
}
|
|
911
|
+
} catch {
|
|
912
|
+
/* 解析异常忽略 */
|
|
913
|
+
}
|
|
914
|
+
} else {
|
|
915
|
+
warn("E11", `登记表实物未解析到(coverage.registry="${cov.registry}")—— 项目侧 G9 身份闸会以「登记表实物不存在」拒绝执行;覆盖率分母必须来自「方案 × 场景」的真实登记表。`);
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
// 交付说明失真:notes 的成功话术与机器可读字段矛盾
|
|
919
|
+
const claimsComplete = /covered\s*[==]\s*applicable|gaps\s*[==]\s*\[\s*\]|覆盖率\s*100|全量覆盖/.test(notesText);
|
|
920
|
+
if (claimsComplete && ((numeric && C < A) || gaps.length)) {
|
|
921
|
+
err(
|
|
922
|
+
"E11",
|
|
923
|
+
`交付说明失真:notes 含「覆盖率 C=A / gaps=[] / 全量覆盖」类完成结论,而字段实为 covered=${C}/applicable=${A}、gaps=${gaps.length} 条 —— 成功话术与机器可读字段互相矛盾,会直接误导下游判定「已交付、无事可做」。`,
|
|
924
|
+
);
|
|
925
|
+
}
|
|
926
|
+
if (!errors.some((e) => e.id === "E11") && !warnings.some((w) => w.id === "E11")) {
|
|
927
|
+
info("E11", `交付完整性:mode=restore,covered=${C}=applicable=${A},gaps 空,台账闭合 ✓`);
|
|
928
|
+
}
|
|
929
|
+
})();
|
|
930
|
+
|
|
931
|
+
// ── E12 设计层累积性(跨轮回退检测 + 交付台账) ──────────────
|
|
932
|
+
// 「继续一次处理不完、继续了也没变化」的根治:把每轮落地状态落成机器可读台账,
|
|
933
|
+
// 使「本轮是否把上一轮成果覆盖掉」可被断言,而不是靠人记得。
|
|
934
|
+
const LEDGER_PATH = path.join(PROJECT, ".codebuddy", "design", "ledger.json");
|
|
935
|
+
const WRITE_LEDGER = !process.argv.includes("--no-ledger");
|
|
936
|
+
(function checkE12() {
|
|
937
|
+
const primaryText = srcTextByFile.get(PRIMARY_LAYER) ?? readIf(PRIMARY_LAYER) ?? "";
|
|
938
|
+
const primaryLines = primaryText ? primaryText.split("\n").length : 0;
|
|
939
|
+
const appliedSids = changeset ? (changeset.changes || []).map((c) => c.id).sort() : [];
|
|
940
|
+
const cov = changeset && changeset.coverage;
|
|
941
|
+
const cur = {
|
|
942
|
+
at: new Date().toISOString(),
|
|
943
|
+
changeset: changesetFile ? relp(changesetFile) : null,
|
|
944
|
+
mode: changeset ? changeset.mode || "design" : null,
|
|
945
|
+
covered: cov ? cov.covered : null,
|
|
946
|
+
applicable: cov ? cov.applicable : null,
|
|
947
|
+
design_shell_lines: primaryLines,
|
|
948
|
+
applied_sids: appliedSids,
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
let ledger = null;
|
|
952
|
+
if (fs.existsSync(LEDGER_PATH)) {
|
|
953
|
+
try {
|
|
954
|
+
ledger = JSON.parse(fs.readFileSync(LEDGER_PATH, "utf-8"));
|
|
955
|
+
} catch {
|
|
956
|
+
ledger = null;
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
const history = (ledger && ledger.history) || [];
|
|
960
|
+
const priorRounds = ledger ? [ledger, ...history] : [];
|
|
961
|
+
|
|
962
|
+
if (!ledger) {
|
|
963
|
+
info("E12", `无交付台账(${relp(LEDGER_PATH)})—— 无法检测「本轮是否覆盖掉上一轮设计层」;已写入本轮台账作为基线。`);
|
|
964
|
+
} else {
|
|
965
|
+
// 与历史峰值比较,避免「坏一轮 → 台账被改写 → 下轮检测不出」的掩盖效应
|
|
966
|
+
const peakLines = Math.max(0, ...priorRounds.map((r) => r.design_shell_lines || 0));
|
|
967
|
+
if (peakLines && primaryLines && primaryLines < peakLines * 0.6) {
|
|
968
|
+
err(
|
|
969
|
+
"E12",
|
|
970
|
+
`交付回退:设计层 ${argValue("--design-layer", "src/styles/design-shell.scss")} 行数 ${peakLines} → ${primaryLines}(骤降 >40%)—— 上一轮已落地的规则被覆盖/丢失。设计层是累积产物,禁止按「出厂空文件」重建;重建必须合并既有规则。(2026-09-23 实证:620 行被覆盖为 75 行,用户侧表现为「布局样式全不对,只剩主题色」。)`,
|
|
971
|
+
);
|
|
972
|
+
}
|
|
973
|
+
const priorSids = new Set(priorRounds.flatMap((r) => r.applied_sids || []));
|
|
974
|
+
const lost = [...priorSids].filter((s) => !appliedSids.includes(s));
|
|
975
|
+
if (priorSids.size && lost.length > Math.max(3, priorSids.size * 0.3)) {
|
|
976
|
+
err("E12", `交付回退:本轮变更集比历史已声明点位少 ${lost.length} 条(${lost.slice(0, 6).join("、")}${lost.length > 6 ? "…" : ""})—— 本轮只覆盖了更小范围,按现状落地会把上一轮成果清掉。`);
|
|
977
|
+
} else if (priorSids.size && lost.length) {
|
|
978
|
+
warn(
|
|
979
|
+
"E12",
|
|
980
|
+
`本轮未覆盖历史已声明点位 ${lost.length} 条(${lost.slice(0, 6).join("、")}${lost.length > 6 ? "…" : ""})—— 若本轮 scope(coverage.page_types)确实不含这些点位可忽略;否则它们会从项目里静默消失。`,
|
|
981
|
+
);
|
|
982
|
+
} else if (priorSids.size) {
|
|
983
|
+
info("E12", `累积性:本轮 ${appliedSids.length} 条 vs 历史已声明 ${priorSids.size} 条,未检出回退`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
if (WRITE_LEDGER) {
|
|
988
|
+
const next = { round: ledger ? (ledger.round || 1) + 1 : 1, ...cur, history: [...history, ...(ledger ? [ledger] : [])].slice(0, 20) };
|
|
989
|
+
for (const r of next.history) delete r.history;
|
|
990
|
+
fs.mkdirSync(path.dirname(LEDGER_PATH), { recursive: true });
|
|
991
|
+
fs.writeFileSync(LEDGER_PATH, JSON.stringify(next, null, 2), "utf-8");
|
|
992
|
+
}
|
|
993
|
+
})();
|
|
994
|
+
|
|
995
|
+
// 列名提取(供 E5/E14 共用):兼容 `label: $t('类型')` / `label="类型"` 两种写法
|
|
996
|
+
const labelOfRaw = (src) => {
|
|
997
|
+
const m =
|
|
998
|
+
src.match(/label:\s*([^,\n}]+)/) || src.match(/\blabel="([^"]*)"/) || src.match(/\blabel='([^']*)'/);
|
|
999
|
+
if (!m) return "?";
|
|
1000
|
+
const s = String(m[1]).trim();
|
|
1001
|
+
const q = s.match(/[`'"]([^`'"]+)[`'"]/);
|
|
1002
|
+
return ((q ? q[1] : s).trim() || "?");
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
// ── E13 固定尺寸 vs 内容度量 ─────────────────────────────────
|
|
1006
|
+
// 「设计说 40px,代码就固定 40px」是本闸治理的对象:像素值不是照抄依据,
|
|
1007
|
+
// 而是「该容器要装多少内容」的结论。固定宽容器装不下内容、又没声明收口/截断
|
|
1008
|
+
// 兜底时,文字会换行、被压缩、甚至字符重叠渲染成畸形。
|
|
1009
|
+
// 判据分两档:① 选择器语义指向文本容器 → 最小需求 = 2 CJK + 左右内边距;
|
|
1010
|
+
// ② 其余固定宽 → 低于 24px 连一个字形都放不下。
|
|
1011
|
+
// 文本承载语义:只认「这个盒子本身要装字」的选择器;图形载体(图标 / 伪元素 /
|
|
1012
|
+
// 分割线 / 开关)不承载文本,不参与内容度量,否则会成片误报。
|
|
1013
|
+
const TEXTY_SEL = /(text|title|label|button|tab|span|desc)/i;
|
|
1014
|
+
const GRAPHIC_LAST = /(el-icon|icon|svg|img|checkbox|radio|switch|avatar|divider|arrow|star|loading|spinner)|::(before|after|placeholder|marker|selection)/i;
|
|
1015
|
+
const MIN_TEXT_BOX = 44;
|
|
1016
|
+
const MIN_ANY_BOX = 24;
|
|
1017
|
+
(function checkE13() {
|
|
1018
|
+
if (!designRules.length) {
|
|
1019
|
+
info("E13", "固定尺寸内容度量:无设计层规则");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// 项目源码里真实存在的类名(排除设计层自身)——用于剔除「挂空选择器」,
|
|
1023
|
+
// 挂空由 E4 单独报,此处只管「锚点存在但装不下内容」。
|
|
1024
|
+
const projectClasses = new Set();
|
|
1025
|
+
for (const [f, t] of srcTextByFile) {
|
|
1026
|
+
if (designSet.has(path.resolve(f))) continue;
|
|
1027
|
+
for (const m of t.matchAll(/class\s*[:=]\s*["'`]([^"'`]+)["'`]/g)) {
|
|
1028
|
+
for (const c of m[1].split(/[\s{}(),:$'"]+/)) if (/^[A-Za-z][\w-]*$/.test(c)) projectClasses.add(c);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
// 同一选择器上的「兄弟规则」也算保护来源(收口常写成独立规则块)
|
|
1032
|
+
const hasProtect = (decls) =>
|
|
1033
|
+
String(decls.overflow || "").match(/hidden|clip/) ||
|
|
1034
|
+
String(decls["text-overflow"] || "").match(/ellipsis/) ||
|
|
1035
|
+
String(decls.display || "").match(/none/);
|
|
1036
|
+
const protectBySel = new Set();
|
|
1037
|
+
for (const r of designRules) {
|
|
1038
|
+
if (hasProtect(r.decls)) protectBySel.add(r.selector);
|
|
1039
|
+
}
|
|
1040
|
+
const hits = [];
|
|
1041
|
+
for (const r of designRules) {
|
|
1042
|
+
const raw = r.decls.width;
|
|
1043
|
+
if (raw == null) continue;
|
|
1044
|
+
if (r.decls["min-width"] != null || r.decls["max-width"] != null) continue;
|
|
1045
|
+
const m = String(raw).match(/^(\d+(?:\.\d+)?)\s*px$/i);
|
|
1046
|
+
if (!m) continue;
|
|
1047
|
+
const n = Number(m[1]);
|
|
1048
|
+
const isTexty = TEXTY_SEL.test(r.selector);
|
|
1049
|
+
const floor = isTexty ? MIN_TEXT_BOX : MIN_ANY_BOX;
|
|
1050
|
+
if (n >= floor) continue;
|
|
1051
|
+
if (hasProtect(r.decls)) continue;
|
|
1052
|
+
if (protectBySel.has(r.selector)) continue;
|
|
1053
|
+
if (GRAPHIC_LAST.test(lastCompound(r.selector)) || GRAPHIC_LAST.test(r.selector)) continue;
|
|
1054
|
+
const toks = classTokens(r.selector);
|
|
1055
|
+
if (toks.length && !toks.some((c) => projectClasses.has(c))) continue; // 挂空 → 交 E4
|
|
1056
|
+
hits.push({ r, n, floor, isTexty });
|
|
1057
|
+
}
|
|
1058
|
+
for (const h of hits.slice(0, 8)) {
|
|
1059
|
+
err(
|
|
1060
|
+
"E13",
|
|
1061
|
+
`${relp(h.r.file)}:\`${h.r.selector}\` 固定 width:${h.n}px —— ` +
|
|
1062
|
+
(h.isTexty
|
|
1063
|
+
? `该选择器指向文本容器,装下 2 个汉字 + 内边距至少需 ${h.floor}px`
|
|
1064
|
+
: `低于任何字形盒子的下限 ${h.floor}px`) +
|
|
1065
|
+
`;且未声明收口兜底(overflow:hidden / text-overflow:ellipsis / display:none)。` +
|
|
1066
|
+
`固定宽小于内容需求时文字会换行、被压缩或重叠渲染 —— ` +
|
|
1067
|
+
`请改为内容驱动(min-width / 自适应),或在同一作用域内显式写出收口策略(形态收容 + 文案隐藏成对)。`,
|
|
1068
|
+
);
|
|
1069
|
+
}
|
|
1070
|
+
if (hits.length > 8) err("E13", `固定尺寸内容度量:另有 ${hits.length - 8} 处未列出。`);
|
|
1071
|
+
if (!hits.length) info("E13", `固定尺寸内容度量:${designRules.length} 条设计层规则中未检出「固定宽 < 内容下限且无收口」`);
|
|
1072
|
+
})();
|
|
1073
|
+
|
|
1074
|
+
// ── E14 列宽预算链自洽(列定义宽 vs 实际 td 内边距)──────────
|
|
1075
|
+
// 列宽与内容需求必须用同一套「可用宽」口径。实例:列宽公式按单元格留白 24px
|
|
1076
|
+
// 推算,而设计层把 td padding 设为 16px(左右共 32px)→ 列宽比真实可用宽多算
|
|
1077
|
+
// 8px,内容必然溢出、被内层盒压缩 → 操作按钮文字换行 / 重叠。
|
|
1078
|
+
(function checkE14() {
|
|
1079
|
+
const tableFiles = [...srcTextByFile.entries()].filter(([, t]) => t.includes("el-table-column"));
|
|
1080
|
+
if (!tableFiles.length) {
|
|
1081
|
+
info("E14", "列宽预算链:未在 src 中发现 el-table-column");
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1084
|
+
// 设计层对数据格宿主的水平内边距(实际生效值)
|
|
1085
|
+
let tdPadX = null;
|
|
1086
|
+
for (const r of designRules) {
|
|
1087
|
+
if (!/td\.el-table__cell|\.el-table__body\s+td/.test(r.selector)) continue;
|
|
1088
|
+
const p = r.decls.padding;
|
|
1089
|
+
if (!p) continue;
|
|
1090
|
+
const parts = String(p).replace(/!important/g, "").trim().split(/\s+/);
|
|
1091
|
+
const px = (v) => {
|
|
1092
|
+
const mm = String(v).match(/(\d+(?:\.\d+)?)px/);
|
|
1093
|
+
return mm ? Number(mm[1]) : null;
|
|
1094
|
+
};
|
|
1095
|
+
const vals = parts.map(px).filter((v) => v != null);
|
|
1096
|
+
if (!vals.length) continue;
|
|
1097
|
+
// padding: A → 四边 A;A B → 上下 A 左右 B;A B C → 上 A 左右 B 下 C
|
|
1098
|
+
const x = vals.length === 1 ? vals[0] : vals.length === 2 ? vals[1] : vals.length === 3 ? vals[1] : vals[3];
|
|
1099
|
+
tdPadX = x * 2;
|
|
1100
|
+
break;
|
|
1101
|
+
}
|
|
1102
|
+
// 操作列:内容需求 = 平铺槽数 × 槽宽(与组件同源公式)
|
|
1103
|
+
const slotW = (() => {
|
|
1104
|
+
const src = readIf(path.join(PROJECT, "src", "components", "ReTableOperate", "utils.ts"));
|
|
1105
|
+
if (!src) return null;
|
|
1106
|
+
const moreChars = Number((src.match(/MORE_LABEL_CHARS\s*=\s*(\d+)/) || [])[1] || 3);
|
|
1107
|
+
const fsMatch = src.match(/default:\s*(\d+)/);
|
|
1108
|
+
const fs = fsMatch ? Number(fsMatch[1]) : 14;
|
|
1109
|
+
const btnSrc = [...srcTextByFile.entries()].find(([, t]) => /operateButtons\s*(?::[^=]+)?=\s*\[/.test(t));
|
|
1110
|
+
let maxChars = 2;
|
|
1111
|
+
if (btnSrc) {
|
|
1112
|
+
const arr = matchArrayLiteral(btnSrc[1], /operateButtons\s*(?::[^=]+)?=\s*\[/);
|
|
1113
|
+
if (arr) for (const obj of extractObjectLiterals(arr)) {
|
|
1114
|
+
const lm = obj.match(/label\s*:\s*(?:\$t\()?['"`]([^'"`]+)/);
|
|
1115
|
+
if (lm) maxChars = Math.max(maxChars, [...lm[1]].length);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
maxChars = Math.max(maxChars, moreChars);
|
|
1119
|
+
return Math.ceil(fs + 4 + maxChars * fs);
|
|
1120
|
+
})();
|
|
1121
|
+
for (const [file, text] of tableFiles) {
|
|
1122
|
+
const rel = relp(file);
|
|
1123
|
+
const arr = matchArrayLiteral(text, /(?:const|let|var)\s+\w*[Cc]olumn\w*\s*(?::[^=]+)?=\s*\[/);
|
|
1124
|
+
if (!arr) continue;
|
|
1125
|
+
const limMatch = text.match(/operateLimit\s*=\s*(\d+)/);
|
|
1126
|
+
const limit = limMatch ? Number(limMatch[1]) : null;
|
|
1127
|
+
for (const objSrc of extractObjectLiterals(arr)) {
|
|
1128
|
+
const prop = (objSrc.match(/prop:\s*[`'"]([^`'"]+)/) || [])[1];
|
|
1129
|
+
if (!prop) continue;
|
|
1130
|
+
const label = labelOfRaw(objSrc);
|
|
1131
|
+
const ops = /^(operate|operation|actions)$/.test(prop);
|
|
1132
|
+
const wLit = (objSrc.match(/\bwidth\s*:\s*[`'"]([\d.]+)px/) || [])[1];
|
|
1133
|
+
const colW = wLit ? Number(wLit) : null;
|
|
1134
|
+
if (!ops) continue;
|
|
1135
|
+
// 模板是否用公式覆盖了列定义字面量 —— 「同一尺寸两个来源」的第一现场
|
|
1136
|
+
const usesFormula = /resolveOperateColumnWidth\s*\(/.test(text);
|
|
1137
|
+
if (colW == null && !usesFormula) continue;
|
|
1138
|
+
if (slotW == null || limit == null) {
|
|
1139
|
+
warn("E14", `${rel}:操作列「${label}」无法解析槽宽/平铺上限(ReTableOperate/utils.ts 或 operateLimit 缺失),跳过列宽预算链核对。`);
|
|
1140
|
+
continue;
|
|
1141
|
+
}
|
|
1142
|
+
const utilSrc = readIf(path.join(PROJECT, "src", "components", "ReTableOperate", "utils.ts")) || "";
|
|
1143
|
+
const padInFormula = Number((utilSrc.match(/cellPadding\s*=\s*options\.cellPadding\s*\?\?\s*(\d+)/) || [])[1] || 24);
|
|
1144
|
+
const formulaW = limit * slotW + padInFormula;
|
|
1145
|
+
const effectiveW = usesFormula ? formulaW : colW != null ? colW : formulaW;
|
|
1146
|
+
const contentNeed = limit * slotW;
|
|
1147
|
+
const available = tdPadX != null ? effectiveW - tdPadX : effectiveW - padInFormula;
|
|
1148
|
+
if (usesFormula && colW != null && colW !== formulaW) {
|
|
1149
|
+
err(
|
|
1150
|
+
"E14",
|
|
1151
|
+
`${rel}:操作列「${label}」列宽有两个来源且不一致 —— 列定义写死 width="${wLit}px",` +
|
|
1152
|
+
`但模板用 \`resolveOperateColumnWidth()\` 覆盖为 ${formulaW}px(${limit}×${slotW}+${padInFormula}),实际生效 ${effectiveW}px。` +
|
|
1153
|
+
`同一尺寸不得有两个权威来源:请删掉列定义里的死值,只保留公式(或反之),否则改一处不生效、排查时按死值反推必然误判。`,
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
if (contentNeed > available) {
|
|
1157
|
+
err(
|
|
1158
|
+
"E14",
|
|
1159
|
+
`${rel}:操作列「${label}」列宽预算链断裂 —— 列宽 ${effectiveW}px(${colW != null ? `列定义 width="${wLit}px"` : `公式 ${limit}×${slotW}+${padInFormula}`})` +
|
|
1160
|
+
`扣掉实际单元格水平内边距 ${tdPadX != null ? `${tdPadX}px(设计层 td padding)` : `${padInFormula}px(公式默认)`} 后可用 ${available}px,` +
|
|
1161
|
+
`而平铺 ${limit} 个等宽槽需 ${contentNeed}px → 溢出 ${contentNeed - available}px。` +
|
|
1162
|
+
`列宽公式的内置留白必须与实际生效的 td padding 同口径;此处请把列宽抬到 ≥ ${contentNeed + (tdPadX ?? padInFormula)}px,或同步下调内边距,` +
|
|
1163
|
+
`否则按钮文字会被内层盒压缩、换行甚至字符重叠。`,
|
|
1164
|
+
);
|
|
1165
|
+
} else if (tdPadX != null && padInFormula !== tdPadX) {
|
|
1166
|
+
warn(
|
|
1167
|
+
"E14",
|
|
1168
|
+
`${rel}:操作列「${label}」列宽公式内置单元格留白 ${padInFormula}px ≠ 设计层实际 td 水平内边距 ${tdPadX}px —— 当前差额 ${Math.abs(padInFormula - tdPadX)}px 尚未导致溢出,但任一参数变动即越界;建议统一为同一来源。`,
|
|
1169
|
+
);
|
|
1170
|
+
} else {
|
|
1171
|
+
info("E14", `${rel}:操作列「${label}」列宽 ${effectiveW}px,可用 ${available}px ≥ 内容 ${contentNeed}px ✓`);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
})();
|
|
1176
|
+
|
|
1177
|
+
// ── E16 引用完整性(交付物指向的实物必须存在)────────────────
|
|
1178
|
+
// 标识(文件名 / 路径)变更后未同步引用 = 静默断链:闸门找不到基线图、
|
|
1179
|
+
// 项目侧 parser 取不到登记表,全链路降级为「凭印象还原」。
|
|
1180
|
+
(function checkE16() {
|
|
1181
|
+
if (!changeset) {
|
|
1182
|
+
info("E16", "引用完整性:未发现变更集");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
const probes = [
|
|
1186
|
+
["oracle.mockup", changeset.oracle?.mockup],
|
|
1187
|
+
["coverage.registry", changeset.coverage?.registry],
|
|
1188
|
+
["oracle.theme_mapping.mockup", changeset.oracle?.theme_mapping?.mockup],
|
|
1189
|
+
];
|
|
1190
|
+
let checked = 0;
|
|
1191
|
+
for (const [field, val] of probes) {
|
|
1192
|
+
if (!val || typeof val !== "string") continue;
|
|
1193
|
+
if (/^https?:\/\//.test(val)) continue;
|
|
1194
|
+
checked++;
|
|
1195
|
+
const cands = [
|
|
1196
|
+
path.resolve(PROJECT, val),
|
|
1197
|
+
path.resolve(SKILL_ROOT, val),
|
|
1198
|
+
path.resolve(SKILL_ROOT, "..", val),
|
|
1199
|
+
path.resolve(PROJECT, "..", val),
|
|
1200
|
+
];
|
|
1201
|
+
if (!cands.some((p) => fs.existsSync(p))) {
|
|
1202
|
+
err(
|
|
1203
|
+
"E16",
|
|
1204
|
+
`变更集 \`${field}\` 指向的实物不存在:${val} —— 标识变更(改名 / 换目录)后未同步引用,` +
|
|
1205
|
+
`闸门与 parser 都取不到基线(会静默降级为无基线还原)。请同步引用方(变更集字段 / 方案注册表 baseline / 登记表路径)。`,
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
// restore 模式的顶层 theme 字段:缺失会让「本次主题」回落到 global.brand_primary 反推,
|
|
1210
|
+
// 重新引入「换主题 = 换读取路径」的分叉
|
|
1211
|
+
if ((changeset.mode || "design") === "restore" && !changeset.theme) {
|
|
1212
|
+
warn(
|
|
1213
|
+
"E16",
|
|
1214
|
+
"变更集 mode=restore 但缺顶层 `theme` 字段 —— 本次主题只能回落按 `global.brand_primary` 反推(旧口径)。补 `theme` 可让「本次主题」与「基线图色板」两个身份各自可机读、互不推断。",
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
if (checked && !errors.some((e) => e.id === "E16")) info("E16", `引用完整性:${checked} 个路径引用均可解析到实物 ✓`);
|
|
1218
|
+
})();
|
|
1219
|
+
|
|
795
1220
|
const result = { project: PROJECT, errors, warnings, infos };
|
|
796
1221
|
|
|
797
1222
|
const CHECK_TITLES = {
|
|
@@ -805,6 +1230,11 @@ const CHECK_TITLES = {
|
|
|
805
1230
|
E8: "浮层残留",
|
|
806
1231
|
E9: "形态收口缺件",
|
|
807
1232
|
E10: "scoped 规则挂空",
|
|
1233
|
+
E11: "交付完整性",
|
|
1234
|
+
E12: "设计层累积性",
|
|
1235
|
+
E13: "固定尺寸内容度量",
|
|
1236
|
+
E14: "列宽预算链自洽",
|
|
1237
|
+
E16: "引用完整性",
|
|
808
1238
|
SID: "@sid 覆盖率",
|
|
809
1239
|
};
|
|
810
1240
|
|