-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add styling_preflight single-call validation tool #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| '@anthropic/helixir-core': minor | ||
| --- | ||
|
|
||
| Add `styling_preflight` MCP tool — single-call styling validation that combines component API discovery, CSS reference resolution, Shadow DOM anti-pattern detection, theme compatibility checking, and a pass/fail verdict. Eliminates the "forgot to check the API first" failure mode. |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,146 @@ | ||||||||||||||
| /** | ||||||||||||||
| * Styling Preflight — single-call tool that combines component API discovery, | ||||||||||||||
| * CSS reference resolution, and validation into one response. | ||||||||||||||
| * | ||||||||||||||
| * Agents call this ONCE with their CSS (and optional HTML) to get: | ||||||||||||||
| * 1. The component's full style API surface (parts, tokens, slots) | ||||||||||||||
| * 2. Resolution of every ::part() and token reference (valid vs hallucinated) | ||||||||||||||
| * 3. Validation issues (shadow DOM, theme, specificity) | ||||||||||||||
| * 4. A correct CSS snippet to use as reference | ||||||||||||||
| * 5. A pass/fail verdict | ||||||||||||||
| * | ||||||||||||||
| * This eliminates the "forgot to check the API first" failure mode. | ||||||||||||||
| */ | ||||||||||||||
|
|
||||||||||||||
| import type { ComponentMetadata } from './cem.js'; | ||||||||||||||
| import { resolveCssApi, type CssApiResolution } from './css-api-resolver.js'; | ||||||||||||||
| import { checkShadowDomUsage } from './shadow-dom-checker.js'; | ||||||||||||||
| import { checkThemeCompatibility } from './theme-checker.js'; | ||||||||||||||
| import { buildCssSnippet } from './styling-diagnostics.js'; | ||||||||||||||
|
|
||||||||||||||
| // ─── Types ─────────────────────────────────────────────────────────────────── | ||||||||||||||
|
|
||||||||||||||
| export interface PreflightInput { | ||||||||||||||
| css: string; | ||||||||||||||
| html?: string; | ||||||||||||||
| meta: ComponentMetadata; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export interface PreflightIssue { | ||||||||||||||
| severity: 'error' | 'warning' | 'info'; | ||||||||||||||
| category: string; | ||||||||||||||
| message: string; | ||||||||||||||
| line?: number; | ||||||||||||||
| suggestion?: string; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export interface PreflightComponentApi { | ||||||||||||||
| tagName: string; | ||||||||||||||
| description: string; | ||||||||||||||
| parts: string[]; | ||||||||||||||
| tokens: string[]; | ||||||||||||||
| slots: string[]; | ||||||||||||||
| hasStyleApi: boolean; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| export interface PreflightResult { | ||||||||||||||
| componentApi: PreflightComponentApi; | ||||||||||||||
| resolution: CssApiResolution; | ||||||||||||||
| issues: PreflightIssue[]; | ||||||||||||||
| correctSnippet: string; | ||||||||||||||
| verdict: string; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // ─── Main Entry Point ─────────────────────────────────────────────────────── | ||||||||||||||
|
|
||||||||||||||
| export function runStylingPreflight(input: PreflightInput): PreflightResult { | ||||||||||||||
| const { css, html, meta } = input; | ||||||||||||||
| const issues: PreflightIssue[] = []; | ||||||||||||||
|
|
||||||||||||||
| // 1. Resolve CSS references against CEM | ||||||||||||||
| const resolution = resolveCssApi(css, meta, html); | ||||||||||||||
|
|
||||||||||||||
| // 2. Run shadow DOM validation (if CSS is non-empty) | ||||||||||||||
| if (css.trim()) { | ||||||||||||||
| try { | ||||||||||||||
| const shadowResult = checkShadowDomUsage(css, meta.tagName, meta); | ||||||||||||||
| for (const issue of shadowResult.issues) { | ||||||||||||||
| issues.push({ | ||||||||||||||
| severity: issue.severity === 'error' ? 'error' : 'warning', | ||||||||||||||
| category: 'shadowDom', | ||||||||||||||
| message: issue.message, | ||||||||||||||
| line: issue.line, | ||||||||||||||
| suggestion: issue.suggestion, | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
| } catch { | ||||||||||||||
| // Shadow DOM check failed — skip | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // 3. Run theme compatibility check | ||||||||||||||
| try { | ||||||||||||||
| const themeResult = checkThemeCompatibility(css); | ||||||||||||||
| for (const issue of themeResult.issues) { | ||||||||||||||
| issues.push({ | ||||||||||||||
| severity: 'warning', | ||||||||||||||
| category: 'themeCompat', | ||||||||||||||
| message: issue.message, | ||||||||||||||
| line: issue.line, | ||||||||||||||
| }); | ||||||||||||||
| } | ||||||||||||||
| } catch { | ||||||||||||||
| // Theme check failed — skip | ||||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // 4. Build the component API summary | ||||||||||||||
| const componentApi: PreflightComponentApi = { | ||||||||||||||
| tagName: meta.tagName, | ||||||||||||||
| description: meta.description, | ||||||||||||||
| parts: meta.cssParts.map((p) => p.name), | ||||||||||||||
| tokens: meta.cssProperties.map((p) => p.name), | ||||||||||||||
| slots: meta.slots.map((s) => (s.name === '' ? '(default)' : s.name)), | ||||||||||||||
| hasStyleApi: meta.cssParts.length > 0 || meta.cssProperties.length > 0 || meta.slots.length > 0, | ||||||||||||||
| }; | ||||||||||||||
|
|
||||||||||||||
| // 5. Generate correct CSS snippet | ||||||||||||||
| const correctSnippet = buildCssSnippet(meta); | ||||||||||||||
|
|
||||||||||||||
| // 6. Build verdict | ||||||||||||||
| const verdict = buildVerdict(resolution, issues); | ||||||||||||||
|
|
||||||||||||||
| return { | ||||||||||||||
| componentApi, | ||||||||||||||
| resolution, | ||||||||||||||
| issues, | ||||||||||||||
| correctSnippet, | ||||||||||||||
| verdict, | ||||||||||||||
| }; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| // ─── Verdict Builder ──────────────────────────────────────────────────────── | ||||||||||||||
|
|
||||||||||||||
| function buildVerdict(resolution: CssApiResolution, issues: PreflightIssue[]): string { | ||||||||||||||
| const errors = issues.filter((i) => i.severity === 'error').length; | ||||||||||||||
| const warnings = issues.filter((i) => i.severity === 'warning').length; | ||||||||||||||
| const invalidRefs = resolution.invalidCount; | ||||||||||||||
|
|
||||||||||||||
| if (errors === 0 && warnings === 0 && invalidRefs === 0) { | ||||||||||||||
| return 'pass — CSS references are valid and no anti-patterns detected.'; | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| const parts: string[] = []; | ||||||||||||||
| if (invalidRefs > 0) { | ||||||||||||||
| parts.push( | ||||||||||||||
| `${invalidRefs} invalid CSS reference${invalidRefs > 1 ? 's' : ''} (hallucinated part/token/slot names)`, | ||||||||||||||
| ); | ||||||||||||||
| } | ||||||||||||||
| if (errors > 0) { | ||||||||||||||
| parts.push(`${errors} error${errors > 1 ? 's' : ''} (will break at runtime)`); | ||||||||||||||
| } | ||||||||||||||
| if (warnings > 0) { | ||||||||||||||
| parts.push(`${warnings} warning${warnings > 1 ? 's' : ''} (theme/dark mode risk)`); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+141
to
+143
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The verdict text is mislabeling non-theme warnings.
📝 Proposed fix if (warnings > 0) {
- parts.push(`${warnings} warning${warnings > 1 ? 's' : ''} (theme/dark mode risk)`);
+ parts.push(`${warnings} warning${warnings > 1 ? 's' : ''}`);
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
|
|
||||||||||||||
| return 'fail — ' + parts.join(', ') + '.'; | ||||||||||||||
| } | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,7 @@ import { checkColorContrast } from '../handlers/color-contrast-checker.js'; | |
| import { checkTransitionAnimation } from '../handlers/transition-checker.js'; | ||
| import { checkShadowDomJs } from '../handlers/shadow-dom-js-checker.js'; | ||
| import { resolveCssApi } from '../handlers/css-api-resolver.js'; | ||
| import { runStylingPreflight } from '../handlers/styling-preflight.js'; | ||
| import { createErrorResponse, createSuccessResponse } from '../shared/mcp-helpers.js'; | ||
| import type { MCPToolResult } from '../shared/mcp-helpers.js'; | ||
| import { handleToolError } from '../shared/error-handling.js'; | ||
|
|
@@ -166,6 +167,12 @@ const ResolveCssApiArgsSchema = z.object({ | |
| htmlText: z.string().optional(), | ||
| }); | ||
|
|
||
| const StylingPreflightArgsSchema = z.object({ | ||
| cssText: z.string(), | ||
| tagName: z.string(), | ||
| htmlText: z.string().optional(), | ||
| }); | ||
|
|
||
| export const STYLING_TOOL_DEFINITIONS = [ | ||
| { | ||
| name: 'diagnose_styling', | ||
|
|
@@ -788,6 +795,35 @@ export const STYLING_TOOL_DEFINITIONS = [ | |
| required: ['cssText', 'tagName'], | ||
| }, | ||
| }, | ||
| { | ||
| name: 'styling_preflight', | ||
| description: | ||
| "Single-call styling validation that combines component API discovery, CSS reference resolution, and anti-pattern detection. Returns: the component's full style API surface (parts, tokens, slots), valid/invalid status for every ::part() and token reference, Shadow DOM and theme validation issues, a correct CSS snippet, and a pass/fail verdict. Call this ONCE before finalizing any component CSS to prevent hallucinated part names, invalid tokens, and Shadow DOM mistakes.", | ||
| inputSchema: { | ||
| type: 'object' as const, | ||
| properties: { | ||
| libraryId: { | ||
| type: 'string', | ||
| description: | ||
| 'Optional library ID to target a specific loaded library instead of the default.', | ||
| }, | ||
| cssText: { | ||
| type: 'string', | ||
| description: 'The CSS code to validate against the component API.', | ||
| }, | ||
| tagName: { | ||
| type: 'string', | ||
| description: 'The custom element tag name (e.g. "hx-button").', | ||
| }, | ||
| htmlText: { | ||
| type: 'string', | ||
| description: | ||
| 'Optional HTML code to validate slot attribute references against the component API.', | ||
| }, | ||
| }, | ||
| required: ['cssText', 'tagName'], | ||
| }, | ||
| }, | ||
| ]; | ||
|
|
||
| /** | ||
|
|
@@ -974,6 +1010,13 @@ export function handleStylingCall( | |
| return createSuccessResponse(JSON.stringify(result, null, 2)); | ||
| } | ||
|
|
||
| if (name === 'styling_preflight') { | ||
| const { cssText, tagName, htmlText } = StylingPreflightArgsSchema.parse(args); | ||
| const meta = parseCem(tagName, cem); | ||
| const result = runStylingPreflight({ css: cssText, html: htmlText, meta }); | ||
| return createSuccessResponse(JSON.stringify(result, null, 2)); | ||
| } | ||
|
Comment on lines
+1013
to
+1018
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial Add one integration test through The new suite only calls 🤖 Prompt for AI Agents |
||
|
|
||
| return createErrorResponse(`Unknown styling tool: ${name}`); | ||
| } catch (err) { | ||
| const mcpErr = handleToolError(err); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do not return a clean preflight when a sub-check crashes.
Both
catchblocks suppress validator failures and continue. If either checker throws on an unexpected input shape, this function can still returnpasseven though the analysis was incomplete.📝 Committable suggestion
🤖 Prompt for AI Agents