Skip to content

Commit 200f68d

Browse files
authored
Merge pull request #171 from bookedsolidtech/feat/validate-css-file
feat: add validate_css_file multi-component CSS validator
2 parents 4c100c0 + d09b8d2 commit 200f68d

File tree

7 files changed

+480
-1
lines changed

7 files changed

+480
-1
lines changed

.changeset/validate-css-file.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'helixir': minor
3+
'@helixir/core': minor
4+
---
5+
6+
Add validate_css_file MCP tool — validates entire CSS files targeting multiple web components in one call with auto-detection

custom-elements.json

Lines changed: 48 additions & 1 deletion
Large diffs are not rendered by default.
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/**
2+
* CSS File Validator — validates an entire CSS file containing styles for
3+
* multiple web components. Auto-detects which components are referenced,
4+
* runs per-component validation (shadow DOM, API resolution), and runs
5+
* global validators (specificity, color contrast, shorthand, theme).
6+
*
7+
* This is the "just validate my whole file" tool — agents don't need to
8+
* know which components are used or call validators individually.
9+
*/
10+
11+
import type { Cem } from './cem.js';
12+
import { parseCem } from './cem.js';
13+
import { checkShadowDomUsage } from './shadow-dom-checker.js';
14+
import { resolveCssApi } from './css-api-resolver.js';
15+
import { checkThemeCompatibility } from './theme-checker.js';
16+
import { checkCssSpecificity } from './specificity-checker.js';
17+
import { checkColorContrast } from './color-contrast-checker.js';
18+
import { checkCssShorthand } from './shorthand-checker.js';
19+
import { checkCssScopeFromMeta } from './scope-checker.js';
20+
import { checkTokenFallbacksFromMeta } from './token-fallback-checker.js';
21+
22+
// ─── Types ───────────────────────────────────────────────────────────────────
23+
24+
export interface CssFileIssue {
25+
severity: 'error' | 'warning' | 'info';
26+
category: string;
27+
message: string;
28+
line?: number;
29+
suggestion?: string;
30+
component?: string;
31+
}
32+
33+
export interface ComponentValidation {
34+
tagName: string;
35+
issues: CssFileIssue[];
36+
invalidParts: string[];
37+
invalidTokens: string[];
38+
}
39+
40+
export interface CssFileValidationResult {
41+
clean: boolean;
42+
totalIssues: number;
43+
componentsFound: string[];
44+
components: Record<string, ComponentValidation>;
45+
globalIssues: CssFileIssue[];
46+
verdict: string;
47+
}
48+
49+
// ─── Component Detection ────────────────────────────────────────────────────
50+
51+
/**
52+
* Extracts web component tag names from CSS selectors.
53+
* Matches: tag-name {, tag-name::part(x) {, tag-name.class {, tag-name[attr] {
54+
*/
55+
function detectComponentsInCss(css: string, cem: Cem): string[] {
56+
// Build a set of known tag names from the CEM
57+
const knownTags = new Set<string>();
58+
for (const mod of cem.modules) {
59+
for (const decl of mod.declarations ?? []) {
60+
if ('tagName' in decl && decl.tagName) {
61+
knownTags.add(decl.tagName);
62+
}
63+
}
64+
}
65+
66+
// Find all custom element tags referenced in selectors
67+
// Custom elements must contain a hyphen
68+
const tagPattern = /(?:^|[},;\s])([a-z][a-z0-9]*-[a-z0-9-]*)(?=[.:{\s,[[])/gm;
69+
const found = new Set<string>();
70+
let match: RegExpExecArray | null;
71+
72+
while ((match = tagPattern.exec(css)) !== null) {
73+
const tag = (match[1] ?? '').trim();
74+
if (knownTags.has(tag)) {
75+
found.add(tag);
76+
}
77+
}
78+
79+
return [...found].sort();
80+
}
81+
82+
// ─── Main Entry Point ───────────────────────────────────────────────────────
83+
84+
export function validateCssFile(css: string, cem: Cem): CssFileValidationResult {
85+
const componentsFound = detectComponentsInCss(css, cem);
86+
const components: Record<string, ComponentValidation> = {};
87+
const globalIssues: CssFileIssue[] = [];
88+
89+
// Per-component validation
90+
for (const tagName of componentsFound) {
91+
const issues: CssFileIssue[] = [];
92+
const invalidParts: string[] = [];
93+
const invalidTokens: string[] = [];
94+
95+
try {
96+
const meta = parseCem(tagName, cem);
97+
98+
// Shadow DOM anti-patterns
99+
safeRun(() => {
100+
const shadowResult = checkShadowDomUsage(css, tagName, meta);
101+
for (const issue of shadowResult.issues) {
102+
issues.push({
103+
severity: issue.severity === 'error' ? 'error' : 'warning',
104+
category: 'shadowDom',
105+
message: issue.message,
106+
line: issue.line,
107+
suggestion: issue.suggestion,
108+
component: tagName,
109+
});
110+
}
111+
});
112+
113+
// CSS API resolution (parts, tokens, slots)
114+
safeRun(() => {
115+
const resolution = resolveCssApi(css, meta);
116+
for (const part of resolution.parts.resolved) {
117+
if (!part.valid) {
118+
invalidParts.push(part.name);
119+
issues.push({
120+
severity: 'error',
121+
category: 'invalidPart',
122+
message: `::part(${part.name}) does not exist on <${tagName}>. ${part.suggestion ? `Did you mean: ${part.suggestion}` : `Valid parts: ${meta.cssParts.map((p) => p.name).join(', ') || 'none'}`}`,
123+
component: tagName,
124+
});
125+
}
126+
}
127+
for (const token of resolution.tokens.resolved) {
128+
if (!token.valid) {
129+
invalidTokens.push(token.name);
130+
issues.push({
131+
severity: 'error',
132+
category: 'invalidToken',
133+
message: `CSS custom property "${token.name}" does not exist on <${tagName}>. ${token.suggestion ? `Did you mean: ${token.suggestion}` : ''}`,
134+
component: tagName,
135+
});
136+
}
137+
}
138+
});
139+
140+
// Token fallbacks
141+
safeRun(() => {
142+
const knownTokens = new Set(meta.cssProperties.map((p) => p.name));
143+
const fallbackResult = checkTokenFallbacksFromMeta(css, knownTokens);
144+
for (const issue of fallbackResult.issues) {
145+
issues.push({
146+
severity: 'warning',
147+
category: 'tokenFallbacks',
148+
message: issue.message,
149+
line: issue.line,
150+
component: tagName,
151+
});
152+
}
153+
});
154+
155+
// Scope validation
156+
safeRun(() => {
157+
const scopeResult = checkCssScopeFromMeta(css, tagName, meta.cssProperties);
158+
for (const issue of scopeResult.issues) {
159+
issues.push({
160+
severity: 'warning',
161+
category: 'scope',
162+
message: issue.message,
163+
line: issue.line,
164+
component: tagName,
165+
});
166+
}
167+
});
168+
} catch {
169+
// Tag not in CEM — skip per-component validation
170+
}
171+
172+
components[tagName] = { tagName, issues, invalidParts, invalidTokens };
173+
}
174+
175+
// Global CSS validation (not component-specific)
176+
safeRun(() => {
177+
const themeResult = checkThemeCompatibility(css);
178+
for (const issue of themeResult.issues) {
179+
globalIssues.push({
180+
severity: 'warning',
181+
category: 'themeCompat',
182+
message: issue.message,
183+
line: issue.line,
184+
});
185+
}
186+
});
187+
188+
safeRun(() => {
189+
const specResult = checkCssSpecificity(css);
190+
for (const issue of specResult.issues) {
191+
globalIssues.push({
192+
severity: 'info',
193+
category: 'specificity',
194+
message: issue.message,
195+
line: issue.line,
196+
});
197+
}
198+
});
199+
200+
safeRun(() => {
201+
const contrastResult = checkColorContrast(css);
202+
for (const issue of contrastResult.issues) {
203+
globalIssues.push({
204+
severity: 'warning',
205+
category: 'colorContrast',
206+
message: issue.message,
207+
line: issue.line,
208+
});
209+
}
210+
});
211+
212+
safeRun(() => {
213+
const shorthandResult = checkCssShorthand(css);
214+
for (const issue of shorthandResult.issues) {
215+
globalIssues.push({
216+
severity: 'warning',
217+
category: 'shorthand',
218+
message: issue.message,
219+
line: issue.line,
220+
suggestion: issue.suggestion,
221+
});
222+
}
223+
});
224+
225+
// Aggregate
226+
const allIssues = [...globalIssues, ...Object.values(components).flatMap((c) => c.issues)];
227+
const totalIssues = allIssues.length;
228+
const clean = totalIssues === 0;
229+
const verdict = buildVerdict(componentsFound, components, globalIssues, totalIssues);
230+
231+
return {
232+
clean,
233+
totalIssues,
234+
componentsFound,
235+
components,
236+
globalIssues,
237+
verdict,
238+
};
239+
}
240+
241+
// ─── Helpers ────────────────────────────────────────────────────────────────
242+
243+
function safeRun(fn: () => void): void {
244+
try {
245+
fn();
246+
} catch {
247+
// Individual checker failed — skip
248+
}
249+
}
250+
251+
function buildVerdict(
252+
componentsFound: string[],
253+
components: Record<string, ComponentValidation>,
254+
globalIssues: CssFileIssue[],
255+
totalIssues: number,
256+
): string {
257+
if (totalIssues === 0) {
258+
return componentsFound.length > 0
259+
? `Clean — ${componentsFound.length} component${componentsFound.length > 1 ? 's' : ''} validated, no issues.`
260+
: 'Clean — no web components detected in CSS.';
261+
}
262+
263+
const errors = [
264+
...globalIssues.filter((i) => i.severity === 'error'),
265+
...Object.values(components).flatMap((c) => c.issues.filter((i) => i.severity === 'error')),
266+
].length;
267+
268+
const warnings = [
269+
...globalIssues.filter((i) => i.severity === 'warning'),
270+
...Object.values(components).flatMap((c) => c.issues.filter((i) => i.severity === 'warning')),
271+
].length;
272+
273+
const parts: string[] = [];
274+
if (errors > 0) parts.push(`${errors} error${errors > 1 ? 's' : ''}`);
275+
if (warnings > 0) parts.push(`${warnings} warning${warnings > 1 ? 's' : ''}`);
276+
277+
const componentSummary = Object.entries(components)
278+
.filter(([, v]) => v.issues.length > 0)
279+
.map(([tag, v]) => `<${tag}>: ${v.issues.length}`)
280+
.join(', ');
281+
282+
return `${parts.join(', ')} across ${componentsFound.length} component${componentsFound.length > 1 ? 's' : ''}${componentSummary ? ` (${componentSummary})` : ''}.`;
283+
}

packages/core/src/handlers/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from './bundle.js';
88
export * from './cdn.js';
99
export * from './cem.js';
1010
export * from './code-validator.js';
11+
export * from './css-file-validator.js';
1112
export * from './color-contrast-checker.js';
1213
export * from './compare.js';
1314
export * from './transition-checker.js';

packages/core/src/tools/styling.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { checkTransitionAnimation } from '../handlers/transition-checker.js';
2929
import { checkShadowDomJs } from '../handlers/shadow-dom-js-checker.js';
3030
import { resolveCssApi } from '../handlers/css-api-resolver.js';
3131
import { runStylingPreflight } from '../handlers/styling-preflight.js';
32+
import { validateCssFile } from '../handlers/css-file-validator.js';
3233
import { createErrorResponse, createSuccessResponse } from '../shared/mcp-helpers.js';
3334
import type { MCPToolResult } from '../shared/mcp-helpers.js';
3435
import { handleToolError } from '../shared/error-handling.js';
@@ -173,6 +174,10 @@ const StylingPreflightArgsSchema = z.object({
173174
htmlText: z.string().optional(),
174175
});
175176

177+
const ValidateCssFileArgsSchema = z.object({
178+
cssText: z.string(),
179+
});
180+
176181
export const STYLING_TOOL_DEFINITIONS = [
177182
{
178183
name: 'diagnose_styling',
@@ -824,6 +829,26 @@ export const STYLING_TOOL_DEFINITIONS = [
824829
required: ['cssText', 'tagName'],
825830
},
826831
},
832+
{
833+
name: 'validate_css_file',
834+
description:
835+
'Validates an entire CSS file targeting multiple web components in one call. Auto-detects all web component tag names in selectors, runs per-component validation (Shadow DOM, ::part() resolution, token validation, scope checks), and global validation (theme compatibility, color contrast, specificity, shorthand). Returns issues grouped by component plus global issues. Use this when reviewing a CSS file that styles multiple components — no need to know which components are used.',
836+
inputSchema: {
837+
type: 'object' as const,
838+
properties: {
839+
libraryId: {
840+
type: 'string',
841+
description:
842+
'Optional library ID to target a specific loaded library instead of the default.',
843+
},
844+
cssText: {
845+
type: 'string',
846+
description: 'The full CSS file content to validate.',
847+
},
848+
},
849+
required: ['cssText'],
850+
},
851+
},
827852
];
828853

829854
/**
@@ -1017,6 +1042,12 @@ export function handleStylingCall(
10171042
return createSuccessResponse(JSON.stringify(result, null, 2));
10181043
}
10191044

1045+
if (name === 'validate_css_file') {
1046+
const { cssText } = ValidateCssFileArgsSchema.parse(args);
1047+
const result = validateCssFile(cssText, cem);
1048+
return createSuccessResponse(JSON.stringify(result, null, 2));
1049+
}
1050+
10201051
return createErrorResponse(`Unknown styling tool: ${name}`);
10211052
} catch (err) {
10221053
const mcpErr = handleToolError(err);

0 commit comments

Comments
 (0)