Improve CSS usage dynamic prefix audit

This commit is contained in:
murashit 2026-06-24 15:37:18 +09:00
parent 8edb115e08
commit 1a42558d68
9 changed files with 194 additions and 20 deletions

View file

@ -3,11 +3,64 @@ import path from "node:path";
const root = process.cwd();
const stylesDir = path.join(root, "src", "styles");
if (process.argv.length > 2) {
console.error("Usage: node scripts/lint/check-css-usage.mjs");
const reportDynamicPrefixes = process.argv[2] === "--report-dynamic-prefixes";
if (process.argv.length > (reportDynamicPrefixes ? 3 : 2)) {
console.error("Usage: node scripts/lint/check-css-usage.mjs [--report-dynamic-prefixes]");
process.exit(1);
}
const dynamicCssClassPatterns = [
{
prefix: "codex-panel-diff__line--",
classes: [
"codex-panel-diff__line--added",
"codex-panel-diff__line--context",
"codex-panel-diff__line--file",
"codex-panel-diff__line--hunk",
"codex-panel-diff__line--removed",
],
},
{
prefix: "codex-panel-diff__word--",
classes: ["codex-panel-diff__word--added", "codex-panel-diff__word--removed"],
},
{
prefix: "codex-panel-threads__row--",
classes: ["codex-panel-threads__row--open", "codex-panel-threads__row--pending", "codex-panel-threads__row--running"],
},
{
prefix: "codex-panel__connection-diagnostics-row--",
classes: ["codex-panel__connection-diagnostics-row--error", "codex-panel__connection-diagnostics-row--warning"],
},
{
prefix: "codex-panel__execution--",
classes: ["codex-panel__execution--completed", "codex-panel__execution--failed", "codex-panel__execution--running"],
},
{
prefix: "codex-panel__goal--",
classes: [
"codex-panel__goal--active",
"codex-panel__goal--blocked",
"codex-panel__goal--budgetLimited",
"codex-panel__goal--complete",
"codex-panel__goal--paused",
"codex-panel__goal--usageLimited",
],
},
{
prefix: "codex-panel__limit-panel-meter--",
classes: ["codex-panel__limit-panel-meter--5", "codex-panel__limit-panel-meter--7"],
},
{
prefix: "codex-panel__limit-panel-row--",
classes: ["codex-panel__limit-panel-row--danger", "codex-panel__limit-panel-row--warn"],
},
{
prefix: "codex-panel__task-step--",
classes: ["codex-panel__task-step--completed", "codex-panel__task-step--inProgress"],
},
];
const cssFiles = await orderedCssFiles();
const sourceFiles = await filesInTree(path.join(root, "src"), new Set([".ts", ".tsx"]), {
excludedPrefixes: [path.join(root, "src", "generated")],
@ -19,16 +72,23 @@ const sourceTexts = await readTexts(sourceFiles);
const testTexts = await readTexts(testFiles);
const sourceText = sourceTexts.map((item) => item.text).join("\n");
const dynamicPrefixes = collectDynamicClassPrefixes(sourceText);
const dynamicCssClasses = dynamicCssClassMap(dynamicCssClassPatterns);
const testOnlyCandidates = [];
const candidates = [];
const dynamicPrefixMatches = new Map();
for (const [className, locations] of cssClasses) {
const sourceMatches = locationsInTexts(sourceTexts, className);
if (sourceMatches.length > 0) continue;
const matchingPrefix = dynamicPrefixes.find((prefix) => className.startsWith(prefix));
if (matchingPrefix) continue;
const dynamicClass = dynamicCssClasses.get(className);
if (dynamicClass && dynamicPrefixes.includes(dynamicClass.prefix)) {
const matches = dynamicPrefixMatches.get(dynamicClass.prefix) ?? [];
matches.push({ className, locations });
dynamicPrefixMatches.set(dynamicClass.prefix, matches);
continue;
}
const testMatches = locationsInTexts(testTexts, className);
if (testMatches.length > 0) {
@ -39,8 +99,18 @@ for (const [className, locations] of cssClasses) {
candidates.push({ className, locations });
}
if (candidates.length + testOnlyCandidates.length > 0) {
printCandidates({ testOnlyCandidates, candidates });
const dynamicConfigurationErrors = dynamicCssConfigurationErrors({
cssClasses,
dynamicCssClassPatterns,
dynamicPrefixes,
});
if (reportDynamicPrefixes) {
printDynamicPrefixReport({ dynamicPrefixes, dynamicPrefixMatches });
}
if (candidates.length + testOnlyCandidates.length + dynamicConfigurationErrors.length > 0) {
printCandidates({ testOnlyCandidates, candidates, dynamicConfigurationErrors });
process.exit(1);
}
@ -82,6 +152,33 @@ function collectDynamicClassPrefixes(text) {
return [...prefixes].sort((left, right) => left.localeCompare(right));
}
function dynamicCssClassMap(patterns) {
const result = new Map();
for (const pattern of patterns) {
for (const className of pattern.classes) {
if (!className.startsWith(pattern.prefix)) {
throw new Error(`Dynamic CSS class ${className} does not match prefix ${pattern.prefix}.`);
}
result.set(className, { prefix: pattern.prefix });
}
}
return result;
}
function dynamicCssConfigurationErrors({ cssClasses, dynamicCssClassPatterns, dynamicPrefixes }) {
const errors = [];
for (const pattern of dynamicCssClassPatterns) {
const existingClasses = pattern.classes.filter((className) => cssClasses.has(className));
if (existingClasses.length > 0 && !dynamicPrefixes.includes(pattern.prefix)) {
errors.push({
title: "Dynamic CSS class configuration references prefixes that are not present in source templates:",
details: [` ${pattern.prefix}`, ...existingClasses.map((className) => ` ${className}`)],
});
}
}
return errors;
}
async function readTexts(files) {
const result = [];
for (const file of files) {
@ -122,8 +219,40 @@ async function collectFiles(directory, extensions, excludedPrefixes, result) {
}
}
function printCandidates({ testOnlyCandidates, candidates }) {
function printDynamicPrefixReport({ dynamicPrefixes, dynamicPrefixMatches }) {
console.log("Dynamic CSS class prefix exemptions:");
if (dynamicPrefixMatches.size === 0) {
console.log(" none");
} else {
for (const [prefix, matches] of [...dynamicPrefixMatches.entries()].sort(([left], [right]) => left.localeCompare(right))) {
console.log(` ${prefix}`);
for (const match of matches) {
console.log(` ${match.className}`);
console.log(` css: ${match.locations.join(", ")}`);
}
}
}
const prefixesWithoutExemptions = dynamicPrefixes.filter((prefix) => !dynamicPrefixMatches.has(prefix));
if (prefixesWithoutExemptions.length === 0) return;
console.log("");
console.log("Detected dynamic prefixes with no CSS exemptions:");
for (const prefix of prefixesWithoutExemptions) {
console.log(` ${prefix}`);
}
}
function printCandidates({ testOnlyCandidates, candidates, dynamicConfigurationErrors }) {
console.error("CSS usage check failed.");
for (const error of dynamicConfigurationErrors) {
console.error("");
console.error(error.title);
for (const detail of error.details) {
console.error(detail);
}
}
if (testOnlyCandidates.length > 0) {
console.error("");
console.error("Test-only unused CSS class candidates:");

View file

@ -158,7 +158,7 @@ function agentDetailView(item: AgentMessageStreamItem): DetailView {
function genericToolDetailView(item: ToolCallMessageStreamItem | HookMessageStreamItem, workspaceRoot?: string | null): DetailView {
return detailViewBase(
item,
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
detailItemClassName(item.kind),
item.toolName ?? item.kind,
messageDetailKey(item.id, "details"),
[...genericToolDetails(item), ...outputSection(item.kind === "hook" ? "Hook output" : "Output", item.output)],
@ -187,7 +187,7 @@ function approvalDetailView(item: ApprovalResultMessageStreamItem): DetailView {
function genericDetailView(item: MessageStreamItem, workspaceRoot?: string | null): DetailView {
return detailViewBase(
item,
`codex-panel__detail-item codex-panel__detail-item--${item.kind}`,
detailItemClassName(item.kind),
detailLabel(item),
messageDetailKey(item.id, "details"),
genericDetailSections(item, workspaceRoot),
@ -199,6 +199,10 @@ function messageDetailKey(itemId: string, suffix: string): string {
return `${itemId}:${suffix}`;
}
function detailItemClassName(kind: MessageStreamItem["kind"]): string {
return kind === "hook" ? "codex-panel__detail-item codex-panel__detail-item--hook" : "codex-panel__detail-item";
}
function resultDetailView(
item: ApprovalResultMessageStreamItem | ReviewResultMessageStreamItem,
label: string,

View file

@ -166,13 +166,20 @@ function executionClassName(state: ExecutionState): string {
}
function textItemClass(item: MessageStreamItem): string {
const classes = ["codex-panel__message", `codex-panel__message--${item.role}`];
const classes = ["codex-panel__message", messageRoleClassName(item.role)];
if (item.kind === "approvalResult") classes.push("codex-panel__message--approval-result");
if (item.kind === "userInputResult") classes.push("codex-panel__message--user-input-result");
if (item.kind === "reviewResult") classes.push("codex-panel__message--review-result");
return classes.join(" ");
}
function messageRoleClassName(role: MessageStreamItem["role"]): string {
if (role === "assistant") return "codex-panel__message--assistant";
if (role === "system") return "codex-panel__message--system";
if (role === "tool") return "codex-panel__message--tool";
return "codex-panel__message--user";
}
function definedProp<Key extends string, Value>(key: Key, value: Value | undefined): Partial<Record<Key, Value>> {
return value === undefined ? {} : ({ [key]: value } as Partial<Record<Key, Value>>);
}

View file

@ -417,7 +417,7 @@ function ComposerMetaChoicePopover({
"--codex-panel-composer-meta-popover-left": `${String(Math.round(left))}px`,
};
return (
<div className={`codex-panel__composer-meta-popover codex-panel__composer-meta-popover--${kind}`} style={style}>
<div className="codex-panel__composer-meta-popover" data-codex-panel-composer-meta-kind={kind} style={style}>
{choices.map((choice) => (
<ComposerMetaChoice key={choice.label} choice={choice} onClose={onClose} />
))}

View file

@ -20,7 +20,7 @@ export function createStatusMessageClassName(className: string, tone?: "warning"
"codex-panel__message--tool",
"codex-panel__status-message",
className,
tone ? `codex-panel__status-message--${tone}` : "",
tone === "warning" ? "codex-panel__status-message--warning" : "",
]
.filter(Boolean)
.join(" ");

View file

@ -128,7 +128,12 @@ function StatusButton({ model, actions }: { model: ToolbarViewModel; actions: To
function ToolbarPanel({ model, actions }: { model: ToolbarViewModel; actions: ToolbarActions }): UiNode {
if (!model.openPanel) return null;
return (
<div className={`codex-panel__toolbar-panel codex-panel__toolbar-panel--${model.openPanel}`}>
<div
className={["codex-panel__toolbar-panel", model.openPanel === "status" ? "codex-panel__toolbar-panel--status" : ""]
.filter(Boolean)
.join(" ")}
data-codex-panel-toolbar-panel={model.openPanel}
>
{model.openPanel === "history" ? <ThreadList threads={model.threads} actions={actions} /> : null}
{model.openPanel === "chat-actions" ? <ChatActionsPanel model={model} actions={actions} /> : null}
{model.openPanel === "status" ? <StatusPanel model={model} actions={actions} /> : null}
@ -182,7 +187,7 @@ function StatusPanel({ model, actions }: { model: ToolbarViewModel; actions: Too
function RateLimitPanel({ rateLimit }: { rateLimit: RateLimitSummary | null }): UiNode {
if (!rateLimit) return null;
return (
<div className={`codex-panel__limit-panel codex-panel__limit-panel--${rateLimit.level}`}>
<div className="codex-panel__limit-panel">
<div className="codex-panel__limit-panel-title">Usage limit</div>
<div className="codex-panel__limit-panel-list">
{rateLimit.rows.map((row) => (

View file

@ -46,7 +46,7 @@ describe("chat panel surface projections", () => {
h(ChatPanelToolbar, { surface: toolbarSurfaceFixture({ archiveExportEnabled: true }), actions: toolbarActionsFixture() }),
);
expect(parent.querySelector(".codex-panel__toolbar-panel--history")).not.toBeNull();
expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull();
expect(parent.querySelector<HTMLButtonElement>(".codex-panel__new-chat")?.disabled).toBe(true);
expect(parent.querySelector<HTMLInputElement>(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe(
"Active",

View file

@ -215,7 +215,7 @@ describe("ComposerShell decisions", () => {
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
await waitForAsyncWork(() => {
expect(parent.querySelector(".codex-panel__composer-meta-popover--model")?.textContent).toBe("gpt-5.5gpt-5.4");
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')?.textContent).toBe("gpt-5.5gpt-5.4");
});
const metaOptions = Array.from(parent.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-option"));
expect(metaOptions.map((option) => option.getAttribute("role"))).toEqual([null, null]);
@ -227,8 +227,8 @@ describe("ComposerShell decisions", () => {
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-effort")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
await waitForAsyncWork(() => {
expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).toBeNull();
expect(parent.querySelector(".codex-panel__composer-meta-popover--effort")?.textContent).toBe("mediumhigh");
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).toBeNull();
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="effort"]')?.textContent).toBe("mediumhigh");
});
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-option")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
@ -239,7 +239,7 @@ describe("ComposerShell decisions", () => {
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
await waitForAsyncWork(() => {
expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).not.toBeNull();
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).not.toBeNull();
});
parent
.querySelectorAll<HTMLElement>(".codex-panel__composer-meta-option")[1]
@ -251,7 +251,7 @@ describe("ComposerShell decisions", () => {
parent.querySelector<HTMLElement>(".codex-panel__composer-meta-model")?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }));
await waitForAsyncWork(() => {
expect(parent.querySelector(".codex-panel__composer-meta-popover--model")).not.toBeNull();
expect(parent.querySelector('[data-codex-panel-composer-meta-kind="model"]')).not.toBeNull();
});
document.dispatchEvent(new Event("mousedown", { bubbles: true }));
await waitForAsyncWork(() => {

View file

@ -149,6 +149,35 @@ describe("development scripts", () => {
expect(result.stderr).toContain("codex-panel__unused");
});
it("reports dynamic CSS prefix exemptions on demand", async () => {
const cwd = await cssUsageFixture({
"src/styles/10-component.css": ".codex-panel__task-step--completed { display: block; }\n",
"src/component.ts": "export const className = `codex-panel__task-step--${status}`;\n",
});
const result = runNodeScript("scripts/lint/check-css-usage.mjs", ["--report-dynamic-prefixes"], cwd);
expect(result.status).toBe(0);
expect(result.stderr).toBe("");
expect(result.stdout).toContain("Dynamic CSS class prefix exemptions:");
expect(result.stdout).toContain("codex-panel__task-step--");
expect(result.stdout).toContain("codex-panel__task-step--completed");
});
it("does not let dynamic CSS prefixes hide unconfigured class candidates", async () => {
const cwd = await cssUsageFixture({
"src/styles/10-component.css": ".codex-panel__task-step--stale { display: block; }\n",
"src/component.ts": "export const className = `codex-panel__task-step--${status}`;\n",
});
const result = runNodeScript("scripts/lint/check-css-usage.mjs", [], cwd);
expect(result.status).toBe(1);
expect(result.stdout).toBe("");
expect(result.stderr).toContain("CSS usage check failed.");
expect(result.stderr).toContain("codex-panel__task-step--stale");
});
it("fails release prepare before changing version files when release notes already exist", async () => {
const cwd = await tempWorkspace();
await mkdir(path.join(cwd, ".github", "release-notes"), { recursive: true });