+
+
# HELiXiR
**Give AI agents full situational awareness of any web component library.**
@@ -13,6 +15,9 @@ Stop AI hallucinations. Ground every component suggestion in your actual Custom
[](https://nodejs.org)
[](https://github.com/bookedsolidtech/helixir/actions/workflows/build.yml)
[](https://github.com/bookedsolidtech/helixir/actions/workflows/test.yml)
+[](https://modelcontextprotocol.io)
+[](https://www.typescriptlang.org)
+[](https://www.npmjs.com/package/helixir)
[Quick Start](#quick-start) · [Why HELiXiR](#why-helixir) · [Tools Reference](#tools-reference) · [Configuration](#configuration) · [AI Tool Configs](#ai-tool-configs)
@@ -23,7 +28,7 @@ Stop AI hallucinations. Ground every component suggestion in your actual Custom
## Why HELiXiR
- **No more hallucinations** — AI reads your real component API from the Custom Elements Manifest, not from training data. Every attribute, event, slot, and CSS part is sourced directly from your library.
-- **30+ MCP tools out of the box** — Component discovery, health scoring, design token lookup, TypeScript diagnostics, breaking-change detection, and Storybook story generation — all callable by any MCP-compatible AI agent.
+- **87+ MCP tools out of the box** — Component discovery, health scoring, design token lookup, TypeScript diagnostics, breaking-change detection, Storybook story generation, Shadow DOM styling validators, theme scaffolding, and scaffold/extend tools — all callable by any MCP-compatible AI agent.
- **Works with any web component framework** — Shoelace, Lit, Stencil, FAST, Spectrum, Vaadin, and any library that produces a `custom-elements.json` CEM file.
- **Any AI editor, zero lock-in** — Claude Code, Claude Desktop, Cursor, VS Code (Cline/Continue), Zed — one config, any tool.
@@ -281,14 +286,15 @@ All tools are exposed over the [Model Context Protocol](https://modelcontextprot
### Health
-| Tool | Description | Required Args |
-| ----------------------- | ----------------------------------------------------------------------------------- | ---------------------- |
-| `score_component` | Latest health score for a component: grade (A–F), dimension scores, and issues | `tagName` |
-| `score_all_components` | Health scores for every component in the library | — |
-| `get_health_trend` | Health trend for a component over the last N days with trend direction | `tagName` |
-| `get_health_diff` | Before/after health comparison between current branch and a base branch | `tagName` |
-| `get_health_summary` | Aggregate health stats for all components: average score, grade distribution | — |
-| `analyze_accessibility` | Accessibility profile: ARIA roles, keyboard events, focus management, label support | `tagName` _(optional)_ |
+| Tool | Description | Required Args |
+| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- |
+| `score_component` | Latest health score for a component: grade (A–F), dimension scores, and issues | `tagName` |
+| `score_all_components` | Health scores for every component in the library | — |
+| `get_health_trend` | Health trend for a component over the last N days with trend direction | `tagName` |
+| `get_health_diff` | Before/after health comparison between current branch and a base branch | `tagName` |
+| `get_health_summary` | Aggregate health stats for all components: average score, grade distribution | — |
+| `analyze_accessibility` | Accessibility profile: ARIA roles, keyboard events, focus management, label support | `tagName` _(optional)_ |
+| `audit_library` | Generates a JSONL audit report scoring every component across 11 dimensions; returns file path (if outputPath given) and summary stats | — |
### Library
@@ -368,6 +374,67 @@ _(Requires `tokensPath` to be configured)_
| `get_design_tokens` | List all design tokens, optionally filtered by category (e.g. `"color"`, `"spacing"`) | — |
| `find_token` | Search for a design token by name or value (case-insensitive substring match) | `query` |
+### TypeGenerate
+
+| Tool | Description | Required Args |
+| ---------------- | ---------------------------------------------------------------------------------------- | ------------- |
+| `generate_types` | Generates TypeScript type definitions (.d.ts content) for all custom elements in the CEM | — |
+
+### Theme
+
+| Tool | Description | Required Args |
+| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
+| `create_theme` | Scaffold a complete enterprise CSS theme from the component library's design tokens with light/dark mode variables and color-scheme support | — |
+| `apply_theme_tokens` | Map a theme token definition to specific components, generating per-component CSS blocks and a global `:root` block | `themeTokens` |
+
+### Scaffold
+
+| Tool | Description | Required Args |
+| -------------------- | ------------------------------------------------------------------------------------------------- | ------------- |
+| `scaffold_component` | Scaffold a new web component with boilerplate code based on an existing component's CEM structure | `tagName` |
+
+### Extend
+
+| Tool | Description | Required Args |
+| ------------------ | ---------------------------------------------------------------------------------------------------------------- | ------------- |
+| `extend_component` | Generate extension boilerplate for a web component, providing a subclass with overridable methods and properties | `tagName` |
+
+### Styling
+
+29 anti-hallucination validators that ground every component styling decision in real CEM data. Run `validate_component_code` as the all-in-one final check, or use individual tools for targeted validation.
+
+| Tool | Description | Required Args |
+| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
+| `diagnose_styling` | Generates a Shadow DOM styling guide for a component — token prefix, theming approach, dark mode support, anti-pattern warnings, and correct CSS usage snippets | `tagName` |
+| `get_component_quick_ref` | Complete quick reference for a component — attributes, methods, events, slots, CSS custom properties, CSS parts, Shadow DOM warnings, and anti-patterns. Use as the FIRST call when working with any component | `tagName` |
+| `validate_component_code` | ALL-IN-ONE validator — runs 19 anti-hallucination sub-validators (HTML, CSS, JS, a11y, events, methods, composition) in a single call. Use as the FINAL check before submitting any code | `html`, `tagName` |
+| `styling_preflight` | Single-call styling validation combining API discovery, CSS reference resolution, and anti-pattern detection with inline fix suggestions. Call ONCE before finalizing component CSS | `cssText`, `tagName` |
+| `validate_css_file` | Validates an entire CSS file targeting multiple components — auto-detects component tags, runs per-component and global validation with inline fixes | `cssText` |
+| `check_shadow_dom_usage` | Scans CSS for Shadow DOM anti-patterns: descendant selectors piercing shadow boundaries, `::slotted()` misuse, invalid `::part()` chaining, `!important` on tokens, unknown part names | `cssText` |
+| `check_html_usage` | Validates consumer HTML against a component CEM — catches invalid slot names, wrong enum values, boolean attribute misuse, and unknown attributes with typo suggestions | `htmlText`, `tagName` |
+| `check_event_usage` | Validates event listener patterns against a component CEM — catches React `onXxx` props for custom events, unknown event names, and framework-specific binding mistakes | `codeText`, `tagName` |
+| `check_component_imports` | Scans HTML/JSX/template code for all custom element tags and verifies they exist in the loaded CEM; catches non-existent components with fuzzy suggestions | `codeText` |
+| `check_slot_children` | Validates that children placed inside slots match expected element types from the CEM — catches wrong child elements in constrained slots (e.g. `
` inside ``) | `htmlText`, `tagName` |
+| `check_attribute_conflicts` | Detects conditional attributes used without their guard conditions — catches `target` without `href`, `min`/`max` on non-number inputs, and other attribute interaction mistakes | `htmlText`, `tagName` |
+| `check_a11y_usage` | Validates consumer HTML for accessibility mistakes — catches missing accessible labels on icon buttons/dialogs/selects, and manual role overrides on components that self-assign ARIA roles | `htmlText`, `tagName` |
+| `check_css_vars` | Validates CSS for custom property usage against a component CEM — catches unknown CSS custom properties with typo suggestions and `!important` on design tokens | `cssText`, `tagName` |
+| `check_token_fallbacks` | Validates CSS for proper `var()` fallback chains and detects hardcoded colors that break theme switching | `cssText`, `tagName` |
+| `check_composition` | Validates cross-component composition patterns — catches tab/panel count mismatches, unlinked cross-references, and empty containers | `htmlText` |
+| `check_method_calls` | Validates JS/TS code for correct method and property usage — catches hallucinated API calls, properties called as methods, and methods assigned as properties | `codeText`, `tagName` |
+| `check_theme_compatibility` | Validates CSS for dark mode and theme compatibility — catches hardcoded colors on background/color/border properties and potential contrast issues | `cssText` |
+| `check_css_specificity` | Detects CSS specificity anti-patterns — catches `!important` usage, ID selectors, deeply nested selectors (4+ levels), and inline style attributes | `code` |
+| `check_layout_patterns` | Detects layout anti-patterns when styling web component host elements — catches display overrides, fixed dimensions, absolute/fixed positioning, and `overflow: hidden` | `cssText` |
+| `check_css_scope` | Detects component-scoped CSS custom properties set at the wrong scope (e.g. on `:root` instead of the component host) | `cssText`, `tagName` |
+| `check_css_shorthand` | Detects risky CSS shorthand + `var()` combinations that can fail silently when any token is undefined | `cssText` |
+| `check_color_contrast` | Detects color contrast issues: low-contrast hardcoded color pairs, mixed color sources (token + hardcoded), and low opacity on text | `cssText` |
+| `check_transition_animation` | Detects CSS transitions and animations on component hosts targeting properties that cannot cross Shadow DOM boundaries | `cssText`, `tagName` |
+| `check_shadow_dom_js` | Detects JavaScript anti-patterns that violate Shadow DOM encapsulation — catches `.shadowRoot.querySelector()`, `attachShadow()` on existing components, and `innerHTML` overwriting slot content | `codeText` |
+| `check_dark_mode_patterns` | Detects dark mode styling anti-patterns — catches theme-scoped selectors setting standard CSS properties that won't reach shadow DOM internals | `cssText` |
+| `resolve_css_api` | Resolves every `::part()`, CSS custom property, and slot reference in agent-generated code against actual CEM data — reports valid/hallucinated references with closest valid alternatives | `cssText`, `tagName` |
+| `detect_theme_support` | Analyzes a component library for theming capabilities — token categories, semantic naming patterns, dark mode readiness, and coverage score | — |
+| `recommend_checks` | Analyzes code to determine which validation tools are most relevant — returns a prioritized list of tool names without running them all | `codeText` |
+| `suggest_fix` | Generates concrete, copy-pasteable code fixes for validation issues by type (shadow-dom, token-fallback, theme-compat, method-call, event-usage, specificity, layout) | `type`, `issue`, `original` |
+
---
## Configuration
diff --git a/assets/social-card.png b/assets/social-card.png
new file mode 100644
index 0000000..8e9bdcf
Binary files /dev/null and b/assets/social-card.png differ
diff --git a/assets/social-card.webp b/assets/social-card.webp
new file mode 100644
index 0000000..8b8a772
Binary files /dev/null and b/assets/social-card.webp differ
diff --git a/build b/build
new file mode 120000
index 0000000..5df8d8b
--- /dev/null
+++ b/build
@@ -0,0 +1 @@
+/Volumes/Development/booked/helixir/build
\ No newline at end of file
diff --git a/node_modules b/node_modules
new file mode 120000
index 0000000..07c009a
--- /dev/null
+++ b/node_modules
@@ -0,0 +1 @@
+/Volumes/Development/booked/helixir/node_modules
\ No newline at end of file
diff --git a/package.json b/package.json
index b490e16..2ab2ac8 100644
--- a/package.json
+++ b/package.json
@@ -36,6 +36,7 @@
"test:coverage": "vitest run --coverage",
"test:watch": "vitest",
"type-check": "tsc --noEmit",
+ "typecheck": "pnpm run type-check",
"lint": "eslint src packages/core/src",
"lint:fix": "eslint src packages/core/src --fix",
"format": "prettier --write .",
diff --git a/packages/core/package.json b/packages/core/package.json
index 11856c5..c0c49ba 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -16,7 +16,7 @@
},
"license": "MIT",
"dependencies": {
- "@modelcontextprotocol/sdk": "^1.26.0",
+ "@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.22.0"
},
"peerDependencies": {
diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts
index d56a547..b35efec 100644
--- a/packages/core/src/config.ts
+++ b/packages/core/src/config.ts
@@ -32,7 +32,6 @@ const defaults: McpWcConfig = {
};
function readConfigFile(projectRoot: string): Partial {
- // Primary config file name
const primaryPath = resolve(projectRoot, 'helixir.mcp.json');
if (existsSync(primaryPath)) {
try {
@@ -44,21 +43,6 @@ function readConfigFile(projectRoot: string): Partial {
}
}
- // Backward-compatible fallback to legacy config file name
- const legacyPath = resolve(projectRoot, 'mcpwc.config.json');
- if (existsSync(legacyPath)) {
- process.stderr.write(
- `[helixir] Warning: mcpwc.config.json is deprecated. Rename to helixir.mcp.json.\n`,
- );
- try {
- const raw = readFileSync(legacyPath, 'utf-8');
- return JSON.parse(raw) as Partial;
- } catch {
- process.stderr.write(`[helixir] Warning: mcpwc.config.json is malformed. Using defaults.\n`);
- return {};
- }
- }
-
return {};
}
@@ -92,36 +76,32 @@ export function loadConfig(): Readonly {
}
// Apply env vars (highest priority)
- if (process.env['MCP_WC_CEM_PATH'] !== undefined) {
- config.cemPath = process.env['MCP_WC_CEM_PATH'];
- }
- if (process.env['MCP_WC_PROJECT_ROOT'] !== undefined) {
- config.projectRoot = process.env['MCP_WC_PROJECT_ROOT'];
- }
- if (process.env['MCP_WC_COMPONENT_PREFIX'] !== undefined) {
- config.componentPrefix = process.env['MCP_WC_COMPONENT_PREFIX'];
- }
- if (process.env['MCP_WC_HEALTH_HISTORY_DIR'] !== undefined) {
- config.healthHistoryDir = process.env['MCP_WC_HEALTH_HISTORY_DIR'];
- }
- if (process.env['MCP_WC_TSCONFIG_PATH'] !== undefined) {
- config.tsconfigPath = process.env['MCP_WC_TSCONFIG_PATH'];
- }
- if (process.env['MCP_WC_TOKENS_PATH'] !== undefined) {
- const val = process.env['MCP_WC_TOKENS_PATH'];
- config.tokensPath = val === 'null' ? null : val;
- }
- if (process.env['MCP_WC_CDN_BASE'] !== undefined) {
- const val = process.env['MCP_WC_CDN_BASE'];
- config.cdnBase = val === 'null' ? null : val;
- }
- if (process.env['MCP_WC_CDN_AUTOLOADER'] !== undefined) {
- const val = process.env['MCP_WC_CDN_AUTOLOADER'];
- config.cdnAutoloader = val === 'null' ? null : val;
+ // String keys map directly; nullable keys treat the literal string 'null' as null.
+ const ENV_MAP_STRING: Readonly> = {
+ MCP_WC_CEM_PATH: 'cemPath',
+ MCP_WC_PROJECT_ROOT: 'projectRoot',
+ MCP_WC_COMPONENT_PREFIX: 'componentPrefix',
+ MCP_WC_HEALTH_HISTORY_DIR: 'healthHistoryDir',
+ MCP_WC_TSCONFIG_PATH: 'tsconfigPath',
+ };
+ const ENV_MAP_NULLABLE: Readonly> = {
+ MCP_WC_TOKENS_PATH: 'tokensPath',
+ MCP_WC_CDN_BASE: 'cdnBase',
+ MCP_WC_CDN_AUTOLOADER: 'cdnAutoloader',
+ MCP_WC_CDN_STYLESHEET: 'cdnStylesheet',
+ };
+
+ for (const [envKey, configKey] of Object.entries(ENV_MAP_STRING)) {
+ const val = process.env[envKey];
+ if (val !== undefined) {
+ (config as Record)[configKey] = val;
+ }
}
- if (process.env['MCP_WC_CDN_STYLESHEET'] !== undefined) {
- const val = process.env['MCP_WC_CDN_STYLESHEET'];
- config.cdnStylesheet = val === 'null' ? null : val;
+ for (const [envKey, configKey] of Object.entries(ENV_MAP_NULLABLE)) {
+ const val = process.env[envKey];
+ if (val !== undefined) {
+ (config as Record)[configKey] = val === 'null' ? null : val;
+ }
}
// --watch CLI flag overrides config file value
diff --git a/packages/core/src/handlers/cem.ts b/packages/core/src/handlers/cem.ts
index b13b3fe..17bbafc 100644
--- a/packages/core/src/handlers/cem.ts
+++ b/packages/core/src/handlers/cem.ts
@@ -725,7 +725,10 @@ export function findComponentsByToken(
cem: Cem,
): FindComponentsByTokenResult {
if (!token.startsWith('--')) {
- throw new Error(`CSS custom property name must start with "--": "${token}"`);
+ throw new MCPError(
+ `CSS custom property name must start with "--": "${token}"`,
+ ErrorCategory.VALIDATION,
+ );
}
const components: TokenComponentMatch[] = [];
diff --git a/packages/core/src/handlers/dependencies.ts b/packages/core/src/handlers/dependencies.ts
index df84ce4..d76417f 100644
--- a/packages/core/src/handlers/dependencies.ts
+++ b/packages/core/src/handlers/dependencies.ts
@@ -1,4 +1,5 @@
import type { Cem } from './cem.js';
+import { MCPError, ErrorCategory } from '../shared/error-handling.js';
export interface ComponentDependencyResult {
tagName: string;
@@ -105,7 +106,7 @@ export function getComponentDependencies(
}
if (!found) {
- throw new Error(`Component "${tagName}" not found in CEM.`);
+ throw new MCPError(`Component "${tagName}" not found in CEM.`, ErrorCategory.NOT_FOUND);
}
const depMap = buildDependencyMap(cem);
diff --git a/packages/core/src/handlers/extend.ts b/packages/core/src/handlers/extend.ts
index 8149c92..d62e579 100644
--- a/packages/core/src/handlers/extend.ts
+++ b/packages/core/src/handlers/extend.ts
@@ -1,4 +1,5 @@
import type { Cem, CemDeclaration } from './cem.js';
+import { MCPError, ErrorCategory } from '../shared/error-handling.js';
// --- Helpers ---
@@ -66,7 +67,7 @@ export function extendComponent(
): ExtendComponentResult {
const parentDecl = findDeclaration(cem, parentTagName);
if (!parentDecl) {
- throw new Error(`Component "${parentTagName}" not found in CEM.`);
+ throw new MCPError(`Component "${parentTagName}" not found in CEM.`, ErrorCategory.NOT_FOUND);
}
const parentClassName = tagNameToClassName(parentTagName);
diff --git a/packages/core/src/handlers/theme.ts b/packages/core/src/handlers/theme.ts
index d10eaf8..55a6e94 100644
--- a/packages/core/src/handlers/theme.ts
+++ b/packages/core/src/handlers/theme.ts
@@ -145,7 +145,7 @@ function lightPlaceholder(tokenName: string, category: string): string {
return '200ms';
default:
- return '/* TODO: set value */';
+ return `var(${tokenName})`;
}
}
diff --git a/packages/mcp/package.json b/packages/mcp/package.json
index 839f973..5ce658c 100644
--- a/packages/mcp/package.json
+++ b/packages/mcp/package.json
@@ -46,7 +46,7 @@
"homepage": "https://github.com/bookedsolidtech/helixir/tree/main/packages/mcp#readme",
"peerDependencies": {
"helixir": ">=0.5.0",
- "@modelcontextprotocol/sdk": "^1.26.0",
+ "@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.22.0"
},
"devDependencies": {
diff --git a/packages/vscode/.vscodeignore b/packages/vscode/.vscodeignore
new file mode 100644
index 0000000..aa8e76e
--- /dev/null
+++ b/packages/vscode/.vscodeignore
@@ -0,0 +1,30 @@
+# Source files — not needed in the packaged extension
+src/
+tsconfig.json
+esbuild.config.mjs
+
+# Development dependencies and lock files
+node_modules/
+.pnpm-store/
+pnpm-lock.yaml
+package-lock.json
+
+# Test artefacts
+coverage/
+*.test.ts
+*.spec.ts
+
+# Build intermediates (keep dist/)
+*.map
+
+# Editor and OS artefacts
+.vscode/
+.DS_Store
+*.log
+
+# Root-level workspace files that should not be bundled
+../../node_modules/
+../../src/
+../../build/
+../../packages/
+../../.github/
diff --git a/packages/vscode/README.md b/packages/vscode/README.md
new file mode 100644
index 0000000..689b2fb
--- /dev/null
+++ b/packages/vscode/README.md
@@ -0,0 +1,84 @@
+# Helixir — VS Code Extension
+
+**AI-powered web component intelligence for VS Code.**
+
+Helixir gives AI assistants full situational awareness of any web component library by wiring the [helixir MCP server](https://github.com/bookedsolidtech/helixir) directly into VS Code's MCP layer.
+
+## Features
+
+- **MCP server auto-registration** — the helixir MCP server starts automatically with VS Code, no manual configuration required
+- **30+ MCP tools** — component discovery, health scoring, breaking-change detection, TypeScript diagnostics, design token lookup, and more
+- **Zero hallucinations** — every AI component suggestion is grounded in your actual `custom-elements.json`
+- **Framework-agnostic** — works with Lit, Stencil, FAST, Spectrum, Shoelace, or any library that produces a Custom Elements Manifest
+
+## Requirements
+
+- VS Code **≥ 1.99.0**
+- A component library with a `custom-elements.json` (Custom Elements Manifest)
+- Node.js **≥ 20** on `PATH`
+
+## Getting Started
+
+1. Install the extension from the VS Code Marketplace
+2. Open your component library folder in VS Code
+3. The Helixir MCP server will register automatically with AI assistants that support MCP (e.g., GitHub Copilot, Claude)
+
+### Optional: Configure the Config Path
+
+If your `mcpwc.config.json` is not at the workspace root, set the path via VS Code settings:
+
+```json
+// .vscode/settings.json
+{
+ "helixir.configPath": "packages/web-components/mcpwc.config.json"
+}
+```
+
+The path can be relative to the workspace root or absolute.
+
+## Commands
+
+| Command | Description |
+|---------|-------------|
+| `Helixir: Run Health Check` | Guides you to run a health check via your AI assistant |
+
+## Extension Settings
+
+| Setting | Type | Default | Description |
+|---------|------|---------|-------------|
+| `helixir.configPath` | `string` | `""` | Path to `mcpwc.config.json`. Empty = workspace root. |
+
+## How It Works
+
+When the extension activates, it registers a **MCP server definition provider** (`helixir`) with VS Code's language model API (`vscode.lm`). VS Code spawns the bundled helixir MCP server (`dist/mcp-server.js`) as a child process over stdio.
+
+The server reads your `custom-elements.json` and exposes 30+ tools that AI models can call to look up component APIs, run health scans, generate type declarations, and more.
+
+## Configuration Reference
+
+The helixir server is configured via environment variables passed by the extension:
+
+| Variable | Description |
+|----------|-------------|
+| `MCP_WC_PROJECT_ROOT` | Set to your workspace folder automatically |
+| `MCP_WC_CONFIG_PATH` | Set when `helixir.configPath` is configured |
+
+Additional configuration (token path, component prefix, health history dir) belongs in `mcpwc.config.json`. See the [helixir documentation](https://github.com/bookedsolidtech/helixir) for the full config reference.
+
+## Troubleshooting
+
+**MCP server not appearing in AI assistant tools**
+- Verify VS Code ≥ 1.99.0 is installed
+- Confirm your workspace contains a `custom-elements.json`
+- Check the Output panel → Helixir for error messages
+
+**"No workspace folder" error from Run Health Check**
+- Open a folder (not just a file) in VS Code — the extension uses the workspace folder as the project root
+
+**Server starts but returns no components**
+- Ensure `custom-elements.json` exists at the workspace root or configure `helixir.configPath`
+- Regenerate the manifest: `npm run analyze:cem` (or your CEM generation script)
+
+## License
+
+MIT — see [LICENSE](../../LICENSE)
diff --git a/packages/vscode/esbuild.config.mjs b/packages/vscode/esbuild.config.mjs
new file mode 100644
index 0000000..85e5030
--- /dev/null
+++ b/packages/vscode/esbuild.config.mjs
@@ -0,0 +1,69 @@
+/**
+ * esbuild configuration for the Helixir VS Code extension.
+ *
+ * Produces two bundles:
+ * dist/extension.js — VS Code extension host entry (CJS, externalizes 'vscode')
+ * dist/mcp-server.js — Helixir MCP server entry (ESM, bundles helixir)
+ */
+
+import * as esbuild from 'esbuild';
+
+const isProduction = process.argv.includes('--production');
+const isWatch = process.argv.includes('--watch');
+
+const sharedOptions = {
+ bundle: true,
+ sourcemap: !isProduction,
+ minify: isProduction,
+ logLevel: 'info',
+ platform: 'node',
+ target: 'node20',
+};
+
+/**
+ * Bundle 1: VS Code extension host entry
+ * - CommonJS (VS Code extension host requires CJS)
+ * - 'vscode' is externalized — provided by the VS Code runtime
+ */
+const extensionConfig = {
+ ...sharedOptions,
+ entryPoints: ['src/extension.ts'],
+ outfile: 'dist/extension.js',
+ format: 'cjs',
+ external: ['vscode'],
+};
+
+/**
+ * Bundle 2: Helixir MCP server
+ * - ESM format (helixir is an ES module)
+ * - Bundles helixir and its dependencies so the extension is self-contained
+ * - Spawned as a child process via stdio by the VS Code extension
+ */
+const mcpServerConfig = {
+ ...sharedOptions,
+ entryPoints: ['src/mcp-server-entry.ts'],
+ outfile: 'dist/mcp-server.js',
+ format: 'esm',
+ banner: {
+ js: '#!/usr/bin/env node\n// Helixir MCP Server — bundled by esbuild',
+ },
+};
+
+async function build() {
+ const extensionCtx = await esbuild.context(extensionConfig);
+ const mcpServerCtx = await esbuild.context(mcpServerConfig);
+
+ if (isWatch) {
+ await Promise.all([extensionCtx.watch(), mcpServerCtx.watch()]);
+ console.log('[helixir-vscode] Watching for changes...');
+ } else {
+ await Promise.all([extensionCtx.rebuild(), mcpServerCtx.rebuild()]);
+ await Promise.all([extensionCtx.dispose(), mcpServerCtx.dispose()]);
+ console.log('[helixir-vscode] Build complete.');
+ }
+}
+
+build().catch((err) => {
+ console.error('[helixir-vscode] Build failed:', err);
+ process.exit(1);
+});
diff --git a/packages/vscode/package.json b/packages/vscode/package.json
new file mode 100644
index 0000000..b33a626
--- /dev/null
+++ b/packages/vscode/package.json
@@ -0,0 +1,72 @@
+{
+ "name": "helixir-vscode",
+ "displayName": "Helixir",
+ "description": "AI-powered web component intelligence for VS Code — powered by helixir MCP",
+ "version": "0.1.0",
+ "publisher": "bookedsolidtech",
+ "private": true,
+ "engines": {
+ "vscode": "^1.99.0"
+ },
+ "categories": [
+ "Other",
+ "AI"
+ ],
+ "keywords": [
+ "mcp",
+ "web components",
+ "helixir",
+ "ai",
+ "custom elements"
+ ],
+ "activationEvents": [
+ "onStartupFinished"
+ ],
+ "main": "./dist/extension.js",
+ "contributes": {
+ "mcpServerDefinitionProviders": [
+ {
+ "id": "helixir",
+ "label": "Helixir"
+ }
+ ],
+ "commands": [
+ {
+ "command": "helixir.runHealthCheck",
+ "title": "Helixir: Run Health Check",
+ "category": "Helixir"
+ },
+ {
+ "command": "helixir.configureCursorWindsurf",
+ "title": "Helixir: Configure for Cursor/Windsurf",
+ "category": "Helixir"
+ }
+ ],
+ "configuration": {
+ "title": "Helixir",
+ "properties": {
+ "helixir.configPath": {
+ "type": "string",
+ "default": "",
+ "description": "Path to mcpwc.config.json (relative to workspace root or absolute). Leave empty to use workspace root defaults."
+ }
+ }
+ }
+ },
+ "scripts": {
+ "vscode:prepublish": "node esbuild.config.mjs --production",
+ "build": "node esbuild.config.mjs",
+ "watch": "node esbuild.config.mjs --watch",
+ "package": "vsce package --no-dependencies",
+ "publish": "vsce publish --no-dependencies"
+ },
+ "dependencies": {
+ "helixir": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/vscode": "^1.99.0",
+ "@vscode/vsce": "^3.0.0",
+ "esbuild": "^0.25.0",
+ "ovsx": "^0.9.0"
+ }
+}
diff --git a/packages/vscode/src/commands/configureCursorWindsurf.ts b/packages/vscode/src/commands/configureCursorWindsurf.ts
new file mode 100644
index 0000000..ffb6ab9
--- /dev/null
+++ b/packages/vscode/src/commands/configureCursorWindsurf.ts
@@ -0,0 +1,114 @@
+import * as fs from 'fs';
+import * as os from 'os';
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+/**
+ * Detects whether the current host is Cursor editor by inspecting the
+ * application name reported by the VS Code API and common environment
+ * variables set by the Cursor process.
+ */
+function isCursor(): boolean {
+ const appName = vscode.env.appName ?? '';
+ return (
+ appName.toLowerCase().includes('cursor') ||
+ (process.env['CURSOR_TRACE_ID'] !== undefined) ||
+ (process.env['CURSOR_APP_PATH'] !== undefined)
+ );
+}
+
+/**
+ * Returns the directory name (.cursor or .windsurf) and a human-readable
+ * editor label based on the detected editor.
+ */
+function resolveEditorConfig(): { dirName: string; label: string } {
+ if (isCursor()) {
+ return { dirName: '.cursor', label: 'Cursor' };
+ }
+ return { dirName: '.windsurf', label: 'Windsurf' };
+}
+
+interface McpServerEntry {
+ command: string;
+ args: string[];
+ env: Record;
+}
+
+interface McpJson {
+ mcpServers: Record;
+}
+
+/**
+ * Registers the "Helixir: Configure for Cursor/Windsurf" command.
+ *
+ * When invoked the command:
+ * 1. Detects whether the host is Cursor or Windsurf/other.
+ * 2. Resolves the target mcp.json path inside the workspace root (or $HOME as
+ * fallback when no workspace is open).
+ * 3. Reads any existing mcp.json so that pre-existing server entries are
+ * preserved.
+ * 4. Upserts the "helixir" entry pointing at the bundled mcp-server.js.
+ * 5. Writes the file and shows an information notification.
+ */
+export function registerConfigureCursorWindsurfCommand(
+ context: vscode.ExtensionContext
+): void {
+ const command = vscode.commands.registerCommand(
+ 'helixir.configureCursorWindsurf',
+ async () => {
+ const { dirName, label } = resolveEditorConfig();
+
+ // Resolve the base directory (workspace root or home directory).
+ const baseDir =
+ vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? os.homedir();
+
+ const configDir = path.join(baseDir, dirName);
+ const configFilePath = path.join(configDir, 'mcp.json');
+
+ // Path to the bundled MCP server shipped with this extension.
+ const serverScriptPath = path.join(
+ context.extensionPath,
+ 'dist',
+ 'mcp-server.js'
+ );
+
+ // Read existing config (if any) so we don't stomp other servers.
+ let existing: McpJson = { mcpServers: {} };
+ if (fs.existsSync(configFilePath)) {
+ try {
+ const raw = fs.readFileSync(configFilePath, 'utf8');
+ const parsed = JSON.parse(raw) as Partial;
+ existing = {
+ mcpServers: parsed.mcpServers ?? {},
+ };
+ } catch {
+ // If the file is malformed, start fresh but preserve the attempt.
+ existing = { mcpServers: {} };
+ }
+ }
+
+ // Upsert the helixir entry.
+ existing.mcpServers['helixir'] = {
+ command: 'node',
+ args: [serverScriptPath],
+ env: {},
+ };
+
+ // Ensure the config directory exists.
+ fs.mkdirSync(configDir, { recursive: true });
+
+ // Write the updated config.
+ fs.writeFileSync(
+ configFilePath,
+ JSON.stringify(existing, null, 2) + '\n',
+ 'utf8'
+ );
+
+ await vscode.window.showInformationMessage(
+ `Helixir: MCP server entry written to ${configFilePath} (${label}).`
+ );
+ }
+ );
+
+ context.subscriptions.push(command);
+}
diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts
new file mode 100644
index 0000000..7480d62
--- /dev/null
+++ b/packages/vscode/src/extension.ts
@@ -0,0 +1,41 @@
+import * as vscode from 'vscode';
+import { registerConfigureCursorWindsurfCommand } from './commands/configureCursorWindsurf.js';
+import { registerMcpProvider } from './mcpProvider.js';
+
+/**
+ * Called when the extension is activated.
+ * Registers the Helixir MCP server definition provider and the
+ * "Helixir: Run Health Check" command.
+ */
+export function activate(context: vscode.ExtensionContext): void {
+ registerMcpProvider(context);
+ registerConfigureCursorWindsurfCommand(context);
+
+ const healthCheckCommand = vscode.commands.registerCommand(
+ 'helixir.runHealthCheck',
+ async () => {
+ const workspaceFolders = vscode.workspace.workspaceFolders;
+ if (!workspaceFolders || workspaceFolders.length === 0) {
+ await vscode.window.showErrorMessage(
+ 'Helixir: No workspace folder is open. ' +
+ 'Open a component library folder to run a health check.'
+ );
+ return;
+ }
+
+ await vscode.window.showInformationMessage(
+ 'Helixir: MCP server is active. ' +
+ 'Ask your AI assistant to call score_all_components via the Helixir MCP server.'
+ );
+ }
+ );
+
+ context.subscriptions.push(healthCheckCommand);
+}
+
+/**
+ * Called when the extension is deactivated.
+ */
+export function deactivate(): void {
+ // Subscriptions are disposed automatically via context.subscriptions.
+}
diff --git a/packages/vscode/src/mcp-server-entry.ts b/packages/vscode/src/mcp-server-entry.ts
new file mode 100644
index 0000000..ef0068d
--- /dev/null
+++ b/packages/vscode/src/mcp-server-entry.ts
@@ -0,0 +1,16 @@
+/**
+ * Helixir MCP Server — entry point for the bundled server.
+ *
+ * This file is bundled by esbuild into dist/mcp-server.js (ESM format).
+ * It is spawned as a child process by the VS Code extension (mcpProvider.ts)
+ * using stdio transport.
+ *
+ * The helixir/mcp module exports a `main()` function that initialises and
+ * starts the MCP server, listening on stdin/stdout.
+ */
+import { main } from 'helixir/mcp';
+
+main().catch((err: unknown) => {
+ process.stderr.write(`[helixir-mcp] Fatal: ${String(err)}\n`);
+ process.exit(1);
+});
diff --git a/packages/vscode/src/mcpProvider.ts b/packages/vscode/src/mcpProvider.ts
new file mode 100644
index 0000000..0bf1e95
--- /dev/null
+++ b/packages/vscode/src/mcpProvider.ts
@@ -0,0 +1,68 @@
+import * as path from 'path';
+import * as vscode from 'vscode';
+
+/**
+ * Registers helixir as an MCP server definition provider with VS Code.
+ *
+ * The provider spawns dist/mcp-server.js (the bundled helixir MCP server)
+ * as a child process via stdio. VS Code passes it to connected AI models
+ * (e.g., GitHub Copilot, Claude) automatically.
+ *
+ * The server is configured with the workspace folder as MCP_WC_PROJECT_ROOT
+ * so helixir reads the correct custom-elements.json.
+ *
+ * Requires VS Code ≥ 1.99.0 (MCP server definition provider API).
+ */
+export function registerMcpProvider(context: vscode.ExtensionContext): void {
+ const provider = {
+ provideMcpServerDefinitions() {
+ const serverScriptPath = path.join(
+ context.extensionPath,
+ 'dist',
+ 'mcp-server.js'
+ );
+
+ const workspaceFolder =
+ vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd();
+
+ const configPath = vscode.workspace
+ .getConfiguration('helixir')
+ .get('configPath', '');
+
+ const env: Record = {
+ MCP_WC_PROJECT_ROOT: workspaceFolder,
+ };
+
+ // If the user specified a custom config path, resolve it and pass it on.
+ if (configPath && configPath.trim() !== '') {
+ env['MCP_WC_CONFIG_PATH'] = path.isAbsolute(configPath)
+ ? configPath
+ : path.join(workspaceFolder, configPath);
+ }
+
+ return [
+ {
+ label: 'Helixir',
+ command: 'node',
+ args: [serverScriptPath],
+ env,
+ },
+ ];
+ },
+ };
+
+ // vscode.lm.registerMcpServerDefinitionProvider was introduced in VS Code 1.99.
+ // We guard the call so the extension degrades gracefully on older hosts.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const lm = vscode.lm as any;
+ if (typeof lm?.registerMcpServerDefinitionProvider === 'function') {
+ context.subscriptions.push(
+ lm.registerMcpServerDefinitionProvider('helixir', provider) as vscode.Disposable
+ );
+ } else {
+ console.warn(
+ '[helixir-vscode] vscode.lm.registerMcpServerDefinitionProvider is not available. ' +
+ 'Upgrade to VS Code ≥ 1.99.0 to enable MCP server support.'
+ );
+ }
+}
diff --git a/packages/vscode/tsconfig.json b/packages/vscode/tsconfig.json
new file mode 100644
index 0000000..cb042b5
--- /dev/null
+++ b/packages/vscode/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "outDir": "dist",
+ "rootDir": "src",
+ "lib": ["ES2022"],
+ "strict": true,
+ "noEmit": true
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/src/cli/index.ts b/src/cli/index.ts
index 91579f2..f74cde2 100644
--- a/src/cli/index.ts
+++ b/src/cli/index.ts
@@ -3,6 +3,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { parseArgs } from 'node:util';
import { loadConfig } from '../../packages/core/src/config.js';
+import { handleToolError } from '../../packages/core/src/shared/error-handling.js';
import { CemSchema } from '../../packages/core/src/handlers/cem.js';
import type { Cem, CemDeclaration } from '../../packages/core/src/handlers/cem.js';
import { parseCem, diffCem, listAllComponents } from '../../packages/core/src/handlers/cem.js';
@@ -179,11 +180,11 @@ async function cmdHealth(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
if (opts.trend) {
- const tag = args[0];
- if (!tag) {
+ if (args.length < 1) {
process.stderr.write('Error: --trend requires a tag name\n');
process.exit(1);
}
+ const tag = args[0] as string;
const trend = await getHealthTrend(config, tag);
if (opts.format === 'json') {
output(trend, 'json');
@@ -283,12 +284,12 @@ async function cmdDiff(args: string[], opts: CliOptions): Promise {
async function cmdMigrate(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
const cem = loadCem(config.cemPath, config.projectRoot);
- const tag = args[0];
- if (!tag) {
+ if (args.length < 1) {
process.stderr.write('Error: migrate requires a tag name\n');
process.exit(1);
}
+ const tag = args[0] as string;
const guide = await generateMigrationGuide(tag, opts.base, config, cem);
@@ -302,12 +303,12 @@ async function cmdMigrate(args: string[], opts: CliOptions): Promise {
async function cmdSuggest(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
const cem = loadCem(config.cemPath, config.projectRoot);
- const tag = args[0];
- if (!tag) {
+ if (args.length < 1) {
process.stderr.write('Error: suggest requires a tag name\n');
process.exit(1);
}
+ const tag = args[0] as string;
const result = await suggestUsage(tag, config, cem);
@@ -324,12 +325,12 @@ async function cmdSuggest(args: string[], opts: CliOptions): Promise {
async function cmdBundle(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
- const tag = args[0];
- if (!tag) {
+ if (args.length < 1) {
process.stderr.write('Error: bundle requires a tag name\n');
process.exit(1);
}
+ const tag = args[0] as string;
const result = await estimateBundleSize(tag, config);
const fp = result.estimates.full_package;
@@ -372,13 +373,13 @@ async function cmdTokens(args: string[], opts: CliOptions): Promise {
async function cmdCompare(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
- const cemA = args[0];
- const cemB = args[1];
- if (!cemA || !cemB) {
+ if (args.length < 2) {
process.stderr.write('Error: compare requires two CEM paths\n');
process.exit(1);
}
+ const cemA = args[0] as string;
+ const cemB = args[1] as string;
const result = await compareLibraries({ cemPathA: cemA, cemPathB: cemB }, config);
@@ -419,12 +420,12 @@ async function cmdBenchmark(args: string[], opts: CliOptions): Promise {
async function cmdValidate(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
- const tag = args[0];
- if (!tag) {
+ if (args.length < 1) {
process.stderr.write('Error: validate requires a tag name\n');
process.exit(1);
}
+ const tag = args[0] as string;
if (!opts.html) {
process.stderr.write('Error: validate requires --html ""\n');
process.exit(1);
@@ -445,14 +446,13 @@ async function cmdValidate(args: string[], opts: CliOptions): Promise {
async function cmdCdn(args: string[], opts: CliOptions): Promise {
const config = loadConfig();
- const pkg = args[0];
- if (!pkg) {
+ if (args.length < 1) {
process.stderr.write('Error: cdn requires a package name\n');
process.exit(1);
}
-
- const version = args[1] ?? 'latest';
+ const pkg = args[0] as string;
+ const version = args.length >= 2 ? (args[1] as string) : 'latest';
const result = await resolveCdnCem(pkg, version, opts.registry, config);
if (opts.format === 'json') {
@@ -524,7 +524,7 @@ export async function runCli(): Promise {
values = result.values;
positionals = result.positionals;
} catch (err) {
- process.stderr.write(`Error: ${String(err)}\n`);
+ process.stderr.write(`Error: ${handleToolError(err).message}\n`);
process.exit(1);
}
@@ -612,7 +612,7 @@ export async function runCli(): Promise {
process.exit(1);
}
} catch (err) {
- process.stderr.write(`Error: ${String(err)}\n`);
+ process.stderr.write(`Error: ${handleToolError(err).message}\n`);
process.exit(1);
}
}
diff --git a/src/mcp/index.ts b/src/mcp/index.ts
index 37aea86..70a331f 100644
--- a/src/mcp/index.ts
+++ b/src/mcp/index.ts
@@ -4,6 +4,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprot
import { existsSync, readFileSync, watch as fsWatch } from 'fs';
import { resolve, relative, sep } from 'path';
import { loadConfig } from '../../packages/core/src/config.js';
+import { handleToolError } from '../../packages/core/src/shared/error-handling.js';
import { CemSchema, loadLibrary, resolveCem } from '../../packages/core/src/handlers/cem.js';
import type { Cem } from '../../packages/core/src/handlers/cem.js';
import {
@@ -92,6 +93,16 @@ import {
handleThemeCall,
isThemeTool,
} from '../../packages/core/src/tools/theme.js';
+import {
+ SCAFFOLD_TOOL_DEFINITIONS,
+ handleScaffoldCall,
+ isScaffoldTool,
+} from '../../packages/core/src/tools/scaffold.js';
+import {
+ EXTEND_TOOL_DEFINITIONS,
+ handleExtendCall,
+ isExtendTool,
+} from '../../packages/core/src/tools/extend.js';
import { createErrorResponse } from '../../packages/core/src/shared/mcp-helpers.js';
import type { MCPToolResult } from '../../packages/core/src/shared/mcp-helpers.js';
@@ -138,7 +149,7 @@ function startCemWatcher(cemAbsPath: string): void {
);
prevCount = componentCount;
} catch (err) {
- process.stderr.write(`[helixir] CEM reload failed: ${String(err)}\n`);
+ process.stderr.write(`[helixir] CEM reload failed: ${handleToolError(err).message}\n`);
} finally {
cemReloading = false;
}
@@ -173,7 +184,7 @@ export async function main(): Promise {
loadCem(cemAbsPath);
} catch (err) {
const relPath = relative(resolvedProjectRoot, cemAbsPath);
- process.stderr.write(`Fatal: CEM file at ${relPath} is invalid: ${String(err)}\n`);
+ process.stderr.write(`Fatal: CEM file at ${relPath} is invalid: ${handleToolError(err).message}\n`);
process.exit(1);
}
@@ -199,6 +210,8 @@ export async function main(): Promise {
...TYPEGENERATE_TOOL_DEFINITIONS,
...STYLING_TOOL_DEFINITIONS,
...THEME_TOOL_DEFINITIONS,
+ ...SCAFFOLD_TOOL_DEFINITIONS,
+ ...EXTEND_TOOL_DEFINITIONS,
...tsTools,
];
@@ -290,6 +303,20 @@ export async function main(): Promise {
);
return handleThemeCall(name, typedArgs, resolveCem(libraryId, cemCache));
}
+ if (isScaffoldTool(name)) {
+ if (cemCache === null || cemReloading)
+ return createErrorResponse(
+ 'CEM not yet loaded — server is still initializing. Please retry.',
+ );
+ return handleScaffoldCall(name, typedArgs, config, resolveCem(libraryId, cemCache));
+ }
+ if (isExtendTool(name)) {
+ if (cemCache === null || cemReloading)
+ return createErrorResponse(
+ 'CEM not yet loaded — server is still initializing. Please retry.',
+ );
+ return handleExtendCall(name, typedArgs, resolveCem(libraryId, cemCache));
+ }
if (isBenchmarkTool(name)) return handleBenchmarkCall(name, typedArgs, config);
if (isTypegenerateTool(name)) {
if (cemCache === null || cemReloading)
diff --git a/tests/handlers/analyzers/api-surface.test.ts b/tests/handlers/analyzers/api-surface.test.ts
new file mode 100644
index 0000000..8e00bb6
--- /dev/null
+++ b/tests/handlers/analyzers/api-surface.test.ts
@@ -0,0 +1,360 @@
+/**
+ * API Surface Quality Analyzer — unit tests
+ *
+ * Tests analyzeApiSurface() covering:
+ * - Method documentation scoring (30 pts)
+ * - Attribute reflection scoring (25 pts)
+ * - Default values documented scoring (25 pts)
+ * - Property descriptions scoring (20 pts)
+ * - Null return for empty components
+ * - Proportional normalization when some categories absent
+ */
+
+import { describe, it, expect } from 'vitest';
+import { analyzeApiSurface } from '../../../packages/core/src/handlers/analyzers/api-surface.js';
+import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js';
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const FULLY_DOCUMENTED: CemDeclaration = {
+ kind: 'class',
+ name: 'FullyDocumented',
+ tagName: 'fully-documented',
+ members: [
+ {
+ kind: 'field',
+ name: 'value',
+ type: { text: 'string' },
+ description: 'The current value.',
+ default: '"hello"',
+ attribute: 'value',
+ reflects: true,
+ },
+ {
+ kind: 'field',
+ name: 'disabled',
+ type: { text: 'boolean' },
+ description: 'Disables the component.',
+ default: 'false',
+ attribute: 'disabled',
+ },
+ {
+ kind: 'method',
+ name: 'reset',
+ description: 'Resets to initial state.',
+ return: { type: { text: 'void' } },
+ },
+ {
+ kind: 'method',
+ name: 'validate',
+ description: 'Validates the current value.',
+ return: { type: { text: 'boolean' } },
+ },
+ ],
+};
+
+const UNDOCUMENTED: CemDeclaration = {
+ kind: 'class',
+ name: 'Undocumented',
+ tagName: 'undocumented',
+ members: [
+ { kind: 'field', name: 'value' },
+ { kind: 'field', name: 'count' },
+ { kind: 'method', name: 'reset' },
+ { kind: 'method', name: 'update' },
+ ],
+};
+
+const EMPTY_COMPONENT: CemDeclaration = {
+ kind: 'class',
+ name: 'Empty',
+ tagName: 'empty-thing',
+};
+
+const METHODS_ONLY: CemDeclaration = {
+ kind: 'class',
+ name: 'MethodsOnly',
+ tagName: 'methods-only',
+ members: [
+ { kind: 'method', name: 'open', description: 'Opens the panel.' },
+ { kind: 'method', name: 'close', description: 'Closes the panel.' },
+ { kind: 'method', name: 'toggle', description: 'Toggles open state.' },
+ ],
+};
+
+const FIELDS_ONLY: CemDeclaration = {
+ kind: 'class',
+ name: 'FieldsOnly',
+ tagName: 'fields-only',
+ members: [
+ {
+ kind: 'field',
+ name: 'label',
+ type: { text: 'string' },
+ description: 'Visible label.',
+ default: '""',
+ attribute: 'label',
+ },
+ {
+ kind: 'field',
+ name: 'placeholder',
+ type: { text: 'string' },
+ description: 'Placeholder text.',
+ default: '""',
+ attribute: 'placeholder',
+ },
+ ],
+};
+
+const PARTIAL_DOCS: CemDeclaration = {
+ kind: 'class',
+ name: 'PartialDocs',
+ tagName: 'partial-docs',
+ members: [
+ {
+ kind: 'field',
+ name: 'value',
+ type: { text: 'string' },
+ description: 'The value.',
+ default: '""',
+ attribute: 'value',
+ },
+ { kind: 'field', name: 'count', type: { text: 'number' } }, // no description, no default, no attribute
+ {
+ kind: 'method',
+ name: 'reset',
+ description: 'Resets it.',
+ },
+ { kind: 'method', name: 'update' }, // no description
+ ],
+};
+
+// ─── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('analyzeApiSurface', () => {
+ describe('null return cases', () => {
+ it('returns null for component with no members', () => {
+ const result = analyzeApiSurface(EMPTY_COMPONENT);
+ expect(result).toBeNull();
+ });
+
+ it('returns null when members is undefined', () => {
+ const decl: CemDeclaration = { kind: 'class', name: 'NoMembers', tagName: 'no-members' };
+ expect(analyzeApiSurface(decl)).toBeNull();
+ });
+
+ it('returns null when members array is empty', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'EmptyMembers',
+ tagName: 'empty-members',
+ members: [],
+ };
+ expect(analyzeApiSurface(decl)).toBeNull();
+ });
+ });
+
+ describe('result structure', () => {
+ it('returns score, confidence, and subMetrics', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ expect(result).not.toBeNull();
+ expect(result).toHaveProperty('score');
+ expect(result).toHaveProperty('confidence');
+ expect(result).toHaveProperty('subMetrics');
+ });
+
+ it('confidence is always heuristic', () => {
+ expect(analyzeApiSurface(FULLY_DOCUMENTED)!.confidence).toBe('heuristic');
+ expect(analyzeApiSurface(UNDOCUMENTED)!.confidence).toBe('heuristic');
+ expect(analyzeApiSurface(METHODS_ONLY)!.confidence).toBe('heuristic');
+ });
+
+ it('has 4 sub-metrics', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ expect(result!.subMetrics).toHaveLength(4);
+ });
+
+ it('sub-metric names match expected categories', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ const names = result!.subMetrics.map((m) => m.name);
+ expect(names).toContain('Method documentation');
+ expect(names).toContain('Attribute reflection');
+ expect(names).toContain('Default values documented');
+ expect(names).toContain('Property descriptions');
+ });
+ });
+
+ describe('full documentation scoring', () => {
+ it('scores 100 for a fully-documented component', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ expect(result!.score).toBe(100);
+ });
+
+ it('scores method documentation as full when all methods have descriptions', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation');
+ expect(methodMetric!.score).toBe(methodMetric!.maxScore);
+ });
+
+ it('scores attribute reflection as full when all fields have attributes', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection');
+ expect(attrMetric!.score).toBe(attrMetric!.maxScore);
+ });
+
+ it('scores default values as full when all fields have defaults', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented');
+ expect(defaultMetric!.score).toBe(defaultMetric!.maxScore);
+ });
+
+ it('scores property descriptions as full when all fields have descriptions', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ const propMetric = result!.subMetrics.find((m) => m.name === 'Property descriptions');
+ expect(propMetric!.score).toBe(propMetric!.maxScore);
+ });
+ });
+
+ describe('low documentation scoring', () => {
+ it('scores low for undocumented component', () => {
+ const result = analyzeApiSurface(UNDOCUMENTED);
+ expect(result!.score).toBeLessThan(20);
+ });
+
+ it('scores 0 for method documentation when no methods have descriptions', () => {
+ const result = analyzeApiSurface(UNDOCUMENTED);
+ const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation');
+ expect(methodMetric!.score).toBe(0);
+ });
+
+ it('scores 0 for attribute reflection when no fields have attributes', () => {
+ const result = analyzeApiSurface(UNDOCUMENTED);
+ const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection');
+ expect(attrMetric!.score).toBe(0);
+ });
+
+ it('scores 0 for default values when no fields have defaults', () => {
+ const result = analyzeApiSurface(UNDOCUMENTED);
+ const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented');
+ expect(defaultMetric!.score).toBe(0);
+ });
+
+ it('scores 0 for property descriptions when no fields have descriptions', () => {
+ const result = analyzeApiSurface(UNDOCUMENTED);
+ const propMetric = result!.subMetrics.find((m) => m.name === 'Property descriptions');
+ expect(propMetric!.score).toBe(0);
+ });
+ });
+
+ describe('methods-only component', () => {
+ it('returns a result for methods-only component', () => {
+ const result = analyzeApiSurface(METHODS_ONLY);
+ expect(result).not.toBeNull();
+ });
+
+ it('scores well when all methods are documented', () => {
+ const result = analyzeApiSurface(METHODS_ONLY);
+ // Only method dimension applies; field dimensions score 0 (no fields)
+ // Score is normalized to applicable max
+ expect(result!.score).toBeGreaterThan(0);
+ });
+
+ it('scores field-related sub-metrics as 0 when no fields exist', () => {
+ const result = analyzeApiSurface(METHODS_ONLY);
+ const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection');
+ const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented');
+ const propMetric = result!.subMetrics.find((m) => m.name === 'Property descriptions');
+ expect(attrMetric!.score).toBe(0);
+ expect(defaultMetric!.score).toBe(0);
+ expect(propMetric!.score).toBe(0);
+ });
+ });
+
+ describe('fields-only component', () => {
+ it('returns a result for fields-only component', () => {
+ const result = analyzeApiSurface(FIELDS_ONLY);
+ expect(result).not.toBeNull();
+ });
+
+ it('scores method documentation as 0 when no methods exist', () => {
+ const result = analyzeApiSurface(FIELDS_ONLY);
+ const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation');
+ expect(methodMetric!.score).toBe(0);
+ });
+
+ it('normalizes score to 100 for fully-documented fields-only component', () => {
+ const result = analyzeApiSurface(FIELDS_ONLY);
+ expect(result!.score).toBe(100);
+ });
+ });
+
+ describe('partial documentation scoring', () => {
+ it('scores proportionally for partial documentation', () => {
+ const result = analyzeApiSurface(PARTIAL_DOCS);
+ expect(result).not.toBeNull();
+ // Not 0 and not 100
+ expect(result!.score).toBeGreaterThan(0);
+ expect(result!.score).toBeLessThan(100);
+ });
+
+ it('scores method documentation at 50% when half methods documented', () => {
+ const result = analyzeApiSurface(PARTIAL_DOCS);
+ const methodMetric = result!.subMetrics.find((m) => m.name === 'Method documentation');
+ // 1 of 2 methods has description → round(1/2 * 30) = 15
+ expect(methodMetric!.score).toBe(15);
+ });
+
+ it('scores attribute reflection at 50% when half fields have attributes', () => {
+ const result = analyzeApiSurface(PARTIAL_DOCS);
+ const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection');
+ // 1 of 2 fields has attribute → round(1/2 * 25) = 13 (or 12)
+ expect(attrMetric!.score).toBeGreaterThan(0);
+ expect(attrMetric!.score).toBeLessThan(25);
+ });
+
+ it('scores default values at 50% when half fields have defaults', () => {
+ const result = analyzeApiSurface(PARTIAL_DOCS);
+ const defaultMetric = result!.subMetrics.find((m) => m.name === 'Default values documented');
+ // 1 of 2 fields has default
+ expect(defaultMetric!.score).toBeGreaterThan(0);
+ expect(defaultMetric!.score).toBeLessThan(25);
+ });
+ });
+
+ describe('reflects field for attribute reflection', () => {
+ it('counts reflects:true as attribute binding', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'WithReflects',
+ tagName: 'with-reflects',
+ members: [
+ { kind: 'field', name: 'open', type: { text: 'boolean' }, reflects: true },
+ { kind: 'field', name: 'value', type: { text: 'string' }, attribute: 'value' },
+ ],
+ };
+ const result = analyzeApiSurface(decl);
+ const attrMetric = result!.subMetrics.find((m) => m.name === 'Attribute reflection');
+ // Both fields qualify: one via reflects, one via attribute
+ expect(attrMetric!.score).toBe(attrMetric!.maxScore);
+ });
+ });
+
+ describe('score bounds', () => {
+ it('score is always in range [0, 100]', () => {
+ const decls = [FULLY_DOCUMENTED, UNDOCUMENTED, PARTIAL_DOCS, METHODS_ONLY, FIELDS_ONLY];
+ for (const decl of decls) {
+ const result = analyzeApiSurface(decl);
+ if (result) {
+ expect(result.score).toBeGreaterThanOrEqual(0);
+ expect(result.score).toBeLessThanOrEqual(100);
+ }
+ }
+ });
+
+ it('sub-metric maxScore values sum to 100', () => {
+ const result = analyzeApiSurface(FULLY_DOCUMENTED);
+ const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0);
+ expect(maxSum).toBe(100);
+ });
+ });
+});
diff --git a/tests/handlers/analyzers/css-architecture.test.ts b/tests/handlers/analyzers/css-architecture.test.ts
new file mode 100644
index 0000000..5f86638
--- /dev/null
+++ b/tests/handlers/analyzers/css-architecture.test.ts
@@ -0,0 +1,323 @@
+/**
+ * CSS Architecture Analyzer — unit tests
+ *
+ * Tests analyzeCssArchitecture() covering:
+ * - CSS property descriptions scoring (35 pts)
+ * - Design token naming patterns scoring (30 pts)
+ * - CSS parts documentation scoring (35 pts)
+ * - Null return for components with no CSS metadata
+ * - Proportional normalization when only props OR parts exist
+ * - Token naming pattern validation (--prefix-name)
+ */
+
+import { describe, it, expect } from 'vitest';
+import { analyzeCssArchitecture } from '../../../packages/core/src/handlers/analyzers/css-architecture.js';
+import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js';
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const IDEAL_CSS: CemDeclaration = {
+ kind: 'class',
+ name: 'IdealCss',
+ tagName: 'ideal-css',
+ cssProperties: [
+ { name: '--ic-color-primary', default: '#0066cc', description: 'Primary brand color.' },
+ { name: '--ic-color-secondary', default: '#666', description: 'Secondary color.' },
+ { name: '--ic-spacing-base', default: '16px', description: 'Base spacing unit.' },
+ { name: '--ic-border-radius', default: '4px', description: 'Border radius.' },
+ ],
+ cssParts: [
+ { name: 'base', description: 'The root element.' },
+ { name: 'label', description: 'The label text element.' },
+ { name: 'icon', description: 'The leading icon.' },
+ ],
+};
+
+const NO_CSS_METADATA: CemDeclaration = {
+ kind: 'class',
+ name: 'NoCss',
+ tagName: 'no-css',
+ members: [{ kind: 'field', name: 'value', type: { text: 'string' } }],
+};
+
+const CSS_PROPS_ONLY: CemDeclaration = {
+ kind: 'class',
+ name: 'CssPropsOnly',
+ tagName: 'css-props-only',
+ cssProperties: [
+ { name: '--cp-color', description: 'Primary color.' },
+ { name: '--cp-size', description: 'Size value.' },
+ ],
+};
+
+const CSS_PARTS_ONLY: CemDeclaration = {
+ kind: 'class',
+ name: 'CssPartsOnly',
+ tagName: 'css-parts-only',
+ cssParts: [
+ { name: 'base', description: 'Base element.' },
+ { name: 'header', description: 'Header element.' },
+ ],
+};
+
+const BAD_TOKEN_NAMING: CemDeclaration = {
+ kind: 'class',
+ name: 'BadTokenNaming',
+ tagName: 'bad-token-naming',
+ cssProperties: [
+ { name: '--color', description: 'A color (missing prefix).' }, // no prefix
+ { name: 'noLeadingDash', description: 'Missing dashes.' }, // no -- prefix
+ { name: '--a', description: 'Too short.' }, // single letter prefix
+ { name: '--bt-color', description: 'Good naming.' }, // valid
+ ],
+};
+
+const MISSING_DESCRIPTIONS: CemDeclaration = {
+ kind: 'class',
+ name: 'MissingDescriptions',
+ tagName: 'missing-descriptions',
+ cssProperties: [
+ { name: '--md-color-primary', description: 'Primary color.' },
+ { name: '--md-color-secondary' }, // no description
+ { name: '--md-spacing-base' }, // no description
+ ],
+ cssParts: [
+ { name: 'base', description: 'Root element.' },
+ { name: 'inner' }, // no description
+ ],
+};
+
+const EMPTY_ARRAYS: CemDeclaration = {
+ kind: 'class',
+ name: 'EmptyArrays',
+ tagName: 'empty-arrays',
+ cssProperties: [],
+ cssParts: [],
+};
+
+// ─── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('analyzeCssArchitecture', () => {
+ describe('null return cases', () => {
+ it('returns null for component with no CSS metadata', () => {
+ const result = analyzeCssArchitecture(NO_CSS_METADATA);
+ expect(result).toBeNull();
+ });
+
+ it('returns null when cssProperties and cssParts are both empty', () => {
+ expect(analyzeCssArchitecture(EMPTY_ARRAYS)).toBeNull();
+ });
+
+ it('returns null when both arrays are undefined', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'NoCssAtAll',
+ tagName: 'no-css-at-all',
+ };
+ expect(analyzeCssArchitecture(decl)).toBeNull();
+ });
+ });
+
+ describe('result structure', () => {
+ it('returns score, confidence, and subMetrics', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ expect(result).not.toBeNull();
+ expect(result).toHaveProperty('score');
+ expect(result).toHaveProperty('confidence');
+ expect(result).toHaveProperty('subMetrics');
+ });
+
+ it('confidence is always heuristic', () => {
+ expect(analyzeCssArchitecture(IDEAL_CSS)!.confidence).toBe('heuristic');
+ expect(analyzeCssArchitecture(CSS_PROPS_ONLY)!.confidence).toBe('heuristic');
+ });
+
+ it('has exactly 3 sub-metrics', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ expect(result!.subMetrics).toHaveLength(3);
+ });
+
+ it('sub-metric names match expected categories', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ const names = result!.subMetrics.map((m) => m.name);
+ expect(names).toContain('CSS property descriptions');
+ expect(names).toContain('Design token naming');
+ expect(names).toContain('CSS parts documentation');
+ });
+ });
+
+ describe('ideal CSS scoring', () => {
+ it('scores 100 for fully-compliant CSS architecture', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ expect(result!.score).toBe(100);
+ });
+
+ it('scores CSS property descriptions at max when all have descriptions', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions');
+ expect(propDescMetric!.score).toBe(propDescMetric!.maxScore);
+ });
+
+ it('scores design token naming at max when all follow --prefix-name pattern', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming');
+ expect(tokenMetric!.score).toBe(tokenMetric!.maxScore);
+ });
+
+ it('scores CSS parts documentation at max when all parts have descriptions', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation');
+ expect(partsMetric!.score).toBe(partsMetric!.maxScore);
+ });
+ });
+
+ describe('design token naming validation', () => {
+ it('requires --prefix-name pattern (at least 2 segments with -)', () => {
+ const result = analyzeCssArchitecture(BAD_TOKEN_NAMING);
+ const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming');
+ // Only '--bt-color' is well-named (1 of 4)
+ // '--color' has no secondary prefix, 'noLeadingDash' fails completely, '--a' is single letter
+ expect(tokenMetric!.score).toBeLessThan(tokenMetric!.maxScore);
+ expect(tokenMetric!.score).toBeGreaterThan(0); // 1/4 valid
+ });
+
+ it('accepts multi-prefix tokens like --sl-button-color', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'MultiPrefix',
+ tagName: 'multi-prefix',
+ cssProperties: [
+ { name: '--sl-button-color', description: 'Shoelace button color.' },
+ { name: '--md-sys-color-primary', description: 'Material color token.' },
+ { name: '--hx-spacing-md', description: 'Helix spacing medium.' },
+ ],
+ };
+ const result = analyzeCssArchitecture(decl);
+ const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming');
+ expect(tokenMetric!.score).toBe(tokenMetric!.maxScore);
+ });
+
+ it('rejects properties without -- prefix', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'NoPrefix',
+ tagName: 'no-prefix',
+ cssProperties: [
+ { name: 'color', description: 'No prefix.' },
+ { name: 'background', description: 'No prefix.' },
+ ],
+ };
+ const result = analyzeCssArchitecture(decl);
+ const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming');
+ expect(tokenMetric!.score).toBe(0);
+ });
+ });
+
+ describe('missing descriptions', () => {
+ it('scores CSS property descriptions proportionally', () => {
+ const result = analyzeCssArchitecture(MISSING_DESCRIPTIONS);
+ const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions');
+ // 1 of 3 CSS properties have descriptions → round(1/3 * 35) = 12 (or 11)
+ expect(propDescMetric!.score).toBeGreaterThan(0);
+ expect(propDescMetric!.score).toBeLessThan(propDescMetric!.maxScore);
+ });
+
+ it('scores CSS parts documentation proportionally', () => {
+ const result = analyzeCssArchitecture(MISSING_DESCRIPTIONS);
+ const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation');
+ // 1 of 2 CSS parts have descriptions → round(1/2 * 35) = 18 (or 17)
+ expect(partsMetric!.score).toBeGreaterThan(0);
+ expect(partsMetric!.score).toBeLessThan(partsMetric!.maxScore);
+ });
+ });
+
+ describe('CSS properties only', () => {
+ it('returns a result when only cssProperties exist', () => {
+ const result = analyzeCssArchitecture(CSS_PROPS_ONLY);
+ expect(result).not.toBeNull();
+ });
+
+ it('scores CSS parts at 0 when no parts exist', () => {
+ const result = analyzeCssArchitecture(CSS_PROPS_ONLY);
+ const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation');
+ expect(partsMetric!.score).toBe(0);
+ });
+
+ it('normalizes score based on applicable dimensions', () => {
+ const result = analyzeCssArchitecture(CSS_PROPS_ONLY);
+ // cssProperties: all described + all well-named → 65/65 → normalized to 100
+ expect(result!.score).toBe(100);
+ });
+ });
+
+ describe('CSS parts only', () => {
+ it('returns a result when only cssParts exist', () => {
+ const result = analyzeCssArchitecture(CSS_PARTS_ONLY);
+ expect(result).not.toBeNull();
+ });
+
+ it('scores CSS properties at 0 when no properties exist', () => {
+ const result = analyzeCssArchitecture(CSS_PARTS_ONLY);
+ const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions');
+ const tokenMetric = result!.subMetrics.find((m) => m.name === 'Design token naming');
+ expect(propDescMetric!.score).toBe(0);
+ expect(tokenMetric!.score).toBe(0);
+ });
+
+ it('normalizes score based on applicable dimensions', () => {
+ const result = analyzeCssArchitecture(CSS_PARTS_ONLY);
+ // cssParts: all described → 35/35 → normalized to 100
+ expect(result!.score).toBe(100);
+ });
+ });
+
+ describe('score bounds', () => {
+ it('score is always in range [0, 100]', () => {
+ const decls = [
+ IDEAL_CSS,
+ CSS_PROPS_ONLY,
+ CSS_PARTS_ONLY,
+ BAD_TOKEN_NAMING,
+ MISSING_DESCRIPTIONS,
+ ];
+ for (const decl of decls) {
+ const result = analyzeCssArchitecture(decl);
+ if (result) {
+ expect(result.score).toBeGreaterThanOrEqual(0);
+ expect(result.score).toBeLessThanOrEqual(100);
+ }
+ }
+ });
+
+ it('sub-metric maxScore values sum to 100', () => {
+ const result = analyzeCssArchitecture(IDEAL_CSS);
+ const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0);
+ expect(maxSum).toBe(100);
+ });
+ });
+
+ describe('whitespace description handling', () => {
+ it('treats whitespace-only descriptions as missing', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'WhitespaceDesc',
+ tagName: 'whitespace-desc',
+ cssProperties: [
+ { name: '--ws-color', description: ' ' }, // whitespace only
+ { name: '--ws-bg', description: 'Valid description.' },
+ ],
+ cssParts: [
+ { name: 'base', description: '' }, // empty string
+ { name: 'inner', description: 'Inner element.' },
+ ],
+ };
+ const result = analyzeCssArchitecture(decl);
+ const propDescMetric = result!.subMetrics.find((m) => m.name === 'CSS property descriptions');
+ const partsMetric = result!.subMetrics.find((m) => m.name === 'CSS parts documentation');
+ // 1 of 2 CSS props has valid description
+ expect(propDescMetric!.score).toBeLessThan(propDescMetric!.maxScore);
+ // 1 of 2 CSS parts has valid description
+ expect(partsMetric!.score).toBeLessThan(partsMetric!.maxScore);
+ });
+ });
+});
diff --git a/tests/handlers/analyzers/event-architecture.test.ts b/tests/handlers/analyzers/event-architecture.test.ts
new file mode 100644
index 0000000..717fd02
--- /dev/null
+++ b/tests/handlers/analyzers/event-architecture.test.ts
@@ -0,0 +1,351 @@
+/**
+ * Event Architecture Analyzer — unit tests
+ *
+ * Tests analyzeEventArchitecture() covering:
+ * - Kebab-case naming convention scoring (35 pts)
+ * - Typed event payloads scoring (35 pts)
+ * - Event descriptions scoring (30 pts)
+ * - Null return for components with no events
+ * - isKebabCase validation edge cases
+ * - Mixed convention components
+ */
+
+import { describe, it, expect } from 'vitest';
+import { analyzeEventArchitecture } from '../../../packages/core/src/handlers/analyzers/event-architecture.js';
+import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js';
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const IDEAL_EVENTS: CemDeclaration = {
+ kind: 'class',
+ name: 'IdealEvents',
+ tagName: 'ideal-events',
+ events: [
+ {
+ name: 'value-change',
+ type: { text: 'CustomEvent<{ value: string }>' },
+ description: 'Fired when the value changes.',
+ },
+ {
+ name: 'menu-open',
+ type: { text: 'CustomEvent' },
+ description: 'Fired when the menu opens.',
+ },
+ {
+ name: 'item-selected',
+ type: { text: 'CustomEvent<{ item: object }>' },
+ description: 'Fired when an item is selected.',
+ },
+ ],
+};
+
+const POOR_EVENTS: CemDeclaration = {
+ kind: 'class',
+ name: 'PoorEvents',
+ tagName: 'poor-events',
+ events: [
+ { name: 'ValueChange' }, // PascalCase, no type, no desc
+ { name: 'onUpdate' }, // camelCase with 'on' prefix, no type, no desc
+ { name: 'CLICK_EVENT' }, // SCREAMING_SNAKE, no type, no desc
+ ],
+};
+
+const NO_EVENTS: CemDeclaration = {
+ kind: 'class',
+ name: 'NoEvents',
+ tagName: 'no-events',
+};
+
+const SINGLE_PERFECT_EVENT: CemDeclaration = {
+ kind: 'class',
+ name: 'SinglePerfect',
+ tagName: 'single-perfect',
+ events: [
+ {
+ name: 'sl-click',
+ type: { text: 'CustomEvent<{ originalEvent: MouseEvent }>' },
+ description: 'Emitted when the button is clicked.',
+ },
+ ],
+};
+
+const MIXED_NAMING: CemDeclaration = {
+ kind: 'class',
+ name: 'MixedNaming',
+ tagName: 'mixed-naming',
+ events: [
+ { name: 'value-change', type: { text: 'CustomEvent' }, description: 'Value changed.' },
+ { name: 'ItemClick', type: { text: 'Event' }, description: 'Item clicked.' }, // PascalCase
+ { name: 'focus', type: { text: 'CustomEvent' }, description: 'Focused.' }, // valid single-word
+ ],
+};
+
+const BARE_EVENT_TYPES: CemDeclaration = {
+ kind: 'class',
+ name: 'BareEventTypes',
+ tagName: 'bare-event-types',
+ events: [
+ { name: 'change', type: { text: 'Event' }, description: 'Changed.' },
+ { name: 'blur', type: { text: 'Event' }, description: 'Blurred.' },
+ { name: 'value-change', type: { text: 'CustomEvent' }, description: 'Value changed.' },
+ ],
+};
+
+const NO_DESCRIPTIONS: CemDeclaration = {
+ kind: 'class',
+ name: 'NoDescriptions',
+ tagName: 'no-descriptions',
+ events: [
+ { name: 'click', type: { text: 'CustomEvent' } },
+ { name: 'change', type: { text: 'CustomEvent' } },
+ { name: 'focus', type: { text: 'CustomEvent' } },
+ ],
+};
+
+// ─── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('analyzeEventArchitecture', () => {
+ describe('null return cases', () => {
+ it('returns null when no events are declared', () => {
+ const result = analyzeEventArchitecture(NO_EVENTS);
+ expect(result).toBeNull();
+ });
+
+ it('returns null when events array is empty', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'EmptyEvents',
+ tagName: 'empty-events',
+ events: [],
+ };
+ expect(analyzeEventArchitecture(decl)).toBeNull();
+ });
+
+ it('returns null when events is undefined', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'UndefinedEvents',
+ tagName: 'undefined-events',
+ };
+ expect(analyzeEventArchitecture(decl)).toBeNull();
+ });
+ });
+
+ describe('result structure', () => {
+ it('returns score, confidence, and subMetrics', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ expect(result).not.toBeNull();
+ expect(result).toHaveProperty('score');
+ expect(result).toHaveProperty('confidence');
+ expect(result).toHaveProperty('subMetrics');
+ });
+
+ it('confidence is always heuristic', () => {
+ expect(analyzeEventArchitecture(IDEAL_EVENTS)!.confidence).toBe('heuristic');
+ expect(analyzeEventArchitecture(POOR_EVENTS)!.confidence).toBe('heuristic');
+ });
+
+ it('has exactly 3 sub-metrics', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ expect(result!.subMetrics).toHaveLength(3);
+ });
+
+ it('sub-metric names match expected categories', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ const names = result!.subMetrics.map((m) => m.name);
+ expect(names).toContain('Kebab-case naming');
+ expect(names).toContain('Typed event payloads');
+ expect(names).toContain('Event descriptions');
+ });
+ });
+
+ describe('ideal events scoring', () => {
+ it('scores 100 for fully-compliant events', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ expect(result!.score).toBe(100);
+ });
+
+ it('scores kebab-case naming at max when all events use kebab-case', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(namingMetric!.maxScore);
+ });
+
+ it('scores typed payloads at max when all events have CustomEvent', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads');
+ expect(typeMetric!.score).toBe(typeMetric!.maxScore);
+ });
+
+ it('scores event descriptions at max when all events have descriptions', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ const descMetric = result!.subMetrics.find((m) => m.name === 'Event descriptions');
+ expect(descMetric!.score).toBe(descMetric!.maxScore);
+ });
+ });
+
+ describe('poor events scoring', () => {
+ it('scores 0 for events with no kebab-case, no types, no descriptions', () => {
+ const result = analyzeEventArchitecture(POOR_EVENTS);
+ expect(result!.score).toBe(0);
+ });
+
+ it('scores kebab-case naming at 0 for PascalCase events', () => {
+ const result = analyzeEventArchitecture(POOR_EVENTS);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(0);
+ });
+
+ it('scores typed payloads at 0 when no events have types', () => {
+ const result = analyzeEventArchitecture(POOR_EVENTS);
+ const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads');
+ expect(typeMetric!.score).toBe(0);
+ });
+
+ it('scores event descriptions at 0 when no events have descriptions', () => {
+ const result = analyzeEventArchitecture(POOR_EVENTS);
+ const descMetric = result!.subMetrics.find((m) => m.name === 'Event descriptions');
+ expect(descMetric!.score).toBe(0);
+ });
+ });
+
+ describe('kebab-case naming validation', () => {
+ it('accepts single lowercase words as kebab-case', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'SingleWord',
+ tagName: 'single-word',
+ events: [{ name: 'click' }, { name: 'focus' }, { name: 'change' }],
+ };
+ const result = analyzeEventArchitecture(decl);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(namingMetric!.maxScore);
+ });
+
+ it('accepts multi-segment kebab-case names', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'MultiSegment',
+ tagName: 'multi-segment',
+ events: [
+ { name: 'value-change' },
+ { name: 'menu-item-click' },
+ { name: 'form-submit' },
+ ],
+ };
+ const result = analyzeEventArchitecture(decl);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(namingMetric!.maxScore);
+ });
+
+ it('rejects PascalCase event names', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'PascalCase',
+ tagName: 'pascal-case',
+ events: [{ name: 'ValueChange' }, { name: 'MenuOpen' }],
+ };
+ const result = analyzeEventArchitecture(decl);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(0);
+ });
+
+ it('rejects camelCase event names', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'CamelCase',
+ tagName: 'camel-case',
+ events: [{ name: 'valueChange' }, { name: 'menuOpen' }],
+ };
+ const result = analyzeEventArchitecture(decl);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(0);
+ });
+
+ it('allows numbers in kebab-case segments', () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'WithNumbers',
+ tagName: 'with-numbers',
+ events: [{ name: 'step2-complete' }, { name: 'item3-click' }],
+ };
+ const result = analyzeEventArchitecture(decl);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ expect(namingMetric!.score).toBe(namingMetric!.maxScore);
+ });
+ });
+
+ describe('typed payload validation', () => {
+ it('excludes bare "Event" type as untyped', () => {
+ const result = analyzeEventArchitecture(BARE_EVENT_TYPES);
+ const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads');
+ // 1 of 3 events has proper CustomEvent, 2 have bare 'Event'
+ expect(typeMetric!.score).toBeGreaterThan(0);
+ expect(typeMetric!.score).toBeLessThan(typeMetric!.maxScore);
+ });
+
+ it('accepts CustomEvent as properly typed', () => {
+ const result = analyzeEventArchitecture(SINGLE_PERFECT_EVENT);
+ const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads');
+ expect(typeMetric!.score).toBe(typeMetric!.maxScore);
+ });
+ });
+
+ describe('no descriptions', () => {
+ it('scores event descriptions at 0 when no events have descriptions', () => {
+ const result = analyzeEventArchitecture(NO_DESCRIPTIONS);
+ const descMetric = result!.subMetrics.find((m) => m.name === 'Event descriptions');
+ expect(descMetric!.score).toBe(0);
+ });
+
+ it('still scores kebab-case and typed payloads even without descriptions', () => {
+ const result = analyzeEventArchitecture(NO_DESCRIPTIONS);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ const typeMetric = result!.subMetrics.find((m) => m.name === 'Typed event payloads');
+ expect(namingMetric!.score).toBeGreaterThan(0);
+ expect(typeMetric!.score).toBeGreaterThan(0);
+ });
+ });
+
+ describe('mixed naming conventions', () => {
+ it('scores proportionally for mixed kebab/non-kebab events', () => {
+ const result = analyzeEventArchitecture(MIXED_NAMING);
+ const namingMetric = result!.subMetrics.find((m) => m.name === 'Kebab-case naming');
+ // 2 of 3 events are kebab-case (value-change, focus); ItemClick is not
+ // round(2/3 * 35) = 23
+ expect(namingMetric!.score).toBe(23);
+ });
+ });
+
+ describe('single event component', () => {
+ it('scores 100 for a single perfectly-defined event', () => {
+ const result = analyzeEventArchitecture(SINGLE_PERFECT_EVENT);
+ expect(result!.score).toBe(100);
+ });
+ });
+
+ describe('score bounds', () => {
+ it('score is always in range [0, 100]', () => {
+ const decls = [IDEAL_EVENTS, POOR_EVENTS, MIXED_NAMING, BARE_EVENT_TYPES, NO_DESCRIPTIONS];
+ for (const decl of decls) {
+ const result = analyzeEventArchitecture(decl);
+ if (result) {
+ expect(result.score).toBeGreaterThanOrEqual(0);
+ expect(result.score).toBeLessThanOrEqual(100);
+ }
+ }
+ });
+
+ it('sub-metric maxScore values sum to 100', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ const maxSum = result!.subMetrics.reduce((acc, m) => acc + m.maxScore, 0);
+ expect(maxSum).toBe(100);
+ });
+
+ it('sub-metric scores sum to total score', () => {
+ const result = analyzeEventArchitecture(IDEAL_EVENTS);
+ const scoreSum = result!.subMetrics.reduce((acc, m) => acc + m.score, 0);
+ expect(scoreSum).toBe(result!.score);
+ });
+ });
+});
diff --git a/tests/handlers/analyzers/mixin-resolver.test.ts b/tests/handlers/analyzers/mixin-resolver.test.ts
new file mode 100644
index 0000000..449d4af
--- /dev/null
+++ b/tests/handlers/analyzers/mixin-resolver.test.ts
@@ -0,0 +1,363 @@
+/**
+ * Mixin & Inheritance Chain Resolver — unit tests
+ *
+ * Tests resolveInheritanceChain() and related helpers from mixin-resolver.ts.
+ * This module has async I/O behavior but has testable pure logic via:
+ * - resolveInheritanceChain() with inline source (no real files needed for component itself)
+ * - Chain resolution on components with no CEM-declared mixins/superclasses
+ * - Aggregation logic via the chain result
+ * - Architecture classification based on chain shape
+ *
+ * Key exports tested:
+ * - resolveInheritanceChain()
+ * - ResolvedSource type structure
+ * - InheritanceChainResult type structure
+ */
+
+import { describe, it, expect } from 'vitest';
+import { resolve } from 'node:path';
+import {
+ resolveInheritanceChain,
+ type ResolvedSource,
+ type InheritanceChainResult,
+} from '../../../packages/core/src/handlers/analyzers/mixin-resolver.js';
+import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js';
+
+// ─── Fixtures ──────────────────────────────────────────────────────────────────
+
+const WORKTREE = '/Volumes/Development/booked/helixir/.worktrees/feature-test-add-test-suites-for-8-untested';
+
+// A minimal component source with no a11y patterns
+const MINIMAL_SOURCE = `
+class MyComponent extends HTMLElement {
+ connectedCallback() {
+ this.textContent = 'Hello';
+ }
+}
+customElements.define('my-component', MyComponent);
+`;
+
+// A component source with ARIA patterns
+const ARIA_SOURCE = `
+class MyButton extends LitElement {
+ @property({ type: Boolean }) disabled = false;
+ render() {
+ return html\`\`;
+ }
+ handleKeyDown(e) {
+ if (e.key === 'Enter') this.click();
+ }
+}
+`;
+
+// A component with a form internals + focus management
+const FORM_SOURCE = `
+class MyInput extends LitElement {
+ static formAssociated = true;
+ constructor() {
+ super();
+ this.#internals = this.attachInternals();
+ }
+ focus() {
+ this.shadowRoot.querySelector('input').focus();
+ }
+ connectedCallback() {
+ super.connectedCallback();
+ this.setAttribute('tabindex', '0');
+ }
+}
+`;
+
+// A component that imports an a11y-relevant mixin
+const MIXIN_IMPORT_SOURCE = `
+import { FocusMixin } from './focus-mixin.js';
+import { KeyboardMixin } from './keyboard-mixin.js';
+
+class MyDropdown extends FocusMixin(KeyboardMixin(HTMLElement)) {
+ connectedCallback() {
+ this.setAttribute('role', 'listbox');
+ }
+}
+`;
+
+// A simple component declaration (no inheritance chain in CEM)
+const SIMPLE_DECL: CemDeclaration = {
+ kind: 'class',
+ name: 'MyComponent',
+ tagName: 'my-component',
+};
+
+const BUTTON_DECL: CemDeclaration = {
+ kind: 'class',
+ name: 'MyButton',
+ tagName: 'my-button',
+};
+
+const FORM_DECL: CemDeclaration = {
+ kind: 'class',
+ name: 'MyInput',
+ tagName: 'my-input',
+};
+
+// ─── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('resolveInheritanceChain', () => {
+ describe('basic chain resolution', () => {
+ it('resolves a component with no inheritance chain', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ expect(chain).toBeDefined();
+ expect(chain.sources.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('always includes the component itself as first source', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ const first = chain.sources[0];
+ expect(first!.type).toBe('component');
+ expect(first!.name).toBe('MyComponent');
+ });
+
+ it('includes component source content', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ const componentSource = chain.sources.find((s) => s.type === 'component');
+ expect(componentSource!.content).toBe(MINIMAL_SOURCE);
+ });
+ });
+
+ describe('result structure', () => {
+ it('returns InheritanceChainResult with all required fields', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ expect(chain).toHaveProperty('sources');
+ expect(chain).toHaveProperty('aggregatedMarkers');
+ expect(chain).toHaveProperty('resolvedCount');
+ expect(chain).toHaveProperty('unresolved');
+ expect(chain).toHaveProperty('architecture');
+ });
+
+ it('resolvedCount equals sources array length', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ expect(chain.resolvedCount).toBe(chain.sources.length);
+ });
+
+ it('unresolved is an array', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ expect(Array.isArray(chain.unresolved)).toBe(true);
+ });
+
+ it('architecture is one of the expected values', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ expect(['inline', 'mixin-heavy', 'controller-based', 'hybrid']).toContain(
+ chain.architecture,
+ );
+ });
+ });
+
+ describe('aggregated markers', () => {
+ it('aggregated markers reflect component source patterns', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ // MINIMAL_SOURCE has no a11y patterns
+ expect(chain.aggregatedMarkers.ariaBindings).toBe(false);
+ expect(chain.aggregatedMarkers.roleAssignments).toBe(false);
+ expect(chain.aggregatedMarkers.keyboardHandling).toBe(false);
+ });
+
+ it('aggregated markers detect aria patterns in component source', async () => {
+ const chain = await resolveInheritanceChain(
+ ARIA_SOURCE,
+ resolve(WORKTREE, 'src/my-button.ts'),
+ BUTTON_DECL,
+ WORKTREE,
+ );
+ expect(chain.aggregatedMarkers.ariaBindings).toBe(true);
+ expect(chain.aggregatedMarkers.keyboardHandling).toBe(true);
+ });
+
+ it('aggregated markers detect form internals and focus in component source', async () => {
+ const chain = await resolveInheritanceChain(
+ FORM_SOURCE,
+ resolve(WORKTREE, 'src/my-input.ts'),
+ FORM_DECL,
+ WORKTREE,
+ );
+ expect(chain.aggregatedMarkers.formInternals).toBe(true);
+ expect(chain.aggregatedMarkers.focusManagement).toBe(true);
+ });
+
+ it('aggregated markers have all 7 keys', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ expect(Object.keys(chain.aggregatedMarkers)).toHaveLength(7);
+ });
+ });
+
+ describe('architecture classification', () => {
+ it('classifies single-file component as "inline"', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ // No mixins resolved → inline
+ expect(chain.architecture).toBe('inline');
+ });
+
+ it('classifies component with all a11y inline as "inline"', async () => {
+ const chain = await resolveInheritanceChain(
+ ARIA_SOURCE,
+ resolve(WORKTREE, 'src/my-button.ts'),
+ BUTTON_DECL,
+ WORKTREE,
+ );
+ // Component has all patterns, no external mixins resolved
+ expect(chain.architecture).toBe('inline');
+ });
+ });
+
+ describe('each ResolvedSource structure', () => {
+ it('component source has correct ResolvedSource structure', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ const src = chain.sources[0]!;
+ expect(src).toHaveProperty('name');
+ expect(src).toHaveProperty('type');
+ expect(src).toHaveProperty('filePath');
+ expect(src).toHaveProperty('content');
+ expect(src).toHaveProperty('markers');
+ });
+
+ it('component source markers are a valid SourceA11yMarkers object', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ );
+ const markers = chain.sources[0]!.markers;
+ const expectedKeys = [
+ 'ariaBindings',
+ 'roleAssignments',
+ 'keyboardHandling',
+ 'focusManagement',
+ 'formInternals',
+ 'liveRegions',
+ 'screenReaderSupport',
+ ];
+ for (const key of expectedKeys) {
+ expect(markers).toHaveProperty(key);
+ expect(typeof markers[key as keyof typeof markers]).toBe('boolean');
+ }
+ });
+ });
+
+ describe('CEM-declared superclass with no module path', () => {
+ it('silently skips framework base classes like LitElement', async () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'MyButton',
+ tagName: 'my-button',
+ superclass: { name: 'LitElement' }, // framework base — skipped by getInheritanceChain
+ };
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ decl,
+ WORKTREE,
+ );
+ // LitElement is a framework base class — getInheritanceChain() skips it entirely.
+ // It does NOT appear in unresolved; the chain simply has no superclass entry.
+ expect(chain.unresolved).not.toContain('LitElement');
+ expect(chain.sources.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('adds unresolved entry when a non-framework superclass has no module path', async () => {
+ const decl: CemDeclaration = {
+ kind: 'class',
+ name: 'MyButton',
+ tagName: 'my-button',
+ superclass: { name: 'BaseButton' }, // custom base class, no module path
+ };
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ decl,
+ WORKTREE,
+ );
+ // BaseButton has no module path → gets added to chain with modulePath=null → goes to unresolved
+ expect(chain.unresolved).toContain('BaseButton');
+ });
+ });
+
+ describe('maxDepth parameter', () => {
+ it('accepts maxDepth parameter without error', async () => {
+ await expect(
+ resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ 0, // depth 0 = no import following
+ ),
+ ).resolves.toBeDefined();
+ });
+
+ it('depth 0 still resolves the component itself', async () => {
+ const chain = await resolveInheritanceChain(
+ MINIMAL_SOURCE,
+ resolve(WORKTREE, 'src/my-component.ts'),
+ SIMPLE_DECL,
+ WORKTREE,
+ 0,
+ );
+ expect(chain.sources.length).toBeGreaterThanOrEqual(1);
+ expect(chain.sources[0]!.type).toBe('component');
+ });
+ });
+});
diff --git a/tests/handlers/analyzers/naming-consistency.test.ts b/tests/handlers/analyzers/naming-consistency.test.ts
new file mode 100644
index 0000000..ba02344
--- /dev/null
+++ b/tests/handlers/analyzers/naming-consistency.test.ts
@@ -0,0 +1,542 @@
+/**
+ * Naming Consistency Analyzer — unit tests (analyzers/ subdirectory)
+ *
+ * Tests all exported functions from naming-consistency.ts covering:
+ * - detectLibraryEventPrefix()
+ * - detectLibraryCssPrefix()
+ * - detectLibraryConventions()
+ * - scoreEventPrefixCoherence()
+ * - scorePropertyNamingConsistency()
+ * - scoreCSSCustomPropertyPrefixing()
+ * - scoreAttributePropertyCoherence()
+ * - analyzeNamingConsistency()
+ *
+ * Additional edge cases beyond tests/handlers/naming-consistency.test.ts:
+ * - snake_case properties detected as alternate convention
+ * - Confidence level logic
+ * - Normalization when dimensions are excluded
+ */
+
+import { describe, it, expect } from 'vitest';
+import {
+ analyzeNamingConsistency,
+ detectLibraryConventions,
+ detectLibraryEventPrefix,
+ detectLibraryCssPrefix,
+ scoreEventPrefixCoherence,
+ scorePropertyNamingConsistency,
+ scoreCSSCustomPropertyPrefixing,
+ scoreAttributePropertyCoherence,
+ type LibraryNamingConventions,
+} from '../../../packages/core/src/handlers/analyzers/naming-consistency.js';
+import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js';
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+function makeDecl(overrides: Partial = {}): CemDeclaration {
+ return {
+ kind: 'class',
+ name: 'TestComponent',
+ tagName: 'test-component',
+ ...overrides,
+ } as CemDeclaration;
+}
+
+const NO_PREFIX_CONVENTIONS: LibraryNamingConventions = {
+ eventPrefix: null,
+ eventPrefixConfidence: 0,
+ cssPrefix: null,
+ cssPrefixConfidence: 0,
+};
+
+const HX_CONVENTIONS: LibraryNamingConventions = {
+ eventPrefix: 'hx-',
+ eventPrefixConfidence: 1.0,
+ cssPrefix: '--hx-',
+ cssPrefixConfidence: 1.0,
+};
+
+// ─── detectLibraryEventPrefix ─────────────────────────────────────────────────
+
+describe('detectLibraryEventPrefix', () => {
+ it('returns null prefix for empty declarations array', () => {
+ const result = detectLibraryEventPrefix([]);
+ expect(result.prefix).toBeNull();
+ expect(result.confidence).toBe(0);
+ });
+
+ it('returns null prefix when no events exist across library', () => {
+ const decls = [makeDecl({ events: [] }), makeDecl({ events: [] })];
+ const result = detectLibraryEventPrefix(decls);
+ expect(result.prefix).toBeNull();
+ });
+
+ it('detects prefix when majority of events share it', () => {
+ const decls = [
+ makeDecl({ events: [{ name: 'sl-click' }, { name: 'sl-focus' }] }),
+ makeDecl({ events: [{ name: 'sl-change' }, { name: 'sl-blur' }] }),
+ ];
+ const result = detectLibraryEventPrefix(decls);
+ expect(result.prefix).toBe('sl-');
+ expect(result.confidence).toBeGreaterThanOrEqual(0.5);
+ });
+
+ it('returns null when events have no common prefix (below 50% threshold)', () => {
+ const decls = [
+ makeDecl({ events: [{ name: 'click' }, { name: 'change' }, { name: 'sl-focus' }] }),
+ ];
+ const result = detectLibraryEventPrefix(decls);
+ // Only 1 of 3 events has 'sl-' prefix → below 50% → null
+ expect(result.prefix).toBeNull();
+ });
+
+ it('aggregates events across multiple declarations', () => {
+ const decls = [
+ makeDecl({ events: [{ name: 'ion-click' }] }),
+ makeDecl({ events: [{ name: 'ion-change' }] }),
+ makeDecl({ events: [{ name: 'ion-focus' }] }),
+ ];
+ const result = detectLibraryEventPrefix(decls);
+ expect(result.prefix).toBe('ion-');
+ });
+});
+
+// ─── detectLibraryCssPrefix ───────────────────────────────────────────────────
+
+describe('detectLibraryCssPrefix', () => {
+ it('returns null prefix for empty declarations array', () => {
+ const result = detectLibraryCssPrefix([]);
+ expect(result.prefix).toBeNull();
+ });
+
+ it('returns null prefix when no CSS properties exist', () => {
+ const decls = [makeDecl({ cssProperties: [] })];
+ const result = detectLibraryCssPrefix(decls);
+ expect(result.prefix).toBeNull();
+ });
+
+ it('detects -- prefix from CSS properties', () => {
+ const decls = [
+ makeDecl({ cssProperties: [{ name: '--sl-color-primary' }, { name: '--sl-spacing-base' }] }),
+ makeDecl({ cssProperties: [{ name: '--sl-font-size' }, { name: '--sl-border-radius' }] }),
+ ];
+ const result = detectLibraryCssPrefix(decls);
+ expect(result.prefix).toBe('--sl-');
+ });
+
+ it('adds -- prefix back to detected prefix', () => {
+ const decls = [
+ makeDecl({
+ cssProperties: [{ name: '--hx-button-color' }, { name: '--hx-button-bg' }],
+ }),
+ ];
+ const result = detectLibraryCssPrefix(decls);
+ expect(result.prefix?.startsWith('--')).toBe(true);
+ });
+});
+
+// ─── detectLibraryConventions ────────────────────────────────────────────────
+
+describe('detectLibraryConventions', () => {
+ it('detects both event and CSS prefixes together', () => {
+ const decls = [
+ makeDecl({
+ events: [{ name: 'md-click' }, { name: 'md-change' }],
+ cssProperties: [{ name: '--md-color-primary' }, { name: '--md-color-secondary' }],
+ }),
+ makeDecl({
+ events: [{ name: 'md-focus' }, { name: 'md-blur' }],
+ cssProperties: [{ name: '--md-spacing-md' }],
+ }),
+ ];
+ const result = detectLibraryConventions(decls);
+ expect(result.eventPrefix).toBe('md-');
+ expect(result.cssPrefix).toBe('--md-');
+ expect(result.eventPrefixConfidence).toBeGreaterThanOrEqual(0.5);
+ expect(result.cssPrefixConfidence).toBeGreaterThanOrEqual(0.5);
+ });
+
+ it('returns null prefixes when library has no consistent conventions', () => {
+ const decls = [
+ makeDecl({
+ events: [{ name: 'click' }, { name: 'change' }],
+ cssProperties: [],
+ }),
+ ];
+ const result = detectLibraryConventions(decls);
+ expect(result.eventPrefix).toBeNull();
+ expect(result.cssPrefix).toBeNull();
+ });
+});
+
+// ─── scoreEventPrefixCoherence ────────────────────────────────────────────────
+
+describe('scoreEventPrefixCoherence', () => {
+ it('returns null for component with no events', () => {
+ const decl = makeDecl({ events: [] });
+ expect(scoreEventPrefixCoherence(decl, 'sl-')).toBeNull();
+ });
+
+ it('returns null when events is undefined', () => {
+ const decl = makeDecl({});
+ // Default events are undefined → treated as empty
+ expect(scoreEventPrefixCoherence(decl, 'sl-')).toBeNull();
+ });
+
+ it('gives full 30 points when all events match prefix', () => {
+ const decl = makeDecl({
+ events: [{ name: 'hx-click' }, { name: 'hx-focus' }, { name: 'hx-change' }],
+ });
+ const result = scoreEventPrefixCoherence(decl, 'hx-');
+ expect(result!.score).toBe(30);
+ expect(result!.subMetric.maxScore).toBe(30);
+ });
+
+ it('gives 0 points when no events match prefix', () => {
+ const decl = makeDecl({
+ events: [{ name: 'click' }, { name: 'focus' }],
+ });
+ const result = scoreEventPrefixCoherence(decl, 'hx-');
+ expect(result!.score).toBe(0);
+ });
+
+ it('scores proportionally for partial prefix match', () => {
+ const decl = makeDecl({
+ events: [
+ { name: 'sl-click' },
+ { name: 'sl-focus' },
+ { name: 'custom-event' }, // doesn't match
+ ],
+ });
+ const result = scoreEventPrefixCoherence(decl, 'sl-');
+ // 2 of 3 match → round(2/3 * 30) = 20
+ expect(result!.score).toBe(20);
+ });
+
+ it('gives full marks when no library prefix is detected (no penalty)', () => {
+ const decl = makeDecl({ events: [{ name: 'click' }, { name: 'change' }] });
+ const result = scoreEventPrefixCoherence(decl, null);
+ expect(result!.score).toBe(30);
+ expect(result!.subMetric.note).toContain('not scored');
+ });
+
+ it('subMetric name is "Event prefix coherence"', () => {
+ const decl = makeDecl({ events: [{ name: 'hx-click' }] });
+ const result = scoreEventPrefixCoherence(decl, 'hx-');
+ expect(result!.subMetric.name).toBe('Event prefix coherence');
+ });
+});
+
+// ─── scorePropertyNamingConsistency ──────────────────────────────────────────
+
+describe('scorePropertyNamingConsistency', () => {
+ it('gives full 25 points for components with no fields', () => {
+ const decl = makeDecl({ members: [] });
+ const result = scorePropertyNamingConsistency(decl);
+ expect(result.score).toBe(25);
+ expect(result.subMetric.note).toContain('trivially');
+ });
+
+ it('gives full 25 for all camelCase properties', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'value' },
+ { kind: 'field', name: 'isDisabled' },
+ { kind: 'field', name: 'maxLength' },
+ ],
+ });
+ const result = scorePropertyNamingConsistency(decl);
+ expect(result.score).toBe(25);
+ });
+
+ it('gives full 25 for all snake_case properties (alternate valid convention)', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'is_disabled' },
+ { kind: 'field', name: 'max_length' },
+ { kind: 'field', name: 'default_value' },
+ ],
+ });
+ const result = scorePropertyNamingConsistency(decl);
+ // All snake_case → consistent → full score
+ expect(result.score).toBe(25);
+ });
+
+ it('scores mixed conventions proportionally using dominant convention', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'value' }, // camelCase (single word)
+ { kind: 'field', name: 'maxLength' }, // camelCase
+ { kind: 'field', name: 'is_broken' }, // snake_case
+ { kind: 'field', name: 'CONSTANT' }, // neither (all caps)
+ ],
+ });
+ const result = scorePropertyNamingConsistency(decl);
+ // 2 camelCase, 1 snake_case, 1 neither → camelCase dominant → 2/4 consistent
+ // round(2/4 * 25) = 13
+ expect(result.score).toBe(13);
+ });
+
+ it('treats single-word lowercase names as camelCase', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'value' },
+ { kind: 'field', name: 'label' },
+ { kind: 'field', name: 'open' },
+ ],
+ });
+ const result = scorePropertyNamingConsistency(decl);
+ expect(result.score).toBe(25);
+ });
+
+ it('subMetric name is "Property naming consistency"', () => {
+ const decl = makeDecl({ members: [{ kind: 'field', name: 'value' }] });
+ const result = scorePropertyNamingConsistency(decl);
+ expect(result.subMetric.name).toBe('Property naming consistency');
+ });
+
+ it('ignores method members (only scores fields)', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'method', name: 'RESET' }, // method with bad casing
+ { kind: 'field', name: 'value' }, // camelCase field
+ ],
+ });
+ const result = scorePropertyNamingConsistency(decl);
+ // Only 1 field exists, it's camelCase → 25/25
+ expect(result.score).toBe(25);
+ });
+});
+
+// ─── scoreCSSCustomPropertyPrefixing ─────────────────────────────────────────
+
+describe('scoreCSSCustomPropertyPrefixing', () => {
+ it('returns null for component with no CSS properties', () => {
+ const decl = makeDecl({ cssProperties: [] });
+ expect(scoreCSSCustomPropertyPrefixing(decl, '--hx-')).toBeNull();
+ });
+
+ it('gives full 25 when all CSS properties match prefix', () => {
+ const decl = makeDecl({
+ cssProperties: [{ name: '--hx-color-primary' }, { name: '--hx-spacing-lg' }],
+ });
+ const result = scoreCSSCustomPropertyPrefixing(decl, '--hx-');
+ expect(result!.score).toBe(25);
+ });
+
+ it('gives 0 when no CSS properties match prefix', () => {
+ const decl = makeDecl({
+ cssProperties: [{ name: '--other-color' }, { name: '--wrong-spacing' }],
+ });
+ const result = scoreCSSCustomPropertyPrefixing(decl, '--hx-');
+ expect(result!.score).toBe(0);
+ });
+
+ it('gives full marks when no CSS prefix detected (no penalty)', () => {
+ const decl = makeDecl({ cssProperties: [{ name: '--color-primary' }] });
+ const result = scoreCSSCustomPropertyPrefixing(decl, null);
+ expect(result!.score).toBe(25);
+ expect(result!.subMetric.note).toContain('not scored');
+ });
+
+ it('scores proportionally for partial prefix match', () => {
+ const decl = makeDecl({
+ cssProperties: [
+ { name: '--sl-color-primary' },
+ { name: '--sl-spacing-base' },
+ { name: '--custom-override' }, // doesn't match
+ ],
+ });
+ const result = scoreCSSCustomPropertyPrefixing(decl, '--sl-');
+ // 2 of 3 match → round(2/3 * 25) = 17
+ expect(result!.score).toBe(17);
+ });
+});
+
+// ─── scoreAttributePropertyCoherence ─────────────────────────────────────────
+
+describe('scoreAttributePropertyCoherence', () => {
+ it('gives full 20 points when no attribute-mapped properties exist', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'value' }, // no attribute
+ ],
+ });
+ const result = scoreAttributePropertyCoherence(decl);
+ expect(result.score).toBe(20);
+ expect(result.subMetric.note).toContain('trivially');
+ });
+
+ it('gives full 20 for correct kebab-case attribute mappings', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'maxLength', attribute: 'max-length' },
+ { kind: 'field', name: 'isDisabled', attribute: 'is-disabled' },
+ { kind: 'field', name: 'value', attribute: 'value' }, // single word
+ ],
+ });
+ const result = scoreAttributePropertyCoherence(decl);
+ expect(result.score).toBe(20);
+ });
+
+ it('gives 0 for completely incoherent attribute mappings', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'maxLength', attribute: 'maxlength' }, // should be max-length
+ { kind: 'field', name: 'isDisabled', attribute: 'disabled' }, // should be is-disabled
+ ],
+ });
+ const result = scoreAttributePropertyCoherence(decl);
+ expect(result.score).toBe(0);
+ });
+
+ it('scores proportionally for mixed coherence', () => {
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'value', attribute: 'value' }, // correct
+ { kind: 'field', name: 'maxLength', attribute: 'max-length' }, // correct
+ { kind: 'field', name: 'isOpen', attribute: 'isopen' }, // incorrect
+ { kind: 'field', name: 'onClick', attribute: 'onclick' }, // incorrect
+ ],
+ });
+ const result = scoreAttributePropertyCoherence(decl);
+ // 2 of 4 coherent → round(2/4 * 20) = 10
+ expect(result.score).toBe(10);
+ });
+
+ it('subMetric name is "Attribute-property coherence"', () => {
+ const decl = makeDecl({ members: [{ kind: 'field', name: 'value', attribute: 'value' }] });
+ const result = scoreAttributePropertyCoherence(decl);
+ expect(result.subMetric.name).toBe('Attribute-property coherence');
+ });
+});
+
+// ─── analyzeNamingConsistency ────────────────────────────────────────────────
+
+describe('analyzeNamingConsistency', () => {
+ it('returns a result with score, confidence, subMetrics', () => {
+ const decl = makeDecl({
+ events: [{ name: 'hx-click' }],
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+ const result = analyzeNamingConsistency(decl, HX_CONVENTIONS);
+ expect(result).not.toBeNull();
+ expect(result).toHaveProperty('score');
+ expect(result).toHaveProperty('confidence');
+ expect(result).toHaveProperty('subMetrics');
+ });
+
+ it('scores 100 for fully consistent component with known conventions', () => {
+ const decl = makeDecl({
+ events: [{ name: 'hx-click' }, { name: 'hx-focus' }],
+ members: [
+ { kind: 'field', name: 'value', attribute: 'value' },
+ { kind: 'field', name: 'isDisabled', attribute: 'is-disabled' },
+ ],
+ cssProperties: [{ name: '--hx-button-color' }, { name: '--hx-button-bg' }],
+ });
+ const result = analyzeNamingConsistency(decl, HX_CONVENTIONS);
+ expect(result!.score).toBe(100);
+ });
+
+ it('scores low for inconsistent component with known conventions', () => {
+ const decl = makeDecl({
+ events: [{ name: 'CLICK' }, { name: 'FOCUS' }], // no hx- prefix
+ members: [
+ { kind: 'field', name: 'IS_VALUE', attribute: 'IS_VALUE' }, // inconsistent
+ ],
+ cssProperties: [{ name: '--wrong-prefix-color' }], // no hx- prefix
+ });
+ const result = analyzeNamingConsistency(decl, HX_CONVENTIONS);
+ expect(result!.score).toBeLessThan(30);
+ });
+
+ it('assigns verified confidence when no prefix conventions exist', () => {
+ // With no prefix to detect, it's pure naming analysis → verified
+ const decl = makeDecl({
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+ const result = analyzeNamingConsistency(decl, NO_PREFIX_CONVENTIONS);
+ expect(result!.confidence).toBe('verified');
+ });
+
+ it('assigns verified confidence when prefix confidence is high (> 0.7)', () => {
+ const decl = makeDecl({
+ events: [{ name: 'hx-click' }],
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+ const highConfConventions: LibraryNamingConventions = {
+ ...HX_CONVENTIONS,
+ eventPrefixConfidence: 0.9,
+ };
+ const result = analyzeNamingConsistency(decl, highConfConventions);
+ expect(result!.confidence).toBe('verified');
+ });
+
+ it('assigns heuristic confidence when prefix confidence is medium (0-0.7)', () => {
+ const decl = makeDecl({
+ events: [{ name: 'hx-click' }],
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+ const medConfConventions: LibraryNamingConventions = {
+ eventPrefix: 'hx-',
+ eventPrefixConfidence: 0.6,
+ cssPrefix: null,
+ cssPrefixConfidence: 0,
+ };
+ const result = analyzeNamingConsistency(decl, medConfConventions);
+ expect(result!.confidence).toBe('heuristic');
+ });
+
+ it('normalizes score to 0-100 when some dimensions are excluded', () => {
+ // No events, no CSS → only property naming (25) + attribute coherence (20) apply
+ const decl = makeDecl({
+ members: [
+ { kind: 'field', name: 'value', attribute: 'value' },
+ { kind: 'field', name: 'label', attribute: 'label' },
+ ],
+ });
+ const result = analyzeNamingConsistency(decl, NO_PREFIX_CONVENTIONS);
+ expect(result!.score).toBeGreaterThanOrEqual(0);
+ expect(result!.score).toBeLessThanOrEqual(100);
+ expect(result!.score).toBe(100); // both dimensions fully satisfied
+ });
+
+ it('includes event prefix sub-metric only when events exist', () => {
+ const noEventDecl = makeDecl({
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+ const withEventDecl = makeDecl({
+ events: [{ name: 'hx-click' }],
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+
+ const noEventResult = analyzeNamingConsistency(noEventDecl, HX_CONVENTIONS);
+ const withEventResult = analyzeNamingConsistency(withEventDecl, HX_CONVENTIONS);
+
+ const noEventNames = noEventResult!.subMetrics.map((m) => m.name);
+ const withEventNames = withEventResult!.subMetrics.map((m) => m.name);
+
+ expect(noEventNames).not.toContain('Event prefix coherence');
+ expect(withEventNames).toContain('Event prefix coherence');
+ });
+
+ it('includes CSS prefix sub-metric only when CSS properties exist', () => {
+ const noCssDecl = makeDecl({
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+ const withCssDecl = makeDecl({
+ cssProperties: [{ name: '--hx-color' }],
+ members: [{ kind: 'field', name: 'value', attribute: 'value' }],
+ });
+
+ const noCssResult = analyzeNamingConsistency(noCssDecl, HX_CONVENTIONS);
+ const withCssResult = analyzeNamingConsistency(withCssDecl, HX_CONVENTIONS);
+
+ const noCssNames = noCssResult!.subMetrics.map((m) => m.name);
+ const withCssNames = withCssResult!.subMetrics.map((m) => m.name);
+
+ expect(noCssNames).not.toContain('CSS custom property prefixing');
+ expect(withCssNames).toContain('CSS custom property prefixing');
+ });
+});
diff --git a/tests/handlers/analyzers/slot-architecture.test.ts b/tests/handlers/analyzers/slot-architecture.test.ts
new file mode 100644
index 0000000..dde7480
--- /dev/null
+++ b/tests/handlers/analyzers/slot-architecture.test.ts
@@ -0,0 +1,376 @@
+/**
+ * Slot Architecture Analyzer — unit tests (analyzers/ subdirectory)
+ *
+ * Tests analyzeSlotArchitecture() covering additional edge cases beyond
+ * the existing tests/handlers/slot-architecture.test.ts:
+ * - Default slot scoring (25 pts)
+ * - Named slot documentation (30 pts)
+ * - Slot type constraints (20 pts)
+ * - Slot-property coherence (25 pts)
+ * - kebab-to-camel name resolution for coherence pairs
+ * - jsdocTags @slot annotation detection
+ * - Multiple coherence pairs with partial scoring
+ */
+
+import { describe, it, expect } from 'vitest';
+import { analyzeSlotArchitecture } from '../../../packages/core/src/handlers/analyzers/slot-architecture.js';
+import type { CemDeclaration } from '../../../packages/core/src/handlers/cem.js';
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const DEFAULT_SLOT_WITH_DESC: CemDeclaration = {
+ kind: 'class',
+ name: 'DefaultWithDesc',
+ tagName: 'default-with-desc',
+ slots: [{ name: '', description: 'Main content area.' }],
+};
+
+const DEFAULT_SLOT_NO_DESC: CemDeclaration = {
+ kind: 'class',
+ name: 'DefaultNoDesc',
+ tagName: 'default-no-desc',
+ slots: [{ name: '' }],
+};
+
+const NAMED_DEFAULT_SLOT: CemDeclaration = {
+ kind: 'class',
+ name: 'NamedDefault',
+ tagName: 'named-default',
+ slots: [{ name: 'default', description: 'Default content using named "default" slot.' }],
+};
+
+const FULLY_DOCUMENTED_SLOTS: CemDeclaration = {
+ kind: 'class',
+ name: 'FullyDocumented',
+ tagName: 'fully-documented',
+ slots: [
+ { name: '', description: 'Primary content.' },
+ { name: 'header', description: 'The header section.' },
+ { name: 'footer', description: 'The footer section.' },
+ { name: 'aside', description: 'Supplemental content.' },
+ ],
+ members: [
+ { kind: 'field', name: 'header', type: { text: 'string' }, description: 'Header text.' },
+ { kind: 'field', name: 'footer', type: { text: 'string' }, description: 'Footer text.' },
+ ],
+};
+
+const JSDOC_SLOT_DECL: CemDeclaration = {
+ kind: 'class',
+ name: 'JsdocSlots',
+ tagName: 'jsdoc-slots',
+ description: 'Component with JSDoc @slot annotations.',
+ jsdocTags: [
+ {
+ name: 'slot',
+ description: 'icon - An