Files
Jyotisha/frontend/tests/class-name-definition-contract.test.ts
T
Jesse_ChenandClaude Opus 5 b3ea8c336e
Independent Staging Quality Gate / validate (push) Failing after 9m19s
Independent Staging Quality Gate / publish (push) Skipped
feat(ui): 侧栏三段合成一条平铺列表,助手消息去掉头像
侧栏此前在第一条会话之前压着三段:星盘名字 chip 组、收藏对话 <details>、
历史对话 <details>——两个折叠箭头,底下都没有值得折叠的东西。现在是一条
平铺列表:收藏作为首个标签组(带 13px 星标),其余沿用既有的日期分组。

星盘入口收敛到一处。这里与任务书 D6 的字面写法方向相反:D6 说留侧栏删
账户菜单,我做的是删侧栏留账户菜单。理由——D6 写于侧栏只有 2 个导航项时,
现在是 4 个;且 chip 点下去只是开弹窗(page.tsx 里三个回调都是
openAccountDialog("chart-library")),一组只会开弹窗的名字没有导航价值。
连带删 charts/onSelectChart/onAddChart props、SidebarChart 类型、
sidebarCharts 派生与一批 CSS。

助手头像删除,--assistant-content-inset 由 calc(32px + gap) 归零,
追问 chip 与运行时间轴改为与正文对齐。/jyotish-logo.png 文件保留。

顺带关闭 R2 留下的隐患:入口 pill 36px 低于 44px 触摸推荐值,补
::after inset -4px 热区扩展,并在 touch-target-contract 里接替
已删除的 .chart-nav-chip。

测试 3350 不变,fail 仍 31 且与基线逐条一致;/ 仍 Static;
CSS gzip 41,044→39,825(−3.0%),三轮累计 −3.1%。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0193vBv6w5MV2cifdTUu9H5P
2026-09-16 05:46:29 +00:00

135 lines
5.7 KiB
TypeScript

import assert from "node:assert/strict";
import { readFileSync, readdirSync } from "node:fs";
import test from "node:test";
// Guards the defect class behind BUG-434 / BUG-435: a className the CSS never
// defines renders as an unstyled element and silently breaks the layout, with
// nothing in the build or the type checker to catch it.
//
// Scope: only the project's own class families. A token counts as ours when its
// first segment already names a family in our CSS (`paywall-`, `message-`,
// `dialog-`, `danger-`, ...), which keeps Tailwind utilities out without having
// to enumerate them.
const projectRoot = new URL("../", import.meta.url);
const sourceRoot = new URL("src/", projectRoot);
const walk = (dir: URL, ext: readonly string[]): string[] =>
readdirSync(dir, { recursive: true, encoding: "utf8" })
.filter((entry) => ext.some((suffix) => entry.endsWith(suffix)))
.map((entry) => `src/${entry.split("\\").join("/")}`);
const read = (path: string) => readFileSync(new URL(path, projectRoot), "utf8");
const styleSheets = walk(sourceRoot, [".css"]).map(read);
const componentFiles = walk(sourceRoot, [".tsx", ".ts"]);
// Boundary pages under the root layout carry their rules in an inline <style>.
const inlineStyles = componentFiles.flatMap((path) =>
[...read(path).matchAll(/<style>\{`([\s\S]*?)`\}<\/style>/g)].map((hit) => hit[1]));
// Comments are stripped first: a class name that only survives in a `/* ... */`
// note is not a definition, and counting it would hide exactly the bug we want.
const styleSource = [...styleSheets, ...inlineStyles].join("\n").replace(/\/\*[\s\S]*?\*\//g, " ");
const definedClasses = new Set(
[...styleSource.matchAll(/\.(-?[A-Za-z_][A-Za-z0-9_-]*)/g)].map((hit) => hit[1]),
);
const projectFamilies = new Set([...definedClasses].map((name) => name.split("-")[0]));
// Tailwind utilities whose first segment collides with one of our families.
const tailwindCollisions = new Set([
"inline-flex", "inline-block", "inline-grid", "inline-table",
"select-none", "select-all", "select-text", "select-auto",
]);
// Classes that carry no styling on purpose: dead modifiers on already-styled
// elements, or hooks handed to a component that styles itself. Each one is a
// no-op today; the point of listing them is that a NEW undefined class fails.
//
// The `is-*` entries are state hooks whose visual is carried by a sibling
// element instead — the thinking step's marker icon, the board minute's
// "当前时间" / "已更新" note. `is-changed` left this list in BUG-622 once
// `.personal-report-chart-cell.is-changed` got a fill/stroke rule.
// `sidebar-header` and `chart-nav` land on shadcn components that style
// themselves. The rest are modifiers sitting beside an already-styled class
// on the same element.
const knownUnstyled = new Set([
"birth-time-evidence-receipt",
"is-done",
"is-live",
"is-working",
"onboarding-step-card",
"personal-report-additional-charts",
"rectification-terminal-actions",
"rectification-terminal-note",
"sidebar-header",
]);
/** Static class tokens; `${...}` holes split the string and their ragged edges are dropped. */
function staticClassTokens(source: string): string[] {
const segments = [...source.matchAll(/className\s*=\s*"([^"]*)"/g)].map((hit) => hit[1]);
for (const opener of source.matchAll(/className\s*=\s*\{/g)) {
let depth = 1;
let cursor = opener.index + opener[0].length;
const start = cursor;
while (cursor < source.length && depth > 0) {
if (source[cursor] === "{") depth += 1;
else if (source[cursor] === "}") depth -= 1;
cursor += 1;
}
const expression = source.slice(start, cursor - 1);
for (const literal of expression.matchAll(/`([^`]*)`|"([^"]*)"|'([^']*)'/g)) {
segments.push(literal[1] ?? literal[2] ?? literal[3] ?? "");
}
}
return segments.flatMap((segment) => (
` ${segment} `.split(/\$\{[^}]*\}/).flatMap((piece) => {
const tokens = piece.split(/\s+/).filter(Boolean);
if (!/^\s/.test(piece)) tokens.shift();
if (!/\s$/.test(piece)) tokens.pop();
return tokens;
})
));
}
const ownershipSuspects = new Map<string, Set<string>>();
for (const path of componentFiles) {
for (const token of staticClassTokens(read(path))) {
if (definedClasses.has(token)) continue;
if (tailwindCollisions.has(token)) continue;
if (!token.includes("-") || /[:[\]()/@!$.{}]/.test(token)) continue;
if (!projectFamilies.has(token.split("-")[0])) continue;
const seen = ownershipSuspects.get(token) ?? new Set<string>();
seen.add(path);
ownershipSuspects.set(token, seen);
}
}
test("every project class a component renders has a rule in our CSS", () => {
const undefinedClasses = [...ownershipSuspects.entries()]
.filter(([name]) => !knownUnstyled.has(name))
.map(([name, files]) => `${name} (${[...files].sort().join(", ")})`)
.sort();
assert.deepEqual(
undefinedClasses,
[],
"These class names style nothing. Define them in CSS, drop them, or add them "
+ "to knownUnstyled with a reason:\n " + undefinedClasses.join("\n "),
);
});
test("the knownUnstyled allowlist has no stale entries", () => {
// An entry that got a rule, or stopped being rendered, must leave the list —
// otherwise the allowlist grows into a blindfold.
const stale = [...knownUnstyled].filter((name) => (
definedClasses.has(name) || !ownershipSuspects.has(name)
));
assert.deepEqual(stale, [], `no longer undefined-and-used: ${stale.join(", ")}`);
});
test("the guard actually sees the codebase", () => {
assert.ok(componentFiles.length > 100, `only walked ${componentFiles.length} components`);
assert.ok(definedClasses.size > 500, `only parsed ${definedClasses.size} classes`);
assert.ok(projectFamilies.has("paywall") && projectFamilies.has("message"));
});