Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,7 @@ Place this file at the root of your component library project (or wherever `MCP_
| `tokensPath` | `string \| null` | `null` | Path to a design tokens JSON file. Set to `null` to disable token tools. |
| `cdnBase` | `string \| null` | `null` | Base URL prepended to component paths when generating CDN `<script>` and `<link>` tags in `suggest_usage` output (e.g. `"https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2/cdn"`). Does not affect `resolve_cdn_cem`. Set to `null` to disable CDN suggestions. |
| `watch` | `boolean` | `false` | When `true`, HELiXiR automatically reloads the CEM on file changes. |
| `scoring` | `object` | `undefined` | Optional scoring configuration for customizing health dimension weights. See [Configurable Health Scoring Weights](#configurable-health-scoring-weights). |

**Full example:**

Expand All @@ -470,6 +471,72 @@ Place this file at the root of your component library project (or wherever `MCP_
}
```

### Configurable Health Scoring Weights

Enterprise teams have different priorities. A design system team may weight accessibility at 3× while a rapid-prototyping team may treat it as lower priority. The `scoring.weights` config section lets you adjust per-dimension weight multipliers:

```json
{
"scoring": {
"weights": {
"documentation": 1.0,
"accessibility": 1.5,
"naming": 1.0,
"apiConsistency": 1.0,
"cssArchitecture": 1.0,
"cemSourceFidelity": 0.5
}
}
}
```

Each value is a **positive multiplier** applied to that dimension's base weight (e.g. `1.5` = 50% more influence; `0.5` = half influence). Omitted keys default to `1.0` (unchanged). Setting a key to `0` or a negative number is rejected with a warning.

**Supported keys and their dimensions:**

| Config Key | Health Dimension | Default Weight |
| ------------------- | ------------------------ | -------------- |
| `documentation` | CEM Completeness | 15 |
| `accessibility` | Accessibility | 10 |
| `typeCoverage` | Type Coverage | 10 |
| `apiConsistency` | API Surface Quality | 10 |
| `cemSourceFidelity` | CEM-Source Fidelity | 10 |
| `testCoverage` | Test Coverage | 10 |
| `cssArchitecture` | CSS Architecture | 5 |
| `eventArchitecture` | Event Architecture | 5 |
| `slotArchitecture` | Slot Architecture | 5 |
| `bundleSize` | Bundle Size | 5 |
| `storyCoverage` | Story Coverage | 5 |
| `naming` | Naming Consistency | 5 |
| `performance` | Performance | 5 |
| `drupalReadiness` | Drupal Readiness | 5 |

**Accessibility-first team example:**
```json
{
"scoring": {
"weights": {
"accessibility": 3.0,
"testCoverage": 2.0,
"cemSourceFidelity": 0.5
}
}
}
```

**Rapid-prototyping team example:**
```json
{
"scoring": {
"weights": {
"documentation": 0.5,
"testCoverage": 0.5,
"accessibility": 0.5
}
}
}
```

### Environment Variables

Environment variables override all config file values. Useful for CI or when pointing the same server at different libraries.
Expand Down
86 changes: 86 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@ import { readFileSync, existsSync } from 'fs';
import { resolve } from 'path';
import { discoverCemPath, FRIENDLY_CEM_ERROR } from './shared/discovery.js';

/**
* Weight multipliers for each health scoring dimension.
* Each key maps to a dimension in the DIMENSION_REGISTRY.
* Values are positive multipliers (1.0 = base weight, 2.0 = double, 0.5 = half).
*/
export interface ScoringWeights {
/** CEM Completeness dimension */
readonly documentation?: number;
/** Accessibility dimension */
readonly accessibility?: number;
/** Type Coverage dimension */
readonly typeCoverage?: number;
/** API Surface Quality dimension */
readonly apiConsistency?: number;
/** CSS Architecture dimension */
readonly cssArchitecture?: number;
/** Event Architecture dimension */
readonly eventArchitecture?: number;
/** Test Coverage dimension */
readonly testCoverage?: number;
/** Bundle Size dimension */
readonly bundleSize?: number;
/** Story Coverage dimension */
readonly storyCoverage?: number;
/** Performance dimension */
readonly performance?: number;
/** Drupal Readiness dimension */
readonly drupalReadiness?: number;
/** CEM-Source Fidelity dimension */
readonly cemSourceFidelity?: number;
/** Slot Architecture dimension */
readonly slotArchitecture?: number;
/** Naming Consistency dimension */
readonly naming?: number;
}

/**
* Enterprise scoring configuration.
* Allows teams to adjust dimension weights to match their priorities.
*/
export interface ScoringConfig {
/** Per-dimension weight multipliers. Missing keys default to 1.0. */
readonly weights?: ScoringWeights;
}

export interface McpWcConfig {
readonly cemPath: string;
readonly projectRoot: string;
Expand All @@ -13,11 +58,44 @@ export interface McpWcConfig {
readonly cdnAutoloader?: string | null;
readonly cdnStylesheet?: string | null;
readonly watch: boolean;
/** Optional scoring configuration for customizing health dimension weights. */
readonly scoring?: ScoringConfig;
}

/** Mutable version used internally during config construction. */
type McpWcConfigMutable = { -readonly [K in keyof McpWcConfig]: McpWcConfig[K] };

/**
* Validates and sanitizes the scoring.weights section from config file.
* Invalid values (non-positive numbers, non-numbers) are discarded with a warning.
*/
function validateScoringWeights(raw: unknown): ScoringWeights | undefined {
if (typeof raw !== 'object' || raw === null) return undefined;
const result: Record<string, number> = {};
for (const [key, val] of Object.entries(raw as Record<string, unknown>)) {
if (typeof val === 'number' && val > 0) {
result[key] = val;
} else if (val !== undefined) {
process.stderr.write(
`[helixir] Warning: scoring.weights.${key} must be a positive number. Ignoring.\n`,
);
}
}
return Object.keys(result).length > 0 ? (result as ScoringWeights) : undefined;
}

/**
* Parses the scoring section from the config file.
* Always returns a ScoringConfig object (never undefined) when called.
*/
function parseScoringConfig(raw: unknown): ScoringConfig {
if (typeof raw !== 'object' || raw === null) return {};
const scoringRaw = raw as Record<string, unknown>;
if (scoringRaw['weights'] === undefined) return {};
const weights = validateScoringWeights(scoringRaw['weights']);
return weights !== undefined ? { weights } : {};
}

const defaults: McpWcConfig = {
cemPath: 'custom-elements.json',
projectRoot: process.cwd(),
Expand Down Expand Up @@ -61,8 +139,16 @@ export function loadConfig(): Readonly<McpWcConfig> {
// Prevent config file from overriding projectRoot (circular dependency).
const safeFileConfig: Omit<Partial<McpWcConfig>, 'projectRoot'> = { ...fileConfig };
delete (safeFileConfig as Record<string, unknown>)['projectRoot'];
// scoring needs special validation — extract before mass-assign and apply separately
const rawScoringFromFile = (safeFileConfig as Record<string, unknown>)['scoring'];
delete (safeFileConfig as Record<string, unknown>)['scoring'];
Object.assign(config, safeFileConfig);

// Apply validated scoring config (weights must be positive numbers)
if (rawScoringFromFile !== undefined) {
config.scoring = parseScoringConfig(rawScoringFromFile);
}

// Auto-discover cemPath if not explicitly configured via env var or config file
const cemPathExplicit = process.env['MCP_WC_CEM_PATH'] !== undefined || fileCemPath !== undefined;

Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/handlers/dimensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,29 @@ export const DIMENSION_CLASSIFICATION = {

export const TOTAL_WEIGHT = DIMENSION_REGISTRY.reduce((sum, d) => sum + d.weight, 0);

/**
* Maps scoring config keys (from helixir.mcp.json `scoring.weights`)
* to their corresponding dimension names in the DIMENSION_REGISTRY.
*
* Used to apply per-enterprise weight multipliers to health scoring.
*/
export const DIMENSION_WEIGHT_KEYS: Readonly<Record<string, string>> = {
documentation: 'CEM Completeness',
accessibility: 'Accessibility',
typeCoverage: 'Type Coverage',
apiConsistency: 'API Surface Quality',
cssArchitecture: 'CSS Architecture',
eventArchitecture: 'Event Architecture',
testCoverage: 'Test Coverage',
bundleSize: 'Bundle Size',
storyCoverage: 'Story Coverage',
performance: 'Performance',
drupalReadiness: 'Drupal Readiness',
cemSourceFidelity: 'CEM-Source Fidelity',
slotArchitecture: 'Slot Architecture',
naming: 'Naming Consistency',
} as const;

// ─── Grade Thresholds ────────────────────────────────────────────────────────

interface GradeThreshold {
Expand Down
37 changes: 34 additions & 3 deletions packages/core/src/handlers/health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,14 @@ import {
} from './analyzers/naming-consistency.js';
import {
DIMENSION_REGISTRY,
DIMENSION_WEIGHT_KEYS,
calculateGrade,
computeWeightedScore,
type ConfidenceLevel,
type DimensionResult,
type SubMetric,
} from './dimensions.js';
import type { ScoringWeights } from '../config.js';

// ─── Return types ────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -740,6 +742,31 @@ function scoreCemCompleteness(decl: CemDeclaration): { score: number; subMetrics
return { score, subMetrics };
}

/**
* Returns the effective weight for a dimension, applying any multiplier
* from the config's `scoring.weights` section.
*
* @param baseWeight - The base weight from DIMENSION_REGISTRY
* @param dimensionName - The dimension name (e.g. 'CEM Completeness')
* @param weights - Optional scoring weights from config
* @returns The base weight multiplied by the configured multiplier (defaults to 1.0)
*/
function getEffectiveWeight(
baseWeight: number,
dimensionName: string,
weights: ScoringWeights | undefined,
): number {
if (!weights) return baseWeight;
// Find the config key that maps to this dimension name
for (const [key, name] of Object.entries(DIMENSION_WEIGHT_KEYS)) {
if (name === dimensionName) {
const multiplier = (weights as Record<string, number | undefined>)[key];
return multiplier !== undefined ? baseWeight * multiplier : baseWeight;
}
}
return baseWeight;
}

/**
* Scores a component across all 11 dimensions using the enterprise grade algorithm.
* CEM-native dimensions are computed from the declaration.
Expand Down Expand Up @@ -768,7 +795,11 @@ export async function scoreComponentMultiDimensional(
// No history available — external dimensions will be untested
}

const scoringWeights = config.scoring?.weights;

for (const def of DIMENSION_REGISTRY) {
const effectiveWeight = getEffectiveWeight(def.weight, def.name, scoringWeights);

if (def.source === 'cem-native') {
const result = await scoreCemNativeDimension(
def.name,
Expand All @@ -782,7 +813,7 @@ export async function scoreComponentMultiDimensional(
dimensions.push({
name: def.name,
score: result.score,
weight: def.weight,
weight: effectiveWeight,
tier: def.tier,
confidence: result.confidence,
measured: !notApplicable,
Expand All @@ -795,7 +826,7 @@ export async function scoreComponentMultiDimensional(
dimensions.push({
name: def.name,
score: historyEntry.score,
weight: def.weight,
weight: effectiveWeight,
tier: def.tier,
confidence: (historyEntry.confidence as 'verified' | 'heuristic') ?? 'verified',
measured: true,
Expand All @@ -804,7 +835,7 @@ export async function scoreComponentMultiDimensional(
dimensions.push({
name: def.name,
score: 0,
weight: def.weight,
weight: effectiveWeight,
tier: def.tier,
confidence: 'untested',
measured: false,
Expand Down
Loading
Loading