Files
WorkBuddy 2c325136ab fix(miniapp): 修复 WXSS 通配符导致编译失败,并把 WXSS 校验升级为真编译
问题
- v2 设计收敛在 app.wxss 新增 `.tap-gap > * + *`。WXSS 不支持通配符 `*`,
  wcc/wcsc 编译期直接报 `error at token '*'`,整个小程序编译不过。
- 更值得警惕的是当时 19 个 UI 契约守卫全绿:静态守卫刻意只扫
  `miniapp/components/`(page 的 wxss 允许标签选择器,套用会误报),
  app.wxss 落在扫描范围外,且判据里根本没有通配符这一项。

修复
- 移除 `.tap-gap > * + *`。复检 v2 新增的 .tap-min / .tap-gap / .focusable / .pressable
  四个工具类全仓零引用(等价能力已各自写在组件样式内,如 button/index.wxss 直接用
  `min-height: var(--tap-min)`),且 `.focusable:focus` / `.pressable:active` 在 view 上
  不生效(小程序须用 hover-class),属"写了等于没写",故整块移除,只保留 token。
- 修掉同块注释里的嵌套 `/* */`:内层结束标记会提前终止外层注释,其后的文字会被
  当成真实样式解析。该约束已写进注释,防止后人再踩。

守卫加固
- scripts/lib/wxss-selectors.mjs
  * 新增 findUniversalSelectors(通配符判据,排除 `*=` 属性匹配符与注释内星号)
  * 新增 findNestedComments(注释嵌套判据)
  * 修 stripComments:原实现用等长空格替换,把注释内的换行也吃掉,导致报错行号
    整体偏移(实测偏差 68 行);现保留换行,并在定位时跳过选择器前的注释占位空白
- ui-tests/wxss-component-selector-contracts.mjs
  * 第 1 级:miniapp/components/(原判据,保留)
  * 第 2 级(新增):全量 miniapp/**/*.wxss,覆盖 app.wxss 与 pages
  * 第 3 级(新增):注释嵌套
  * 三级各自带正/反自检样例

新增
- scripts/miniapp-compile-check.mjs:直接调用微信开发者工具内置的 wcc/wcsc 做真编译预检
  (wcsc 单独调用不解析 @import 相对路径,脚本会先内联再编译)。
  CI runner 无该工具时输出 SKIPPED 并退出 0;刻意不挂进 run-ui-contracts —— 一个在 CI 里
  什么都不做的守卫被计入"全绿",正是本项目反复强调的假绿。CI 拦截由上面的静态守卫承担。

验证(均实际执行)
- node scripts/miniapp-compile-check.mjs → WXSS 88 个 + WXML 86 个,0 失败
- 负向注入:把 `.tap-gap > * + *` 改回去 → 预检 FAIL(exit 1),报错与用户原始报错逐字一致
  `app.wxss(364:12): error at token '*'`;已还原并复测通过
- node scripts/run-ui-contracts.mjs → 20 / 20 PASS
2026-09-23 08:25:11 +08:00

243 lines
9.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 组件 WXSS 选择器检查(lib):供 CLI 与 UI 契约门禁共用。
//
// 规则来源:微信小程序**自定义组件**的 wxss 只允许类选择器。使用标签选择器(`image`/`view`…)、
// ID 选择器(`#id`)、属性选择器(`[attr]`)时开发者工具警告:
// [pages/index/index] Some selectors are not allowed in component wxss,
// including tag name selectors, ID selectors, and attribute selectors.
// (./components/recommend-card/index.wxss:1:1092)
//
// ⚠️ 作用域:**page 的 wxss 不受此限制**(页面不是组件)。
// 对 `miniapp/pages/` 套用本规则会产生误报,故只管 `miniapp/components/`。
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
/** 去掉注释,并用等长空白占位以保持原始字符偏移(报错行号才准)
*
* ⚠️ 2026-09-23 修正:占位必须**保留换行符**。此前用 `' '.repeat(m.length)` 会把
* 多行注释内部的换行一起吞掉,导致其后所有内容的行号整体前移(实测 app.wxss
* 报 296 行、真实位置 366 行,偏差 70 行)。原守卫只扫 components/(注释较少、
* 头注释短)所以一直没暴露;一旦把范围扩到 app.wxss(36 行头注释)立刻失真。
* 行号不可信等于守卫不可用 —— 排查时根本定位不到。
*/
export function stripComments(src) {
return src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
}
/** 切出所有「选择器前置文本」(`{` 之前),跳过 at-rule 与声明块内部 */
export function extractSelectorPreludes(src) {
const preludes = [];
let depth = 0;
let buf = '';
let bufStart = 0;
for (let i = 0; i < src.length; i++) {
const c = src[i];
if (c === '{') {
if (depth === 0) preludes.push({ text: buf, index: bufStart });
depth++;
buf = '';
continue;
}
if (c === '}') {
depth = Math.max(0, depth - 1);
buf = '';
continue;
}
if (depth === 0) {
if (buf === '') bufStart = i;
buf += c;
}
}
return preludes;
}
/**
* 检查一段选择器前置文本是否含违规成分。
* @returns {string[]} 违规描述(去重)
*/
export function findViolations(selector) {
const bad = [];
const s = selector.trim();
if (!s) return bad;
if (s.startsWith('@')) return bad; // @media / @keyframes / @import 不是选择器
// 去掉伪类/伪元素及其函数括号内容,避免把 `:nth-child(2)` 里的标识符当标签
const cleaned = s.replace(/:{1,2}[a-zA-Z-]+(\([^)]*\))?/g, '').replace(/\([^)]*\)/g, '');
for (const part of cleaned.split(',')) {
const token = part.trim();
if (!token) continue;
const idMatch = token.match(/#[A-Za-z_][\w-]*/);
if (idMatch) bad.push(`ID 选择器 \`${idMatch[0]}\``);
const attrMatch = token.match(/\[[^\]]*\]/);
if (attrMatch) bad.push(`属性选择器 \`${attrMatch[0]}\``);
// 标签选择器:`>`/空格/`+`/`~` 之后紧跟的裸元素名
const tagMatch = token.match(/(^|[\s>+~])([a-zA-Z][\w-]*)/);
if (tagMatch) bad.push(`标签选择器 \`${tagMatch[2]}\``);
}
return [...new Set(bad)];
}
/**
* 计算选择器在**原文件**中的行号。
*
* 为什么需要单独一个 helper:
* `extractSelectorPreludes` 给出的 index 是「上一个规则块结束之后」的位置,
* 它与选择器之间可能夹着大段注释占位空白(app.wxss 实测偏差 24 行 —— 报 340
* 实际 364)。必须再跳过 text 的前导空白,落到选择器真正开始的位置。
* 2026-09-23 修正;此前 `clean.slice(0, index)` 的写法在 components/ 里偏移较小
* 未暴露,范围一扩到 app.wxss(长头注释)立刻失真。
*/
function lineOfSelector(clean, index, text) {
const leading = text.length - text.trimStart().length;
return clean.slice(0, index + leading).split('\n').length;
}
/**
* 检查单个 wxss 文件内容。
* @returns {{line:number, message:string}[]}
*/
export function checkWxss(src) {
const clean = stripComments(src);
const out = [];
for (const { text, index } of extractSelectorPreludes(clean)) {
for (const v of findViolations(text)) {
out.push({
line: lineOfSelector(clean, index, text),
message: `${v}(选择器原文 \`${text.trim().slice(0, 60)}\`)`,
});
}
}
return out;
}
export function walkWxss(dir, out = []) {
let entries;
try {
entries = readdirSync(dir, { withFileTypes: true });
} catch {
return out;
}
for (const e of entries) {
const p = join(dir, e.name);
if (e.isDirectory()) {
if (!['node_modules', 'miniprogram_npm'].includes(e.name)) walkWxss(p, out);
} else if (e.name.endsWith('.wxss')) {
out.push(p);
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// 通配符选择器 `*` —— **编译期致命**,与作用域无关(2026-09-23 新增)
//
// 实证:`miniapp/app.wxss` 里的 `.tap-gap > * + *` 使开发者工具编译直接失败:
// [ WXSS 文件编译错误] ./app.wxss
// ./app.wxss(356:12): error at token `*`
//
// 与上面「组件选择器限制」是**两个不同级别的问题**,不可混淆:
// 组件限制(标签/ID/属性)→ 只警告,样式不生效,且仅限 components/
// 通配符 `*` → 直接编译失败,app.wxss / pages / components 全都不允许
// 因此这一项必须独立成函数,并对**全部** wxss 施加。
//
// 顺带修正一个此前的过度推断:报错列号 356:12 恰好落在 `*` 上,说明解析器已接受
// 其左侧的 `>` 与 `+` —— 子选择器与相邻兄弟选择器**不在**禁止范围,不应误报。
// ─────────────────────────────────────────────────────────────────────────────
/** 检查一段选择器前置文本是否含通配符 `*` */
export function findUniversalSelectors(selector) {
const bad = [];
const s = selector.trim();
if (!s || s.startsWith('@')) return bad;
// 把函数括号内容折叠为 `()`:`:nth-child(2)` → `:nth-child()`,保持偏移语义
const cleaned = s.replace(/\([^)]*\)/g, '()');
for (const part of cleaned.split(',')) {
const token = part.trim();
if (!token) continue;
// 边界条件:`*` 前是行首/空白/组合符,后是空白/组合符/伪类/类/属性/行尾;
// 且排除 `*=`(属性匹配符,如 `[class*="x"]`)—— 那是合法 CSS,不在本规则内
if (/(^|[\s>+~])\*(?!\s*=)(?=[\s>+~:.#\[]|$)/.test(token)) bad.push('通配符选择器 `*`');
}
return [...new Set(bad)];
}
/**
* 全量检查单个 wxss 的通配符使用(任何作用域都禁止)。
* @returns {{line:number, message:string}[]}
*/
export function checkWxssUniversal(src) {
const clean = stripComments(src);
const out = [];
for (const { text, index } of extractSelectorPreludes(clean)) {
for (const v of findUniversalSelectors(text)) {
out.push({
line: lineOfSelector(clean, index, text),
message: `${v}(选择器原文 \`${text.trim().slice(0, 60)}\`)`,
});
}
}
return out;
}
// ─────────────────────────────────────────────────────────────────────────────
// 注释嵌套检测(2026-09-23 新增)
//
// CSS 注释**不支持嵌套**:解析器遇到第一个结束标记就退出注释态。若在注释里又写
// 了内层注释,外层注释会被提前截断,其后的文字被当成真实样式解析 ——
// 轻则样式污染,重则 wcc 直接编译失败。
//
// 实证:app.wxss 的"可访问性说明"注释内写了内层注释(形如 斜杠星号…星号斜杠),
// 结果外层在第 2 行就被截断,尾部残留 `gap: var(--tap-gap);` 与孤立的 `*`
// 被当成选择器文本;负向注入时守卫因此报出一条**原因完全错误**的命中
// (选择器原文显示成 `gap: var(--tap-gap);`)—— 这也说明"变红"本身不足以证明
// 守卫有效,必须核对变红的原因。
//
// 判据:逐字符状态机。不在注释态却遇到结束标记 ⇒ 必有嵌套。
// ─────────────────────────────────────────────────────────────────────────────
/**
* @returns {{line:number, message:string}[]}
*/
export function findNestedComments(src) {
const out = [];
let i = 0;
let line = 1;
let inComment = false;
let commentStart = 0;
while (i < src.length) {
const c = src[i];
const n = src[i + 1];
if (c === '\n') line++;
if (!inComment && c === '/' && n === '*') {
inComment = true;
commentStart = line;
i += 2;
continue;
}
if (inComment && c === '*' && n === '/') {
inComment = false;
i += 2;
continue;
}
if (!inComment && c === '*' && n === '/') {
out.push({
line,
message: `注释嵌套:多余的结束标记(外层注释自第 ${commentStart} 行起被提前截断,其后文字会被当作真实样式解析)`,
});
i += 2;
continue;
}
i++;
}
if (inComment) {
out.push({ line: commentStart, message: '注释未闭合:文件结束时仍处于注释态' });
}
return out;
}
export { readFileSync };