类名守门补上条件类与精确 selector 匹配,堵掉两处会静默放行的漏洞
Project CI / Repository checks (pull_request) Successful in 4m13s
Project CI / Frontend tests (pull_request) Successful in 4m22s
Project CI / Native shell tests (pull_request) Successful in 12m18s
Project CI / Backend tests (pull_request) Successful in 5m31s

这条守门是为「#193 把策划选择器整段误删」设的,但它自己有两处会放行:

一、条件类进不了清单。抽取时 `.replace(/\$\{[^}]*\}/g, ' ')` 把插值整段丢掉,而两个
组件里唯一的条件类恰好长在插值里:``plan-gdd-surface${showCard ? ' …--with-card' : ''}``。
清单 25 个类里没有 `plan-gdd-surface--with-card`。它不是装饰性的:
`.game-workbench-chat .plan-gdd-surface--with-card` 的 `grid-template-rows: auto
minmax(0, 1fr)` 是审批卡内滚的边界,策划面自己是 `display: grid` + `overflow: hidden`
的外壳,删掉这条,批准后的交付行连同路径和两个按钮会被切在壳外。实测把该类的两条
规则整段删掉,改前的测试照样绿。

改成只取插值里的字符串字面量——三元的条件和变量名不是类名,不能混进清单。清单
25 → 26,新进的正是 `plan-gdd-surface--with-card`,没有杂质。

二、`styles.includes('.' + name)` 是子串匹配,`.gdd-approval-card__header` 可以冒充
`.gdd-approval-card` 的存在证据。改成要求类名后面不再跟类名字符。当前 26 个类逐一
校验都有精确 selector,所以这一处堵的是潜在放行,不是正在掩盖的缺失。

另按评审要求补一条负向钉子:单独钉住上面那条承重的行模板。存在性检查只要 `--with-card`
还剩任意一条规则就绿,而两条里只有它是承重的。

三条负向都已验证非空转:删两条规则 → 红;只删承重那条留下分隔线 → 红;把
`.gdd-approval-card__details` 改名只留派生类 → 红(改前用 includes 会绿)。

评审同时建议做 computed-style/布局断言,这里没做:appSurface 跑 jsdom,没有布局引擎,
且这套 suite 不把 styles.css 挂进 DOM。要真断言得换真实浏览器 runner,是另一件事。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 10:13:34 +00:00
parent 763f4b5b4c
commit cf01c99fe6
@@ -546,7 +546,18 @@ export function registerPlanGddApprovalTests() {
for (const match of componentSources.matchAll(
/className=(?:"([^"]+)"|\{`([^`]+)`\})/g,
)) {
const literal = (match[1] ?? match[2] ?? '').replace(/\$\{[^}]*\}/g, ' ');
// 条件类长在插值里:`plan-gdd-surface${showCard ? ' …--with-card' : ''}`。
// 把 `${…}` 整段丢掉等于把它们排除在守门之外,而它们恰恰是最容易被顺手删干净
// 的一档——`--with-card` 挂着审批卡的行模板,没有它卡片底部会被外壳的
// `overflow: hidden` 切掉。只取插值里的字符串字面量:三元的条件、变量名都不是
// 类名,不能混进清单。
const literal = (match[1] ?? match[2] ?? '').replace(
/\$\{([^}]*)\}/g,
(_whole, expression: string) =>
[...expression.matchAll(/'([^']*)'|"([^"]*)"/g)]
.map((piece) => piece[1] ?? piece[2] ?? '')
.join(' '),
);
for (const name of literal.split(/\s+/)) {
if (name) {
referencedClasses.add(name);
@@ -559,9 +570,15 @@ export function registerPlanGddApprovalTests() {
'utf8',
);
for (const name of referencedClasses) {
// 子串匹配会把 `.gdd-approval-card__header` 当成 `.gdd-approval-card` 的证据:
// 前缀类的规则被删光、只剩派生类时这里照样绿。要求类名后面不能再跟类名字符,
// 才是「存在这个类的精确 selector」。
const exactSelector = new RegExp(
`\\.${name.replace(/[^\w-]/g, '\\$&')}(?![\\w-])`,
);
expect(
styles.includes(`.${name}`),
`styles.css 缺少 .${name}规则`,
exactSelector.test(styles),
`styles.css 缺少 .${name}精确 selector`,
).toBe(true);
}
// 正文弹层必须是浮层:backdrop 一旦丢掉 fixed 定位,整个 GDD 会内联平铺进
@@ -586,5 +603,12 @@ export function registerPlanGddApprovalTests() {
expect(styles).toMatch(
/\.game-workbench-chat\s+\.project-supervisor-conversation:has\([^)]*\.planning-lane-runtime-strip[^)]*\)\s*\{[^}]*display:\s*flex/s,
);
// `--with-card` 有两条规则,上面的存在性检查只要还剩一条就绿。承重的是这一条:
// 策划面是 `display: grid` + `overflow: hidden` 的外壳,审批卡的 `max-height`
// 和内滚要靠这个行模板才有边界。只删它、留下那条分隔线,表现是批准后的交付行
// 连同路径和两个按钮被切在壳外。
expect(styles).toMatch(
/\.game-workbench-chat\s+\.plan-gdd-surface--with-card\s*\{[^}]*grid-template-rows:\s*auto minmax\(0, 1fr\)/s,
);
});
}