diff --git a/.nvmrc b/.nvmrc index 7d41c73..8e35034 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22.14.0 +24.14.1 diff --git a/docs/architecture-internal-design.md b/docs/architecture-internal-design.md index 0864f9c..a784e5d 100644 --- a/docs/architecture-internal-design.md +++ b/docs/architecture-internal-design.md @@ -110,7 +110,7 @@ These options control how design tokens are discovered, organised, and categoris | Option | Type | Default | Description | |--------|------|---------|-------------| | `ds.tokens.filePattern` | string | `**/semantic.css` | Glob pattern to discover token files inside `generatedStylesRoot`. | -| `ds.tokens.propertyPrefix` | string \| null | `null` | When set, only properties starting with this prefix are loaded. | +| `ds.tokens.propertyPrefix` | string \| null | `null` | When set, only properties starting with this prefix are loaded into the token dataset. **Note**: setting this to a single prefix (e.g. `--semantic-`) means the `report-audit-token-usage` tool will only validate and detect overrides for tokens matching that prefix. Leave as `null` to load all tokens and let the tool derive all prefixes automatically (e.g. `--semantic-` and `--ds-`). | | `ds.tokens.scopeStrategy` | enum | `flat` | `flat` or `brand-theme`. Controls how directory structure maps to token scope metadata. `flat`: no scope. `brand-theme`: path segments → brand/theme scope keys. | | `ds.tokens.categoryInference` | enum | `by-prefix` | `by-prefix`, `by-value`, or `none`. Controls how tokens are assigned categories. | | `ds.tokens.categoryPrefixMap` | Record | `{ color: '--semantic-color', ... }` | Category → prefix mapping (used with `by-prefix`). | diff --git a/docs/tools.md b/docs/tools.md index 697e2e6..119cc20 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -33,6 +33,26 @@ This document provides comprehensive guidance for AI agents working with Angular - With `saveAsFile: true`: File path and statistics (components, files, lines) **Best Practice**: Use `saveAsFile: true` to persist results for grouping workflows or large-scale migration planning. The saved file can be used as input for work distribution grouping tools. +#### `report-audit-token-usage` +**Purpose**: Audits design token usage in style files — validates `var()` references for typos and detects token overrides +**AI Usage**: Use to identify invalid token references (with suggestions) and find places where tokens are being overridden instead of consumed +**Key Parameters**: +- `directory`: Target directory to scan +- `modes`: `"all"` (default), `["validate"]`, or `["overrides"]` +- `brandName`: Optional — primary brand context for brand-specific token warnings +- `tokenPrefix`: Optional — overrides the prefix derived from the token dataset +- `excludePatterns`: Optional glob pattern(s) to exclude files/directories +- `saveAsFile`: Optional boolean — persists results to `tmp/.angular-toolkit-mcp/audit-token-usage/` +**Output**: +- `validate`: Invalid token references with typo suggestions (Levenshtein distance ≤ 3) and valid references +- `overrides`: Token re-declarations grouped by mechanism (`host`, `ng-deep`, `class-selector`, `root-theme`, `important`, `unknown`) with optional classification (`legitimate`, `component-override`, `deep-override`, `important-override`, `inline-override`, `scope-violation`) +- `summary`: Total issue count broken down by mode +- `diagnostics`: Warnings when modes are skipped or running in degraded state +**Modes**: +- `validate` — requires `generatedStylesRoot` to be configured; skipped with diagnostic if unavailable +- `overrides` — works without token data (detection-only); classification requires `generatedStylesRoot` +**Best Practice**: Leave `ds.tokens.propertyPrefix` as `null` in config so the tool loads all token prefixes (e.g. both `--semantic-` and `--ds-`) and validates references across all of them. Setting a single prefix limits both validation and override detection to that prefix only. + #### `get-project-dependencies` **Purpose**: Analyzes project structure, dependencies, and buildability **AI Usage**: Validate project architecture before suggesting refactoring strategies diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/audit-token-usage.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/audit-token-usage.tool.ts new file mode 100644 index 0000000..b0c7e22 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/audit-token-usage.tool.ts @@ -0,0 +1,372 @@ +import { join } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; + +import { globToRegex } from '@push-based/utils'; + +import { + createHandler, + type HandlerContext, +} from '../shared/utils/handler-helpers.js'; +import { resolveCrossPlatformPath } from '../shared/utils/cross-platform-path.js'; +import { DEFAULT_OUTPUT_BASE } from '../shared/constants.js'; +import { loadTokenDataset } from '../shared/utils/token-dataset-loader.js'; +import type { TokenDataset } from '../shared/utils/token-dataset.js'; +import { findStyleFiles } from '../project/utils/styles-report-helpers.js'; + +import { auditTokenUsageSchema } from './models/schema.js'; +import type { + AuditMode, + AuditTokenUsageOptions, + AuditTokenUsageResult, + AuditSummary, + ValidateResult, + OverridesResult, +} from './models/types.js'; +import { runValidateMode } from './utils/validate-mode.js'; +import { runOverridesMode } from './utils/overrides-mode.js'; + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const AUDIT_OUTPUT_SUBDIR = 'audit-token-usage'; + +// --------------------------------------------------------------------------- +// Exported helpers +// --------------------------------------------------------------------------- + +/** + * Derives all distinct token prefixes from the dataset. + * Extracts the leading `--segment-` from every token name and deduplicates. + * + * Example: dataset with `--semantic-color-primary` and `--ds-button-color-bg` + * returns `['--semantic-', '--ds-']`. + * + * Falls back to `['--semantic-']` if the dataset is empty or no names match. + */ +export function deriveTokenPrefixes(dataset: TokenDataset): string[] { + const prefixes = new Set(); + for (const token of dataset.tokens) { + const match = token.name.match(/^(--[\w]+-)/); + if (match) prefixes.add(match[1]); + } + return prefixes.size > 0 ? [...prefixes] : ['--semantic-']; +} + +/** + * Resolves the active audit modes from the user-provided `modes` parameter. + * Default (`undefined` or `'all'`) → both modes. + */ +export function resolveActiveModes( + modes: AuditTokenUsageOptions['modes'], +): AuditMode[] { + if (!modes || modes === 'all') { + return ['validate', 'overrides']; + } + return modes; +} + +/** + * Builds the `AuditSummary` from optional validate / overrides results. + */ +export function buildSummary( + validateResult: ValidateResult | undefined, + overridesResult: OverridesResult | undefined, +): AuditSummary { + const validateIssues = validateResult + ? validateResult.semantic.invalid.length + : 0; + const overridesIssues = overridesResult ? overridesResult.items.length : 0; + + const byMode: AuditSummary['byMode'] = {}; + if (validateResult !== undefined) byMode.validate = validateIssues; + if (overridesResult !== undefined) byMode.overrides = overridesIssues; + + return { + totalIssues: validateIssues + overridesIssues, + byMode, + }; +} + +// --------------------------------------------------------------------------- +// Exclude-pattern filtering +// --------------------------------------------------------------------------- + +function applyExcludePatterns( + files: string[], + patterns: string | string[] | undefined, +): string[] { + if (!patterns) return files; + const normalized = Array.isArray(patterns) ? patterns : [patterns]; + if (normalized.length === 0) return files; + const regexes = normalized.map((p) => globToRegex(p.replace(/\\/g, '/'))); + return files.filter( + (f) => !regexes.some((re) => re.test(f.replace(/\\/g, '/'))), + ); +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +function generateFilename(directory: string): string { + const sanitised = directory + .replace(/^\.?[/\\]+/, '') // strip leading ./ or / + .replace(/[/\\]/g, '-') + .replace(/^-+|-+$/g, ''); + return `${sanitised}-audit.json`; +} + +async function persistResult( + result: AuditTokenUsageResult, + directory: string, + cwd: string, +): Promise { + const outputDir = join(cwd, DEFAULT_OUTPUT_BASE, AUDIT_OUTPUT_SUBDIR); + const filename = generateFilename(directory); + const filePath = join(outputDir, filename); + await mkdir(outputDir, { recursive: true }); + await writeFile(filePath, JSON.stringify(result, null, 2), 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Format result (pretty-printer) +// --------------------------------------------------------------------------- + +/** + * Formats the audit result into human-readable `string[]` lines. + */ +export function formatAuditResult(result: AuditTokenUsageResult): string[] { + const lines: string[] = []; + const DIVIDER = '────────────────────────────────────────'; + + // ── Summary ── + const parts: string[] = []; + if (result.summary.byMode.validate !== undefined) { + parts.push(`${result.summary.byMode.validate} invalid token(s)`); + } + if (result.summary.byMode.overrides !== undefined) { + parts.push(`${result.summary.byMode.overrides} override(s)`); + } + lines.push( + `📊 Audit summary: ${result.summary.totalIssues} issue(s) — ${parts.join(', ')}`, + ); + + // ── Diagnostics ── + if (result.diagnostics.length > 0) { + lines.push(''); + lines.push(DIVIDER); + lines.push('⚠️ Diagnostics'); + lines.push(DIVIDER); + for (const diag of result.diagnostics) { + lines.push(` ⚠ ${diag}`); + } + } + + // ── Invalid tokens (validate mode) ── + if (result.validate) { + const { semantic } = result.validate; + + if (semantic.invalid.length > 0) { + lines.push(''); + lines.push(DIVIDER); + lines.push(`❌ Invalid semantic tokens (${semantic.invalid.length})`); + lines.push(DIVIDER); + for (const ref of semantic.invalid) { + let entry = ` ${ref.token} ${ref.file}:${ref.line}`; + if (ref.suggestion) { + entry += ` → did you mean "${ref.suggestion}"? (distance ${ref.editDistance})`; + } else { + entry += ` [not found]`; + } + lines.push(entry); + } + } + + if (result.validate.brandWarnings?.length) { + lines.push(''); + lines.push(DIVIDER); + lines.push( + `🏷️ Brand-specific warnings (${result.validate.brandWarnings.length})`, + ); + lines.push(DIVIDER); + for (const w of result.validate.brandWarnings) { + lines.push( + ` ${w.token} ${w.file}:${w.line} available in: ${w.availableBrands.join(', ')}`, + ); + } + } + } + + // ── Overrides ── + if (result.overrides && result.overrides.items.length > 0) { + lines.push(''); + lines.push(DIVIDER); + lines.push(`🔧 Token overrides (${result.overrides.items.length})`); + lines.push(DIVIDER); + for (const item of result.overrides.items) { + let entry = ` ${item.token} ${item.file}:${item.line} [${item.mechanism}]`; + if (item.classification) { + entry += ` (${item.classification})`; + } + if (item.newValue) { + const truncated = + item.newValue.length > 60 + ? item.newValue.slice(0, 57) + '...' + : item.newValue; + entry += ` → ${truncated}`; + } + lines.push(entry); + } + + lines.push(''); + lines.push(' Mechanism breakdown:'); + for (const [mechanism, count] of Object.entries( + result.overrides.byMechanism, + )) { + lines.push(` ${mechanism}: ${count}`); + } + + if (result.overrides.byClassification) { + lines.push(''); + lines.push(` Classification breakdown:`); + for (const [classification, count] of Object.entries( + result.overrides.byClassification, + )) { + lines.push(` ${classification}: ${count}`); + } + } + } + + return lines; +} + +// --------------------------------------------------------------------------- +// Main handler +// --------------------------------------------------------------------------- + +async function handleAuditTokenUsage( + params: AuditTokenUsageOptions, + context: HandlerContext, +): Promise { + // 1. Resolve active modes (default: both) + const activeModes = resolveActiveModes(params.modes); + + // 2. Discover style files + const absDir = resolveCrossPlatformPath(context.cwd, params.directory); + let styleFiles = await findStyleFiles(absDir); + styleFiles = applyExcludePatterns(styleFiles, params.excludePatterns); + + // 3. Resolve token prefix: explicit param > config > null + const configuredPrefix = + params.tokenPrefix ?? context.tokensConfig?.propertyPrefix ?? null; + + // 4. Load token dataset (if generatedStylesRoot available) + const tokenDataset = + context.generatedStylesRoot && context.tokensConfig + ? await loadTokenDataset({ + generatedStylesRoot: context.generatedStylesRoot, + workspaceRoot: context.workspaceRoot, + tokens: context.tokensConfig, + }) + : null; + + const diagnostics: string[] = []; + let validateResult: ValidateResult | undefined; + let overridesResult: OverridesResult | undefined; + + // Resolve tokenPrefixes for validate mode: explicit config takes priority, + // otherwise derive all distinct prefixes from the dataset (e.g. ['--semantic-', '--ds-']). + const tokenPrefixes: string[] | null = + configuredPrefix != null + ? [configuredPrefix] + : tokenDataset && !tokenDataset.isEmpty + ? deriveTokenPrefixes(tokenDataset) + : null; + + // Single-prefix string for overrides mode (dataset-based lookup handles multi-prefix there). + const tokenPrefix = configuredPrefix; + + // 5. Run validate mode + if (activeModes.includes('validate')) { + if (!tokenDataset) { + diagnostics.push( + 'validate mode skipped: generatedStylesRoot is not configured', + ); + } else if (tokenDataset.isEmpty) { + diagnostics.push( + tokenDataset.diagnostics[0] ?? + 'validate mode skipped: token dataset is empty', + ); + } else { + validateResult = await runValidateMode(styleFiles, tokenDataset, { + tokenPrefixes, + brandName: params.brandName, + componentName: params.componentName, + cwd: context.cwd, + }); + } + } + + // 6. Run overrides mode + if (activeModes.includes('overrides')) { + if (!tokenDataset) { + diagnostics.push( + 'overrides mode running in detection-only mode (no token dataset for classification)', + ); + } + overridesResult = await runOverridesMode(styleFiles, { + tokenDataset, + tokenPrefix, + cwd: context.cwd, + }); + } + + // 7. Check if all active modes were effectively skipped or empty + const validateSkippedOrEmpty = + !activeModes.includes('validate') || !validateResult; + const overridesSkippedOrEmpty = + !activeModes.includes('overrides') || + !overridesResult || + overridesResult.items.length === 0; + + if ( + validateSkippedOrEmpty && + overridesSkippedOrEmpty && + diagnostics.length > 0 + ) { + diagnostics.push( + 'Neither mode produced results. Configure generatedStylesRoot and tokensConfig for full audit capabilities.', + ); + } + + // 8. Build summary and assemble result + const summary = buildSummary(validateResult, overridesResult); + + const result: AuditTokenUsageResult = { + ...(validateResult && { validate: validateResult }), + ...(overridesResult && { overrides: overridesResult }), + summary, + diagnostics, + }; + + // 9. Persist if requested + if (params.saveAsFile) { + await persistResult(result, params.directory, context.cwd); + } + + return result; +} + +// --------------------------------------------------------------------------- +// Tool wiring +// --------------------------------------------------------------------------- + +export const auditTokenUsageHandler = createHandler< + AuditTokenUsageOptions, + AuditTokenUsageResult +>(auditTokenUsageSchema.name, handleAuditTokenUsage, formatAuditResult); + +export const auditTokenUsageTools = [ + { schema: auditTokenUsageSchema, handler: auditTokenUsageHandler }, +]; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/index.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/index.ts new file mode 100644 index 0000000..8d11a2a --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/index.ts @@ -0,0 +1,24 @@ +export { + auditTokenUsageTools, + auditTokenUsageHandler, + resolveActiveModes, + buildSummary, + formatAuditResult, +} from './audit-token-usage.tool.js'; + +export { auditTokenUsageSchema } from './models/schema.js'; + +export type { + AuditMode, + AuditTokenUsageOptions, + ValidTokenRef, + InvalidTokenRef, + BrandSpecificWarning, + ValidateResult, + OverrideMechanism, + OverrideClassification, + OverrideItem, + OverridesResult, + AuditSummary, + AuditTokenUsageResult, +} from './models/types.js'; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/schema.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/schema.ts new file mode 100644 index 0000000..59d6e00 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/schema.ts @@ -0,0 +1,46 @@ +import { ToolSchemaOptions } from '@push-based/models'; +import { + createProjectAnalysisSchema, + COMMON_SCHEMA_PROPERTIES, + COMMON_ANNOTATIONS, +} from '../../shared/models/schema-helpers.js'; +import { DEFAULT_OUTPUT_BASE } from '../../shared/constants.js'; + +export const auditTokenUsageSchema: ToolSchemaOptions = { + name: 'report-audit-token-usage', + description: + 'Audit token usage: validate token references (typo detection with suggestions) and detect token overrides in style files. ' + + 'Operates in two modes — "validate" (checks var() references against the token dataset) and "overrides" (finds token re-declarations in consumer files). ' + + 'Default mode "all" runs both.', + inputSchema: createProjectAnalysisSchema({ + modes: { + anyOf: [ + { + type: 'array', + items: { type: 'string', enum: ['validate', 'overrides'] }, + }, + { type: 'string', enum: ['all'] }, + ], + description: + 'Modes to run. Default: "all" (both validate and overrides).', + default: 'all', + }, + brandName: { + type: 'string', + description: 'Primary brand context for brand-specific token detection.', + }, + componentName: COMMON_SCHEMA_PROPERTIES.componentName, + tokenPrefix: { + type: 'string', + description: 'Override the default token prefix from TokensConfig.', + }, + saveAsFile: { + type: 'boolean', + description: `Persist results to ${DEFAULT_OUTPUT_BASE}/audit-token-usage/`, + }, + }), + annotations: { + title: 'Audit Token Usage', + ...COMMON_ANNOTATIONS.readOnly, + }, +}; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/types.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/types.ts new file mode 100644 index 0000000..b7443e5 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/types.ts @@ -0,0 +1,105 @@ +import type { BaseHandlerOptions } from '../../shared/utils/handler-helpers.js'; + +// ============================================================================ +// Input types +// ============================================================================ + +export type AuditMode = 'validate' | 'overrides'; + +export interface AuditTokenUsageOptions extends BaseHandlerOptions { + directory: string; + modes?: AuditMode[] | 'all'; + brandName?: string; + componentName?: string; + tokenPrefix?: string; + excludePatterns?: string | string[]; + saveAsFile?: boolean; +} + +// ============================================================================ +// Validate mode results +// ============================================================================ + +export interface ValidTokenRef { + token: string; + file: string; + line: number; +} + +export interface InvalidTokenRef { + token: string; + file: string; + line: number; + suggestion?: string; + editDistance?: number; +} + +export interface BrandSpecificWarning { + token: string; + file: string; + line: number; + availableBrands: string[]; +} + +export interface ValidateResult { + semantic: { + valid: ValidTokenRef[]; + invalid: InvalidTokenRef[]; + }; + brandWarnings?: BrandSpecificWarning[]; +} + +// ============================================================================ +// Overrides mode results +// ============================================================================ + +export type OverrideMechanism = + | 'host' + | 'ng-deep' + | 'class-selector' + | 'root-theme' + | 'important' + | 'unknown'; + +export type OverrideClassification = + | 'legitimate' + | 'component-override' + | 'inline-override' + | 'deep-override' + | 'important-override' + | 'scope-violation'; + +export interface OverrideItem { + file: string; + line: number; + token: string; + newValue: string; + originalValue?: string; + mechanism: OverrideMechanism; + classification?: OverrideClassification; +} + +export interface OverridesResult { + items: OverrideItem[]; + byMechanism: Record; + byClassification?: Record; +} + +// ============================================================================ +// Combined result +// ============================================================================ + +export interface AuditSummary { + totalIssues: number; + byMode: { + validate?: number; + overrides?: number; + }; +} + +export interface AuditTokenUsageResult { + validate?: ValidateResult; + overrides?: OverridesResult; + summary: AuditSummary; + diagnostics: string[]; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/audit-token-usage.tool.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/audit-token-usage.tool.spec.ts new file mode 100644 index 0000000..1f2ff7b --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/audit-token-usage.tool.spec.ts @@ -0,0 +1,346 @@ +import { describe, it, expect } from 'vitest'; + +import { + resolveActiveModes, + buildSummary, + formatAuditResult, +} from '../audit-token-usage.tool.js'; +import type { + AuditMode, + AuditTokenUsageResult, + ValidateResult, + OverridesResult, +} from '../models/types.js'; + +/** + * Validates: Requirements 11.1, 11.4 + * + * Tests for output correctness properties: + * - Property 6 — Output structure: result contains keys for active modes only, plus summary and diagnostics always present + * - Property 7 — Summary counts: summary.totalIssues equals sum of invalid tokens + override items, and byMode breakdown matches + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeValidateResult(semanticInvalidCount: number): ValidateResult { + return { + semantic: { + valid: [], + invalid: Array.from({ length: semanticInvalidCount }, (_, i) => ({ + token: `--semantic-invalid-${i}`, + file: `file-${i}.scss`, + line: i + 1, + })), + }, + }; +} + +function makeOverridesResult(itemCount: number): OverridesResult { + return { + items: Array.from({ length: itemCount }, (_, i) => ({ + file: `override-${i}.scss`, + line: i + 1, + token: `--ds-override-${i}`, + newValue: 'red', + mechanism: 'host' as const, + })), + byMechanism: itemCount > 0 ? { host: itemCount } : {}, + }; +} + +function assembleResult( + activeModes: AuditMode[], + validateResult?: ValidateResult, + overridesResult?: OverridesResult, + diagnostics: string[] = [], +): AuditTokenUsageResult { + const summary = buildSummary(validateResult, overridesResult); + return { + ...(activeModes.includes('validate') && + validateResult && { validate: validateResult }), + ...(activeModes.includes('overrides') && + overridesResult && { overrides: overridesResult }), + summary, + diagnostics, + }; +} + +// --------------------------------------------------------------------------- +// resolveActiveModes +// --------------------------------------------------------------------------- + +describe('resolveActiveModes', () => { + it('returns both modes for undefined input', () => { + expect(resolveActiveModes(undefined)).toEqual(['validate', 'overrides']); + }); + + it('returns both modes for "all" input', () => { + expect(resolveActiveModes('all')).toEqual(['validate', 'overrides']); + }); + + it('returns only validate when given ["validate"]', () => { + expect(resolveActiveModes(['validate'])).toEqual(['validate']); + }); + + it('returns only overrides when given ["overrides"]', () => { + expect(resolveActiveModes(['overrides'])).toEqual(['overrides']); + }); + + it('returns both modes when given ["validate", "overrides"]', () => { + expect(resolveActiveModes(['validate', 'overrides'])).toEqual([ + 'validate', + 'overrides', + ]); + }); +}); + +// --------------------------------------------------------------------------- +// Property 6 — Output structure matches active modes +// --------------------------------------------------------------------------- + +describe('Property 6: Output structure matches active modes', () => { + it('both modes active: result has validate, overrides, summary, diagnostics', () => { + const result = assembleResult( + ['validate', 'overrides'], + makeValidateResult(1), + makeOverridesResult(2), + ); + + expect(result).toHaveProperty('validate'); + expect(result).toHaveProperty('overrides'); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('diagnostics'); + }); + + it('only validate active: result has validate but not overrides', () => { + const result = assembleResult(['validate'], makeValidateResult(2)); + + expect(result).toHaveProperty('validate'); + expect(result).not.toHaveProperty('overrides'); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('diagnostics'); + }); + + it('only overrides active: result has overrides but not validate', () => { + const result = assembleResult( + ['overrides'], + undefined, + makeOverridesResult(3), + ); + + expect(result).not.toHaveProperty('validate'); + expect(result).toHaveProperty('overrides'); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('diagnostics'); + }); + + it('summary is always present even with zero issues', () => { + const result = assembleResult( + ['validate', 'overrides'], + makeValidateResult(0), + makeOverridesResult(0), + ); + + expect(result).toHaveProperty('summary'); + expect(result.summary).toHaveProperty('totalIssues'); + expect(result.summary).toHaveProperty('byMode'); + }); + + it('diagnostics is always present and is an array', () => { + const result = assembleResult( + ['validate'], + makeValidateResult(0), + undefined, + ['validate mode skipped: token dataset is empty'], + ); + + expect(result).toHaveProperty('diagnostics'); + expect(Array.isArray(result.diagnostics)).toBe(true); + }); + + it('diagnostics is an empty array when no diagnostics exist', () => { + const result = assembleResult( + ['validate', 'overrides'], + makeValidateResult(1), + makeOverridesResult(1), + ); + + expect(result.diagnostics).toEqual([]); + }); + + it('summary.byMode contains only keys for active modes with results', () => { + const validateOnly = assembleResult(['validate'], makeValidateResult(2)); + expect(validateOnly.summary.byMode).toHaveProperty('validate'); + expect(validateOnly.summary.byMode).not.toHaveProperty('overrides'); + + const overridesOnly = assembleResult( + ['overrides'], + undefined, + makeOverridesResult(3), + ); + expect(overridesOnly.summary.byMode).not.toHaveProperty('validate'); + expect(overridesOnly.summary.byMode).toHaveProperty('overrides'); + + const both = assembleResult( + ['validate', 'overrides'], + makeValidateResult(1), + makeOverridesResult(2), + ); + expect(both.summary.byMode).toHaveProperty('validate'); + expect(both.summary.byMode).toHaveProperty('overrides'); + }); +}); + +// --------------------------------------------------------------------------- +// Property 7 — Summary counts match actual issues +// --------------------------------------------------------------------------- + +describe('Property 7: Summary counts match actual issues', () => { + it('totalIssues equals invalid tokens + override items (both modes)', () => { + const validateResult = makeValidateResult(5); + const overridesResult = makeOverridesResult(4); + const summary = buildSummary(validateResult, overridesResult); + + expect(summary.totalIssues).toBe( + validateResult.semantic.invalid.length + overridesResult.items.length, + ); + expect(summary.totalIssues).toBe(9); + }); + + it('byMode.validate equals semantic.invalid count', () => { + const validateResult = makeValidateResult(7); + const summary = buildSummary(validateResult, undefined); + + expect(summary.byMode.validate).toBe( + validateResult.semantic.invalid.length, + ); + expect(summary.byMode.validate).toBe(7); + }); + + it('byMode.overrides equals overrides.items.length', () => { + const overridesResult = makeOverridesResult(5); + const summary = buildSummary(undefined, overridesResult); + + expect(summary.byMode.overrides).toBe(overridesResult.items.length); + expect(summary.byMode.overrides).toBe(5); + }); + + it('totalIssues is 0 when both modes have zero issues', () => { + const summary = buildSummary(makeValidateResult(0), makeOverridesResult(0)); + + expect(summary.totalIssues).toBe(0); + expect(summary.byMode.validate).toBe(0); + expect(summary.byMode.overrides).toBe(0); + }); + + it('totalIssues counts only validate issues when overrides is undefined', () => { + const validateResult = makeValidateResult(5); + const summary = buildSummary(validateResult, undefined); + + expect(summary.totalIssues).toBe(5); + expect(summary.byMode.validate).toBe(5); + expect(summary.byMode.overrides).toBeUndefined(); + }); + + it('totalIssues counts only overrides issues when validate is undefined', () => { + const overridesResult = makeOverridesResult(7); + const summary = buildSummary(undefined, overridesResult); + + expect(summary.totalIssues).toBe(7); + expect(summary.byMode.validate).toBeUndefined(); + expect(summary.byMode.overrides).toBe(7); + }); + + it('totalIssues is 0 when both results are undefined', () => { + const summary = buildSummary(undefined, undefined); + + expect(summary.totalIssues).toBe(0); + expect(summary.byMode.validate).toBeUndefined(); + expect(summary.byMode.overrides).toBeUndefined(); + }); + + it('byMode breakdown sums to totalIssues', () => { + const validateResult = makeValidateResult(6); + const overridesResult = makeOverridesResult(3); + const summary = buildSummary(validateResult, overridesResult); + + const byModeSum = + (summary.byMode.validate ?? 0) + (summary.byMode.overrides ?? 0); + expect(byModeSum).toBe(summary.totalIssues); + }); + + it('handles large counts correctly', () => { + const validateResult = makeValidateResult(80); + const overridesResult = makeOverridesResult(100); + const summary = buildSummary(validateResult, overridesResult); + + expect(summary.totalIssues).toBe(180); + expect(summary.byMode.validate).toBe(80); + expect(summary.byMode.overrides).toBe(100); + }); +}); + +// --------------------------------------------------------------------------- +// formatAuditResult — supplementary tests +// --------------------------------------------------------------------------- + +describe('formatAuditResult', () => { + it('includes summary line with total issues', () => { + const result = assembleResult( + ['validate', 'overrides'], + makeValidateResult(3), + makeOverridesResult(3), + ); + const lines = formatAuditResult(result); + + expect(lines[0]).toContain('Audit summary:'); + expect(lines[0]).toContain('6 issue(s)'); + }); + + it('includes invalid token count in summary line', () => { + const result = assembleResult(['validate'], makeValidateResult(3)); + const lines = formatAuditResult(result); + + expect(lines[0]).toContain('3 invalid token(s)'); + }); + + it('includes override count in summary line', () => { + const result = assembleResult( + ['overrides'], + undefined, + makeOverridesResult(5), + ); + const lines = formatAuditResult(result); + + expect(lines[0]).toContain('5 override(s)'); + }); + + it('includes diagnostics with warning prefix', () => { + const result = assembleResult(['validate'], undefined, undefined, [ + 'validate mode skipped: generatedStylesRoot is not configured', + ]); + const lines = formatAuditResult(result); + + const diagLine = lines.find( + (l) => l.includes('⚠') && l.includes('generatedStylesRoot'), + ); + expect(diagLine).toBeDefined(); + expect(diagLine).toContain('generatedStylesRoot'); + }); + + it('returns array of strings', () => { + const result = assembleResult( + ['validate', 'overrides'], + makeValidateResult(1), + makeOverridesResult(1), + ); + const lines = formatAuditResult(result); + + expect(Array.isArray(lines)).toBe(true); + for (const line of lines) { + expect(typeof line).toBe('string'); + } + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/graceful-degradation.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/graceful-degradation.spec.ts new file mode 100644 index 0000000..64f4422 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/graceful-degradation.spec.ts @@ -0,0 +1,355 @@ +import { describe, it, expect } from 'vitest'; + +import { buildSummary, formatAuditResult } from '../audit-token-usage.tool.js'; +import type { + AuditTokenUsageResult, + ValidateResult, + OverridesResult, +} from '../models/types.js'; +import { + createEmptyTokenDataset, + TokenDatasetImpl, +} from '../../shared/utils/token-dataset.js'; + +/** + * Validates: Requirements 13.1, 13.2, 13.3, 13.4, 13.5 + * + * Tests for graceful degradation behaviour: + * - undefined generatedStylesRoot → validate skipped with diagnostic + * - empty TokenDataset → validate skipped, diagnostics forwarded + * - overrides mode works without token dataset (detection-only) + * - both modes cannot run → actionable error message + */ + +// --------------------------------------------------------------------------- +// Helpers — simulate the handler's degradation logic +// --------------------------------------------------------------------------- + +/** + * Mirrors the handler's degradation logic for validate mode. + * Returns { validateResult, diagnostics } based on the token dataset state. + */ +function simulateValidateDegradation( + tokenDataset: { isEmpty: boolean; diagnostics: string[] } | null, +): { validateResult: ValidateResult | undefined; diagnostics: string[] } { + const diagnostics: string[] = []; + let validateResult: ValidateResult | undefined; + + if (!tokenDataset) { + diagnostics.push( + 'validate mode skipped: generatedStylesRoot is not configured', + ); + } else if (tokenDataset.isEmpty) { + diagnostics.push( + tokenDataset.diagnostics[0] ?? + 'validate mode skipped: token dataset is empty', + ); + } else { + // Would run validate mode — return a minimal valid result + validateResult = { + semantic: { valid: [], invalid: [] }, + }; + } + + return { validateResult, diagnostics }; +} + +/** + * Simulates the handler's degradation logic for overrides mode. + * Overrides mode always runs, but emits a diagnostic when no token dataset. + */ +function simulateOverridesDegradation( + tokenDataset: { isEmpty: boolean; diagnostics: string[] } | null, +): { overridesResult: OverridesResult; diagnostics: string[] } { + const diagnostics: string[] = []; + + if (!tokenDataset) { + diagnostics.push( + 'overrides mode running in detection-only mode (no token dataset for classification)', + ); + } + + // Overrides mode always produces a result (even if empty) + const overridesResult: OverridesResult = { + items: [], + byMechanism: {}, + }; + + return { overridesResult, diagnostics }; +} + +/** + * Assembles a full AuditTokenUsageResult from degradation simulation outputs. + */ +function assembleResult( + validateResult: ValidateResult | undefined, + overridesResult: OverridesResult | undefined, + diagnostics: string[], +): AuditTokenUsageResult { + const summary = buildSummary(validateResult, overridesResult); + return { + ...(validateResult && { validate: validateResult }), + ...(overridesResult && { overrides: overridesResult }), + summary, + diagnostics, + }; +} + +// --------------------------------------------------------------------------- +// Requirement 13.1 — undefined generatedStylesRoot → validate skipped +// --------------------------------------------------------------------------- + +describe('Requirement 13.1: validate skipped when generatedStylesRoot is undefined', () => { + it('emits diagnostic explaining generatedStylesRoot is required', () => { + const { validateResult, diagnostics } = simulateValidateDegradation(null); + + expect(validateResult).toBeUndefined(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toBe( + 'validate mode skipped: generatedStylesRoot is not configured', + ); + }); + + it('result has no validate key when generatedStylesRoot is undefined', () => { + const { validateResult, diagnostics: valDiag } = + simulateValidateDegradation(null); + const { overridesResult, diagnostics: ovDiag } = + simulateOverridesDegradation(null); + + const result = assembleResult(validateResult, overridesResult, [ + ...valDiag, + ...ovDiag, + ]); + + expect(result).not.toHaveProperty('validate'); + expect(result).toHaveProperty('summary'); + expect(result).toHaveProperty('diagnostics'); + }); + + it('formatted output includes the diagnostic with warning prefix', () => { + const result = assembleResult(undefined, undefined, [ + 'validate mode skipped: generatedStylesRoot is not configured', + ]); + const lines = formatAuditResult(result); + + const diagLine = lines.find((l) => + l.includes('generatedStylesRoot is not configured'), + ); + expect(diagLine).toBeDefined(); + expect(diagLine).toContain('⚠'); + }); +}); + +// --------------------------------------------------------------------------- +// Requirement 13.2 — empty TokenDataset → validate skipped, diagnostics forwarded +// --------------------------------------------------------------------------- + +describe('Requirement 13.2: validate skipped when TokenDataset is empty', () => { + it('forwards the dataset diagnostic when dataset is empty', () => { + const emptyDataset = createEmptyTokenDataset( + "No files matched pattern '**/semantic.css' in 'generated-styles'", + ); + + const { validateResult, diagnostics } = + simulateValidateDegradation(emptyDataset); + + expect(validateResult).toBeUndefined(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toBe( + "No files matched pattern '**/semantic.css' in 'generated-styles'", + ); + }); + + it('uses fallback message when empty dataset has no diagnostics', () => { + const emptyDataset = createEmptyTokenDataset(); + + const { validateResult, diagnostics } = + simulateValidateDegradation(emptyDataset); + + expect(validateResult).toBeUndefined(); + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toBe( + 'validate mode skipped: token dataset is empty', + ); + }); + + it('result has no validate key when dataset is empty', () => { + const emptyDataset = createEmptyTokenDataset('some diagnostic'); + + const { validateResult, diagnostics } = + simulateValidateDegradation(emptyDataset); + const result = assembleResult(validateResult, undefined, diagnostics); + + expect(result).not.toHaveProperty('validate'); + expect(result.diagnostics).toContain('some diagnostic'); + }); + + it('summary shows zero validate issues when skipped', () => { + const emptyDataset = createEmptyTokenDataset('empty'); + + const { validateResult, diagnostics } = + simulateValidateDegradation(emptyDataset); + const result = assembleResult(validateResult, undefined, diagnostics); + + expect(result.summary.totalIssues).toBe(0); + expect(result.summary.byMode.validate).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Requirement 13.3 — overrides mode works without token dataset (detection-only) +// --------------------------------------------------------------------------- + +describe('Requirement 13.3: overrides mode runs in detection-only mode without token dataset', () => { + it('emits detection-only diagnostic when no token dataset', () => { + const { diagnostics } = simulateOverridesDegradation(null); + + expect(diagnostics).toHaveLength(1); + expect(diagnostics[0]).toBe( + 'overrides mode running in detection-only mode (no token dataset for classification)', + ); + }); + + it('overrides result is still produced (not undefined)', () => { + const { overridesResult } = simulateOverridesDegradation(null); + + expect(overridesResult).toBeDefined(); + expect(overridesResult.items).toEqual([]); + expect(overridesResult.byMechanism).toEqual({}); + }); + + it('detection-only result omits byClassification', () => { + const { overridesResult } = simulateOverridesDegradation(null); + + expect(overridesResult).not.toHaveProperty('byClassification'); + }); + + it('result includes overrides key even in detection-only mode', () => { + const { overridesResult, diagnostics } = simulateOverridesDegradation(null); + const result = assembleResult(undefined, overridesResult, diagnostics); + + expect(result).toHaveProperty('overrides'); + expect(result.overrides).toBeDefined(); + }); + + it('does not emit detection-only diagnostic when token dataset is available', () => { + const dataset = new TokenDatasetImpl([ + { + name: '--semantic-color-primary', + value: '#000', + scope: {}, + sourceFile: 'tokens.css', + }, + ]); + + const { diagnostics } = simulateOverridesDegradation(dataset); + + expect(diagnostics).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// Requirement 13.4 — both modes cannot run → actionable error message +// --------------------------------------------------------------------------- + +describe('Requirement 13.4: actionable error when neither mode produces results', () => { + it('includes actionable guidance when validate is skipped and overrides is empty', () => { + // Simulate: validate skipped (no generatedStylesRoot), overrides ran but found nothing + const { validateResult, diagnostics: valDiag } = + simulateValidateDegradation(null); + const { overridesResult, diagnostics: ovDiag } = + simulateOverridesDegradation(null); + + const allDiagnostics = [...valDiag, ...ovDiag]; + + // Mirror the handler's "both modes produced nothing" check + if ( + !validateResult && + overridesResult && + overridesResult.items.length === 0 && + allDiagnostics.length > 0 + ) { + allDiagnostics.push( + 'Neither mode produced results. Configure generatedStylesRoot and tokensConfig for full audit capabilities.', + ); + } + + const result = assembleResult( + validateResult, + overridesResult, + allDiagnostics, + ); + + expect(result.diagnostics).toContain( + 'Neither mode produced results. Configure generatedStylesRoot and tokensConfig for full audit capabilities.', + ); + }); + + it('actionable message mentions both generatedStylesRoot and tokensConfig', () => { + const message = + 'Neither mode produced results. Configure generatedStylesRoot and tokensConfig for full audit capabilities.'; + + expect(message).toContain('generatedStylesRoot'); + expect(message).toContain('tokensConfig'); + }); + + it('formatted output renders the actionable message as a diagnostic', () => { + const result = assembleResult(undefined, { items: [], byMechanism: {} }, [ + 'validate mode skipped: generatedStylesRoot is not configured', + 'overrides mode running in detection-only mode (no token dataset for classification)', + 'Neither mode produced results. Configure generatedStylesRoot and tokensConfig for full audit capabilities.', + ]); + + const lines = formatAuditResult(result); + const actionableLine = lines.find((l) => + l.includes('Neither mode produced results'), + ); + + expect(actionableLine).toBeDefined(); + expect(actionableLine).toContain('⚠'); + }); +}); + +// --------------------------------------------------------------------------- +// Requirement 13.5 — diagnostics array lists skipped modes and reasons +// --------------------------------------------------------------------------- + +describe('Requirement 13.5: diagnostics array lists skipped modes and reasons', () => { + it('collects diagnostics from both validate and overrides degradation', () => { + const { diagnostics: valDiag } = simulateValidateDegradation(null); + const { diagnostics: ovDiag } = simulateOverridesDegradation(null); + + const allDiagnostics = [...valDiag, ...ovDiag]; + + expect(allDiagnostics).toHaveLength(2); + expect(allDiagnostics[0]).toContain('validate mode skipped'); + expect(allDiagnostics[1]).toContain( + 'overrides mode running in detection-only', + ); + }); + + it('diagnostics is empty when full config is available', () => { + const dataset = new TokenDatasetImpl([ + { + name: '--semantic-color-primary', + value: '#000', + scope: {}, + sourceFile: 'tokens.css', + }, + ]); + + const { diagnostics: valDiag } = simulateValidateDegradation(dataset); + const { diagnostics: ovDiag } = simulateOverridesDegradation(dataset); + + expect([...valDiag, ...ovDiag]).toHaveLength(0); + }); + + it('diagnostics array is always present in the result', () => { + const result1 = assembleResult(undefined, undefined, []); + expect(result1).toHaveProperty('diagnostics'); + expect(Array.isArray(result1.diagnostics)).toBe(true); + + const result2 = assembleResult(undefined, undefined, ['some diagnostic']); + expect(result2.diagnostics).toHaveLength(1); + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/edit-distance.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/edit-distance.ts new file mode 100644 index 0000000..91de9b3 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/edit-distance.ts @@ -0,0 +1,34 @@ +/** + * Computes the Levenshtein edit distance between two strings. + * Uses the Wagner-Fischer algorithm with O(min(m,n)) space. + */ +export function levenshtein(a: string, b: string): number { + if (a === b) return 0; + if (a.length === 0) return b.length; + if (b.length === 0) return a.length; + + // Ensure a is the shorter string for space optimization + if (a.length > b.length) [a, b] = [b, a]; + + const m = a.length; + const n = b.length; + let prev = new Array(m + 1); + let curr = new Array(m + 1); + + for (let i = 0; i <= m; i++) prev[i] = i; + + for (let j = 1; j <= n; j++) { + curr[0] = j; + for (let i = 1; i <= m; i++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + curr[i] = Math.min( + prev[i] + 1, // deletion + curr[i - 1] + 1, // insertion + prev[i - 1] + cost, // substitution + ); + } + [prev, curr] = [curr, prev]; + } + + return prev[m]; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/override-classifier.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/override-classifier.ts new file mode 100644 index 0000000..eb08fb1 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/override-classifier.ts @@ -0,0 +1,65 @@ +import type { ScssPropertyEntry } from '@push-based/styles-ast-utils'; + +import type { TokenEntry } from '../../shared/utils/token-dataset.js'; +import type { + OverrideMechanism, + OverrideClassification, +} from '../models/types.js'; + +// --------------------------------------------------------------------------- +// Mechanism detection +// --------------------------------------------------------------------------- + +/** + * Determines the CSS mechanism used for a token override. + * + * Priority order (first match wins): + * 1. `!important` in value + * 2. `::ng-deep` in selector + * 3. `:root[data-theme` in selector + * 4. `:host` in selector + * 5. Class selector (`.className`) in selector + * 6. Fallback: `unknown` (bare element selectors, `:root` without data-theme, etc.) + */ +export function detectMechanism(entry: ScssPropertyEntry): OverrideMechanism { + const selector = entry.selector; + if (entry.important) return 'important'; + if (selector.includes('::ng-deep')) return 'ng-deep'; + if (selector.includes(':root[data-theme')) return 'root-theme'; + if (selector.includes(':host')) return 'host'; + // Class selector: has a dot-prefixed class but not :host or ::ng-deep + if (/\.\w/.test(selector)) return 'class-selector'; + return 'unknown'; +} + +// --------------------------------------------------------------------------- +// Override classification +// --------------------------------------------------------------------------- + +/** + * Classifies a token override by intent. + * + * Classification priority: + * 1. `important-override` — value contains `!important` + * 2. `deep-override` — selector uses `::ng-deep` + * 3. `legitimate` — original token has a theme scope, or selector is `:root[data-theme` + * 4. `component-override` — selector uses `:host` + * 5. `inline-override` — selector uses a class selector + * 6. `scope-violation` — fallback for unrecognised patterns + */ +export function classifyOverride( + entry: ScssPropertyEntry, + originalToken: TokenEntry | undefined, +): OverrideClassification { + if (entry.important) return 'important-override'; + if (entry.selector.includes('::ng-deep')) return 'deep-override'; + + // Legitimate: override in a theme file (scope has theme key) + if (originalToken?.scope?.theme) return 'legitimate'; + + if (entry.selector.includes(':root[data-theme')) return 'legitimate'; + if (entry.selector.includes(':host')) return 'component-override'; + if (/\.\w/.test(entry.selector)) return 'inline-override'; + + return 'scope-violation'; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/overrides-mode.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/overrides-mode.ts new file mode 100644 index 0000000..000e37f --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/overrides-mode.ts @@ -0,0 +1,129 @@ +import * as path from 'node:path'; + +import { parseScssValues } from '@push-based/styles-ast-utils'; + +import type { TokenDataset } from '../../shared/utils/token-dataset.js'; +import { detectMechanism, classifyOverride } from './override-classifier.js'; +import type { OverrideItem, OverridesResult } from '../models/types.js'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface OverridesModeOptions { + tokenDataset: TokenDataset | null; + tokenPrefix: string | null; + cwd: string; +} + +// --------------------------------------------------------------------------- +// Main pipeline +// --------------------------------------------------------------------------- + +/** + * Runs the **overrides** mode pipeline. + * + * For every style file: + * 1. Parse with `parseScssValues` to get classified entries. + * 2. Iterate declarations (token overrides) and consumptions. + * 3. Detect the override mechanism. + * 4. Optionally classify the override when `tokenDataset` is available. + * + * When `tokenDataset` is unavailable (zero-config mode), `classification` + * and `originalValue` are omitted from the output. + */ +export async function runOverridesMode( + styleFiles: string[], + options: OverridesModeOptions, +): Promise { + const overrideItems: OverrideItem[] = []; + const mechanismCounts: Record = {}; + const classificationCounts: Record = {}; + + for (const filePath of styleFiles) { + const parsed = await parseScssValues(filePath); + const declarations = parsed.getDeclarations(); + const consumptions = parsed.getConsumptions(); + const relPath = path.relative(options.cwd, filePath); + + // --- Token declarations --- + for (const entry of declarations) { + const isKnownToken = options.tokenDataset + ? options.tokenDataset.getByName(entry.property) !== undefined + : false; + + const matchesPrefix = + isKnownToken || + (options.tokenPrefix != null && + entry.property.startsWith(options.tokenPrefix)); + + if (!matchesPrefix) continue; + + const mechanism = detectMechanism(entry); + mechanismCounts[mechanism] = (mechanismCounts[mechanism] ?? 0) + 1; + + const item: OverrideItem = { + file: relPath, + line: entry.line, + token: entry.property, + newValue: entry.value, + mechanism, + }; + + if (options.tokenDataset) { + const original = options.tokenDataset.getByName(entry.property); + if (original) { + item.originalValue = original.value; + } + const classification = classifyOverride(entry, original); + item.classification = classification; + classificationCounts[classification] = + (classificationCounts[classification] ?? 0) + 1; + } + + overrideItems.push(item); + } + + // --- Token consumptions — detect !important only --- + for (const entry of consumptions) { + if (!entry.important) continue; + + const tokenMatch = entry.value.match(/var\(\s*(--[\w-]+)/); + if (!tokenMatch) continue; + + const tokenName = tokenMatch[1]; + if ( + options.tokenPrefix == null || + !tokenName.startsWith(options.tokenPrefix) + ) + continue; + + const mechanism = 'important' as const; + mechanismCounts[mechanism] = (mechanismCounts[mechanism] ?? 0) + 1; + + overrideItems.push({ + file: relPath, + line: entry.line, + token: entry.property, + newValue: entry.value, + mechanism, + ...(options.tokenDataset && { + classification: 'important-override' as const, + }), + }); + + if (options.tokenDataset) { + classificationCounts['important-override'] = + (classificationCounts['important-override'] ?? 0) + 1; + } + } + } + + return { + items: overrideItems, + byMechanism: mechanismCounts, + ...(Object.keys(classificationCounts).length > 0 && { + byClassification: classificationCounts, + }), + }; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/edit-distance.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/edit-distance.spec.ts new file mode 100644 index 0000000..857ac5c --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/edit-distance.spec.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from 'vitest'; +import { levenshtein } from '../edit-distance.js'; + +/** + * Validates: Requirements 16.1, 16.2, 16.3 + * + * Tests for edit-distance correctness properties: + * - Property 8: Identity — levenshtein(a, b) === 0 iff a === b + * - Property 9: Symmetry — levenshtein(a, b) === levenshtein(b, a) + * - Property 10: Empty string base case — levenshtein(a, '') === a.length + */ + +describe('levenshtein – edit-distance', () => { + /** + * Property 8 — Identity: + * levenshtein(a, b) === 0 if and only if a === b + * **Validates: Requirements 16.1** + */ + describe('Property 8: Identity', () => { + it('returns 0 for two empty strings', () => { + expect(levenshtein('', '')).toBe(0); + }); + + it('returns 0 for identical single-char strings', () => { + expect(levenshtein('a', 'a')).toBe(0); + }); + + it('returns 0 for identical multi-char strings', () => { + expect(levenshtein('hello', 'hello')).toBe(0); + }); + + it('returns 0 for identical token-like strings', () => { + expect(levenshtein('--ds-button-color-bg', '--ds-button-color-bg')).toBe( + 0, + ); + }); + + it('returns non-zero for different strings', () => { + expect(levenshtein('a', 'b')).not.toBe(0); + }); + + it('returns non-zero for strings differing by one char', () => { + expect(levenshtein('cat', 'car')).not.toBe(0); + }); + + it('returns non-zero for empty vs non-empty', () => { + expect(levenshtein('', 'a')).not.toBe(0); + }); + }); + + /** + * Property 9 — Symmetry: + * levenshtein(a, b) === levenshtein(b, a) + * **Validates: Requirements 16.2** + */ + describe('Property 9: Symmetry', () => { + const pairs: [string, string][] = [ + ['', ''], + ['', 'abc'], + ['a', 'b'], + ['kitten', 'sitting'], + ['--ds-button-bg', '--ds-button-color-bg'], + ['abc', 'xyz'], + ['flaw', 'lawn'], + ]; + + for (const [a, b] of pairs) { + it(`levenshtein('${a}', '${b}') === levenshtein('${b}', '${a}')`, () => { + expect(levenshtein(a, b)).toBe(levenshtein(b, a)); + }); + } + }); + + /** + * Property 10 — Empty string base case: + * levenshtein(a, '') === a.length and levenshtein('', a) === a.length + * **Validates: Requirements 16.3** + */ + describe('Property 10: Empty string base case', () => { + const strings = ['', 'a', 'ab', 'hello', '--ds-button-enabled-color-bg']; + + for (const s of strings) { + it(`levenshtein('${s}', '') === ${s.length}`, () => { + expect(levenshtein(s, '')).toBe(s.length); + }); + + it(`levenshtein('', '${s}') === ${s.length}`, () => { + expect(levenshtein('', s)).toBe(s.length); + }); + } + }); + + /** + * Known distances — example-based verification + */ + describe('Known distances', () => { + it('kitten → sitting = 3', () => { + expect(levenshtein('kitten', 'sitting')).toBe(3); + }); + + it('single insertion: abc → abcd = 1', () => { + expect(levenshtein('abc', 'abcd')).toBe(1); + }); + + it('single deletion: abcd → abc = 1', () => { + expect(levenshtein('abcd', 'abc')).toBe(1); + }); + + it('single substitution: abc → aXc = 1', () => { + expect(levenshtein('abc', 'aXc')).toBe(1); + }); + + it('completely different strings: abc → xyz = 3', () => { + expect(levenshtein('abc', 'xyz')).toBe(3); + }); + + it('token typo: --ds-buton-bg → --ds-button-bg = 1', () => { + expect(levenshtein('--ds-buton-bg', '--ds-button-bg')).toBe(1); + }); + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/overrides-mode.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/overrides-mode.spec.ts new file mode 100644 index 0000000..cd1c222 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/overrides-mode.spec.ts @@ -0,0 +1,650 @@ +import { describe, it, expect } from 'vitest'; + +import { detectMechanism, classifyOverride } from '../override-classifier.js'; +import type { ScssPropertyEntry } from '@push-based/styles-ast-utils'; +import type { TokenEntry } from '../../../shared/utils/token-dataset.js'; +import type { OverrideMechanism, OverrideItem } from '../../models/types.js'; + +/** + * Validates: Requirements 6.1, 6.3, 7.1–7.5 + * + * Tests for overrides mode correctness properties: + * - Property 4: Declaration completeness — every declaration entry maps to an override item + * - Property 5: Mechanism determinism — detectMechanism returns expected mechanism per priority rules + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const VALID_MECHANISMS: OverrideMechanism[] = [ + 'host', + 'ng-deep', + 'class-selector', + 'root-theme', + 'important', + 'unknown', +]; + +function makeEntry( + overrides: Partial = {}, +): ScssPropertyEntry { + return { + property: '--ds-button-color-bg', + value: 'red', + line: 10, + selector: ':host', + classification: 'declaration', + important: false, + ...overrides, + }; +} + +function makeTokenEntry( + name: string, + value = '#000', + scope: Record = {}, +): TokenEntry { + return { name, value, scope, sourceFile: 'tokens.css' }; +} + +/** + * Simulates the overrides-mode pipeline mapping for a single declaration entry. + * This mirrors the core logic in `runOverridesMode` without file I/O. + */ +function buildOverrideItem( + entry: ScssPropertyEntry, + relPath: string, +): OverrideItem { + const mechanism = detectMechanism(entry); + return { + file: relPath, + line: entry.line, + token: entry.property, + newValue: entry.value, + mechanism, + }; +} + +// --------------------------------------------------------------------------- +// Property 4 — Declaration completeness +// --------------------------------------------------------------------------- + +describe('Property 4: Declaration completeness', () => { + /** + * Every ScssPropertyEntry with classification === 'declaration' must appear + * in overrides.items with correct token, newValue, file, line, and valid mechanism. + * **Validates: Requirements 6.1, 6.3** + */ + + it('maps a single declaration entry to an override item with correct fields', () => { + const entry = makeEntry({ + property: '--ds-card-color-bg', + value: 'blue', + line: 42, + selector: ':host', + classification: 'declaration', + }); + + const item = buildOverrideItem(entry, 'src/card.component.scss'); + + expect(item.token).toBe(entry.property); + expect(item.newValue).toBe(entry.value); + expect(item.file).toBe('src/card.component.scss'); + expect(item.line).toBe(entry.line); + expect(VALID_MECHANISMS).toContain(item.mechanism); + }); + + it('maps multiple declaration entries preserving each entry fields', () => { + const entries: ScssPropertyEntry[] = [ + makeEntry({ + property: '--ds-button-color-bg', + value: 'red', + line: 5, + selector: ':host', + }), + makeEntry({ + property: '--ds-button-color-text', + value: 'white', + line: 12, + selector: '::ng-deep .inner', + }), + makeEntry({ + property: '--ds-card-border-radius', + value: '8px', + line: 30, + selector: '.card-wrapper', + }), + ]; + + const items = entries.map((e) => + buildOverrideItem(e, 'components/button.scss'), + ); + + expect(items).toHaveLength(entries.length); + for (let i = 0; i < entries.length; i++) { + expect(items[i].token).toBe(entries[i].property); + expect(items[i].newValue).toBe(entries[i].value); + expect(items[i].line).toBe(entries[i].line); + expect(items[i].file).toBe('components/button.scss'); + expect(VALID_MECHANISMS).toContain(items[i].mechanism); + } + }); + + it('override item has a valid mechanism string for every declaration', () => { + const selectors = [ + ':host', + '::ng-deep .child', + '.my-class', + ':root[data-theme="dark"]', + ':host .nested', + 'div', + ]; + + for (const selector of selectors) { + const entry = makeEntry({ selector, classification: 'declaration' }); + const item = buildOverrideItem(entry, 'test.scss'); + expect(VALID_MECHANISMS).toContain(item.mechanism); + } + }); + + it('preserves token property name exactly as-is', () => { + const tokenNames = [ + '--ds-button-enabled-color-bg', + '--ds-card-border-width', + '--semantic-color-primary-base', + ]; + + for (const tokenName of tokenNames) { + const entry = makeEntry({ property: tokenName }); + const item = buildOverrideItem(entry, 'file.scss'); + expect(item.token).toBe(tokenName); + } + }); + + it('preserves newValue exactly as-is including complex values', () => { + const values = [ + 'red', + '#ff0000', + 'var(--semantic-color-primary)', + 'rgba(0, 0, 0, 0.5)', + '1px solid var(--ds-border-color)', + ]; + + for (const value of values) { + const entry = makeEntry({ value }); + const item = buildOverrideItem(entry, 'file.scss'); + expect(item.newValue).toBe(value); + } + }); + + it('preserves line number exactly', () => { + const lines = [1, 10, 100, 999]; + + for (const line of lines) { + const entry = makeEntry({ line }); + const item = buildOverrideItem(entry, 'file.scss'); + expect(item.line).toBe(line); + } + }); +}); + +// --------------------------------------------------------------------------- +// Property 5 — Mechanism determinism +// --------------------------------------------------------------------------- + +describe('Property 5: Mechanism determinism', () => { + /** + * detectMechanism returns expected mechanism for each selector/value + * combination following priority rules, and same inputs always produce + * same output. + * + * **Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5** + */ + + // --- Priority 1: !important --- + describe('!important (highest priority)', () => { + it('returns "important" when entry has !important', () => { + const entry = makeEntry({ important: true, selector: ':host' }); + expect(detectMechanism(entry)).toBe('important'); + }); + + it('!important takes priority over ::ng-deep', () => { + const entry = makeEntry({ + important: true, + selector: '::ng-deep .child', + }); + expect(detectMechanism(entry)).toBe('important'); + }); + + it('!important takes priority over :root[data-theme]', () => { + const entry = makeEntry({ + important: true, + selector: ':root[data-theme="dark"]', + }); + expect(detectMechanism(entry)).toBe('important'); + }); + + it('!important takes priority over class selector', () => { + const entry = makeEntry({ + important: true, + selector: '.my-class', + }); + expect(detectMechanism(entry)).toBe('important'); + }); + }); + + // --- Priority 2: ::ng-deep --- + describe('::ng-deep (priority 2)', () => { + it('returns "ng-deep" when selector contains ::ng-deep', () => { + const entry = makeEntry({ value: 'red', selector: '::ng-deep .child' }); + expect(detectMechanism(entry)).toBe('ng-deep'); + }); + + it('returns "ng-deep" for ::ng-deep with :host prefix', () => { + const entry = makeEntry({ + value: 'red', + selector: ':host ::ng-deep .inner', + }); + expect(detectMechanism(entry)).toBe('ng-deep'); + }); + }); + + // --- Priority 3: :root[data-theme] --- + describe(':root[data-theme] (priority 3)', () => { + it('returns "root-theme" when selector contains :root[data-theme', () => { + const entry = makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }); + expect(detectMechanism(entry)).toBe('root-theme'); + }); + + it('returns "root-theme" for :root[data-theme without closing bracket', () => { + const entry = makeEntry({ value: 'red', selector: ':root[data-theme' }); + expect(detectMechanism(entry)).toBe('root-theme'); + }); + }); + + // --- Priority 4: :host --- + describe(':host (priority 4)', () => { + it('returns "host" when selector contains :host', () => { + const entry = makeEntry({ value: 'red', selector: ':host' }); + expect(detectMechanism(entry)).toBe('host'); + }); + + it('returns "host" for :host with context selector', () => { + const entry = makeEntry({ value: 'red', selector: ':host(.active)' }); + expect(detectMechanism(entry)).toBe('host'); + }); + + it('returns "host" for :host-context', () => { + const entry = makeEntry({ + value: 'red', + selector: ':host-context(.theme-dark)', + }); + expect(detectMechanism(entry)).toBe('host'); + }); + }); + + // --- Priority 5: class-selector --- + describe('class-selector (priority 5)', () => { + it('returns "class-selector" for a simple class selector', () => { + const entry = makeEntry({ value: 'red', selector: '.my-class' }); + expect(detectMechanism(entry)).toBe('class-selector'); + }); + + it('returns "class-selector" for compound class selector', () => { + const entry = makeEntry({ value: 'red', selector: '.wrapper .inner' }); + expect(detectMechanism(entry)).toBe('class-selector'); + }); + + it('returns "class-selector" for element.class selector', () => { + const entry = makeEntry({ value: 'red', selector: 'div.container' }); + expect(detectMechanism(entry)).toBe('class-selector'); + }); + }); + + // --- Fallback: unknown --- + describe('fallback to unknown', () => { + it('returns "unknown" for bare element selector', () => { + const entry = makeEntry({ value: 'red', selector: 'div' }); + expect(detectMechanism(entry)).toBe('unknown'); + }); + + it('returns "unknown" for empty selector', () => { + const entry = makeEntry({ value: 'red', selector: '' }); + expect(detectMechanism(entry)).toBe('unknown'); + }); + + it('returns "unknown" for :root without data-theme', () => { + const entry = makeEntry({ value: 'red', selector: ':root' }); + expect(detectMechanism(entry)).toBe('unknown'); + }); + }); + + // --- Determinism --- + describe('determinism', () => { + it('same inputs always produce the same output', () => { + const testCases: ScssPropertyEntry[] = [ + makeEntry({ important: true, selector: ':host' }), + makeEntry({ value: 'red', selector: '::ng-deep .child' }), + makeEntry({ value: 'red', selector: ':root[data-theme="dark"]' }), + makeEntry({ value: 'red', selector: ':host' }), + makeEntry({ value: 'red', selector: '.my-class' }), + makeEntry({ value: 'red', selector: 'div' }), + ]; + + for (const entry of testCases) { + const result1 = detectMechanism(entry); + const result2 = detectMechanism(entry); + const result3 = detectMechanism(entry); + expect(result1).toBe(result2); + expect(result2).toBe(result3); + } + }); + + it('returns consistent results across all 5 mechanism types', () => { + const cases: Array<{ + entry: ScssPropertyEntry; + expected: OverrideMechanism; + }> = [ + { + entry: makeEntry({ important: true, selector: ':host' }), + expected: 'important', + }, + { + entry: makeEntry({ value: 'red', selector: '::ng-deep .child' }), + expected: 'ng-deep', + }, + { + entry: makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }), + expected: 'root-theme', + }, + { + entry: makeEntry({ value: 'red', selector: ':host' }), + expected: 'host', + }, + { + entry: makeEntry({ value: 'red', selector: '.wrapper' }), + expected: 'class-selector', + }, + ]; + + for (const { entry, expected } of cases) { + expect(detectMechanism(entry)).toBe(expected); + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// classifyOverride — supplementary tests +// --------------------------------------------------------------------------- + +describe('classifyOverride', () => { + it('returns "important-override" when entry has !important', () => { + const entry = makeEntry({ important: true, selector: ':host' }); + expect(classifyOverride(entry, undefined)).toBe('important-override'); + }); + + it('returns "deep-override" when selector contains ::ng-deep', () => { + const entry = makeEntry({ value: 'red', selector: '::ng-deep .child' }); + expect(classifyOverride(entry, undefined)).toBe('deep-override'); + }); + + it('returns "legitimate" when original token has theme scope', () => { + const entry = makeEntry({ value: 'red', selector: ':host' }); + const original = makeTokenEntry('--ds-button-bg', '#000', { + theme: 'dark', + }); + expect(classifyOverride(entry, original)).toBe('legitimate'); + }); + + it('returns "legitimate" when selector contains :root[data-theme', () => { + const entry = makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }); + expect(classifyOverride(entry, undefined)).toBe('legitimate'); + }); + + it('returns "component-override" when selector contains :host', () => { + const entry = makeEntry({ value: 'red', selector: ':host' }); + expect(classifyOverride(entry, undefined)).toBe('component-override'); + }); + + it('returns "inline-override" when selector is a class selector', () => { + const entry = makeEntry({ value: 'red', selector: '.my-class' }); + expect(classifyOverride(entry, undefined)).toBe('inline-override'); + }); + + it('returns "scope-violation" as fallback', () => { + const entry = makeEntry({ value: 'red', selector: 'div' }); + expect(classifyOverride(entry, undefined)).toBe('scope-violation'); + }); +}); + +// --------------------------------------------------------------------------- +// runOverridesMode pipeline tests +// --------------------------------------------------------------------------- + +import { runOverridesMode } from '../overrides-mode.js'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import * as fs from 'node:fs'; +import { TokenDatasetImpl } from '../../../shared/utils/token-dataset.js'; + +function makeTempScss(content: string): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'overrides-test-')); + const file = path.join(dir, 'test.scss'); + fs.writeFileSync(file, content, 'utf-8'); + return file; +} + +describe('runOverridesMode pipeline', () => { + const cwd = process.cwd(); + + // --- Declaration detection --- + + it('detects a token declaration override via :host selector', async () => { + const file = makeTempScss(':host { --ds-button-color-bg: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].token).toBe('--ds-button-color-bg'); + expect(result.items[0].newValue).toBe('red'); + expect(result.items[0].mechanism).toBe('host'); + expect(result.items[0].file).toBe(path.relative(cwd, file)); + }); + + it('detects a token declaration override via ::ng-deep selector', async () => { + const file = makeTempScss('::ng-deep .inner { --ds-card-color-bg: blue; }'); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].mechanism).toBe('ng-deep'); + }); + + // --- matchesPrefix logic --- + + it('skips declarations that do not match tokenPrefix when no dataset', async () => { + const file = makeTempScss(':host { --other-token: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.items).toHaveLength(0); + }); + + it('detects declarations matched via dataset lookup when tokenPrefix is null', async () => { + const dataset = new TokenDatasetImpl([ + { + name: '--ds-button-color-bg', + value: '#000', + scope: {}, + sourceFile: 'tokens.css', + }, + ]); + const file = makeTempScss(':host { --ds-button-color-bg: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: dataset, + tokenPrefix: null, + cwd, + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].token).toBe('--ds-button-color-bg'); + }); + + it('skips declarations not in dataset when tokenPrefix is null', async () => { + const dataset = new TokenDatasetImpl([ + { + name: '--ds-button-color-bg', + value: '#000', + scope: {}, + sourceFile: 'tokens.css', + }, + ]); + const file = makeTempScss(':host { --ds-unknown-token: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: dataset, + tokenPrefix: null, + cwd, + }); + + expect(result.items).toHaveLength(0); + }); + + // --- !important consumption path --- + + it('detects !important on a token consumption', async () => { + const file = makeTempScss( + '.foo { color: var(--ds-button-color-text) !important; }', + ); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.items).toHaveLength(1); + expect(result.items[0].mechanism).toBe('important'); + // PostCSS strips !important from value into decl.important — value is clean + expect(result.items[0].newValue).toBe('var(--ds-button-color-text)'); + }); + + it('skips !important consumption when tokenPrefix is null', async () => { + const file = makeTempScss( + '.foo { color: var(--ds-button-color-text) !important; }', + ); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: null, + cwd, + }); + + // No prefix and no dataset — !important consumption path requires prefix match + expect(result.items).toHaveLength(0); + }); + + // --- byClassification aggregation --- + + it('populates byClassification when dataset is available', async () => { + const dataset = new TokenDatasetImpl([ + { + name: '--ds-button-color-bg', + value: '#000', + scope: {}, + sourceFile: 'tokens.css', + }, + ]); + const file = makeTempScss(':host { --ds-button-color-bg: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: dataset, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.byClassification).toBeDefined(); + expect(Object.keys(result.byClassification!).length).toBeGreaterThan(0); + }); + + it('omits byClassification when no dataset is available', async () => { + const file = makeTempScss(':host { --ds-button-color-bg: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.byClassification).toBeUndefined(); + }); + + // --- Zero-config mode --- + + it('returns empty result when both tokenDataset and tokenPrefix are null', async () => { + const file = makeTempScss(':host { --ds-button-color-bg: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: null, + cwd, + }); + + expect(result.items).toHaveLength(0); + expect(result.byMechanism).toEqual({}); + expect(result.byClassification).toBeUndefined(); + }); + + // --- byMechanism aggregation --- + + it('counts mechanisms correctly across multiple overrides', async () => { + const file = makeTempScss(` + :host { --ds-button-color-bg: red; } + :host { --ds-button-color-text: blue; } + ::ng-deep .inner { --ds-card-color-bg: green; } + `); + const result = await runOverridesMode([file], { + tokenDataset: null, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.byMechanism['host']).toBe(2); + expect(result.byMechanism['ng-deep']).toBe(1); + }); + + // --- originalValue from dataset --- + + it('includes originalValue when token exists in dataset', async () => { + const dataset = new TokenDatasetImpl([ + { + name: '--ds-button-color-bg', + value: 'var(--semantic-color-primary)', + scope: {}, + sourceFile: 'tokens.css', + }, + ]); + const file = makeTempScss(':host { --ds-button-color-bg: red; }'); + const result = await runOverridesMode([file], { + tokenDataset: dataset, + tokenPrefix: '--ds-', + cwd, + }); + + expect(result.items[0].originalValue).toBe('var(--semantic-color-primary)'); + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/validate-mode.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/validate-mode.spec.ts new file mode 100644 index 0000000..fd10113 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/validate-mode.spec.ts @@ -0,0 +1,351 @@ +import { describe, it, expect } from 'vitest'; + +import { extractVarReferences, findClosestToken } from '../validate-mode.js'; +import { + TokenDatasetImpl, + type TokenEntry, +} from '../../../shared/utils/token-dataset.js'; +import { levenshtein } from '../edit-distance.js'; +import type { InvalidTokenRef } from '../../models/types.js'; + +/** + * Validates: Requirements 3.1, 3.5, 3.6, 4.1, 4.4, 4.5 + * + * Tests for validate mode correctness properties: + * - Property 1: Prefix filtering — extractVarReferences + prefix filter returns only matching tokens + * - Property 2: Suggestion threshold — findClosestToken returns suggestion iff distance ≤ 3 + * - Property 3: Invalid token report fields — InvalidTokenRef has all required fields + */ + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeToken(name: string, value = '#000'): TokenEntry { + return { name, value, scope: {}, sourceFile: 'tokens.css' }; +} + +function buildDataset(tokens: TokenEntry[]): TokenDatasetImpl { + return new TokenDatasetImpl(tokens); +} + +// --------------------------------------------------------------------------- +// Property 1 — Prefix filtering +// --------------------------------------------------------------------------- + +describe('Property 1: Prefix filtering', () => { + /** + * extractVarReferences extracts all var() token names, and filtering by + * prefix retains only those starting with the given prefix. + * **Validates: Requirements 3.1, 4.1** + */ + + it('extracts a single var() reference', () => { + const refs = extractVarReferences('var(--ds-button-bg)'); + expect(refs).toEqual(['--ds-button-bg']); + }); + + it('extracts multiple var() references from one value', () => { + const refs = extractVarReferences( + 'var(--ds-button-bg) var(--semantic-color-primary)', + ); + expect(refs).toEqual(['--ds-button-bg', '--semantic-color-primary']); + }); + + it('returns empty array when no var() references exist', () => { + expect(extractVarReferences('red')).toEqual([]); + expect(extractVarReferences('10px solid #ccc')).toEqual([]); + expect(extractVarReferences('')).toEqual([]); + }); + + it('handles var() with whitespace after opening paren', () => { + const refs = extractVarReferences('var( --ds-spacing-sm )'); + expect(refs).toEqual(['--ds-spacing-sm']); + }); + + it('handles nested var() with fallback', () => { + const refs = extractVarReferences('var(--ds-color-bg, var(--ds-fallback))'); + expect(refs).toEqual(['--ds-color-bg', '--ds-fallback']); + }); + + it('prefix filter retains only tokens starting with --ds-', () => { + const refs = extractVarReferences( + 'var(--ds-button-bg) var(--semantic-color-primary) var(--ds-spacing-md)', + ); + const dsOnly = refs.filter((r) => r.startsWith('--ds-')); + expect(dsOnly).toEqual(['--ds-button-bg', '--ds-spacing-md']); + // No semantic tokens should be in the filtered result + for (const token of dsOnly) { + expect(token.startsWith('--ds-')).toBe(true); + } + }); + + it('prefix filter retains only tokens starting with --semantic-', () => { + const refs = extractVarReferences( + 'var(--ds-button-bg) var(--semantic-color-primary) var(--semantic-font-size)', + ); + const semanticOnly = refs.filter((r) => r.startsWith('--semantic-')); + expect(semanticOnly).toEqual([ + '--semantic-color-primary', + '--semantic-font-size', + ]); + for (const token of semanticOnly) { + expect(token.startsWith('--semantic-')).toBe(true); + } + }); + + it('prefix filter returns empty when no tokens match the prefix', () => { + const refs = extractVarReferences('var(--ds-button-bg) var(--ds-spacing)'); + const filtered = refs.filter((r) => r.startsWith('--semantic-')); + expect(filtered).toEqual([]); + }); + + it('every extracted token matching the prefix is included', () => { + const value = + 'var(--ds-a) var(--other-b) var(--ds-c) var(--ds-d) var(--other-e)'; + const allRefs = extractVarReferences(value); + const dsRefs = allRefs.filter((r) => r.startsWith('--ds-')); + // All --ds- tokens from the original value must be present + expect(dsRefs).toEqual(['--ds-a', '--ds-c', '--ds-d']); + // No non-matching tokens should leak through + expect(dsRefs.every((r) => r.startsWith('--ds-'))).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Property 2 — Suggestion threshold +// --------------------------------------------------------------------------- + +describe('Property 2: Suggestion threshold', () => { + /** + * findClosestToken returns a suggestion iff a candidate exists within + * Levenshtein distance ≤ 3, and the returned distance is correct. + * **Validates: Requirements 3.5, 4.4** + */ + + const tokens = [ + makeToken('--ds-button-color-bg'), + makeToken('--ds-button-color-text'), + makeToken('--ds-card-color-bg'), + makeToken('--ds-spacing-sm'), + makeToken('--ds-spacing-md'), + makeToken('--ds-spacing-lg'), + ]; + const dataset = buildDataset(tokens); + + it('returns exact match with distance 0 when token exists', () => { + const result = findClosestToken('--ds-button-color-bg', dataset, '--ds-'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('--ds-button-color-bg'); + expect(result!.distance).toBe(0); + }); + + it('returns suggestion for single-char typo (distance 1)', () => { + // "buton" instead of "button" — missing one 't' + const result = findClosestToken('--ds-buton-color-bg', dataset, '--ds-'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('--ds-button-color-bg'); + expect(result!.distance).toBe(1); + }); + + it('returns suggestion for two-char typo (distance 2)', () => { + // "buton-colr" instead of "button-color" — missing 't' and 'o' + const result = findClosestToken('--ds-buton-colr-bg', dataset, '--ds-'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('--ds-button-color-bg'); + expect(result!.distance).toBe(2); + }); + + it('returns suggestion for distance exactly 3', () => { + // "spacng-s" instead of "spacing-sm" — 3 edits + const result = findClosestToken('--ds-spacng-s', dataset, '--ds-'); + expect(result).not.toBeNull(); + expect(result!.distance).toBeLessThanOrEqual(3); + }); + + it('returns null when no candidate is within distance 3', () => { + const result = findClosestToken( + '--ds-completely-different-token-name', + dataset, + '--ds-', + ); + expect(result).toBeNull(); + }); + + it('returns null for empty dataset', () => { + const emptyDataset = buildDataset([]); + const result = findClosestToken('--ds-button-bg', emptyDataset, '--ds-'); + expect(result).toBeNull(); + }); + + it('returns the closest candidate when multiple are within threshold', () => { + // "--ds-spacing-sm" and "--ds-spacing-md" differ by 1 char each from "--ds-spacing-sx" + const result = findClosestToken('--ds-spacing-sm', dataset, '--ds-'); + expect(result).not.toBeNull(); + expect(result!.name).toBe('--ds-spacing-sm'); + expect(result!.distance).toBe(0); + }); + + it('returned distance matches actual levenshtein distance', () => { + // Verify the distance field is accurate + const result = findClosestToken('--ds-buton-color-bg', dataset, '--ds-'); + expect(result).not.toBeNull(); + // Cross-check with the levenshtein function directly + expect(result!.distance).toBe( + levenshtein('--ds-buton-color-bg', result!.name), + ); + }); + + it('only considers candidates matching the given prefix', () => { + const mixedTokens = [ + makeToken('--semantic-color-primary'), + makeToken('--ds-color-primary'), + ]; + const mixedDataset = buildDataset(mixedTokens); + + // Search with --semantic- prefix — should only find semantic tokens + const result = findClosestToken( + '--semantic-color-primry', + mixedDataset, + '--semantic-', + ); + expect(result).not.toBeNull(); + expect(result!.name).toBe('--semantic-color-primary'); + expect(result!.name.startsWith('--semantic-')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Property 3 — Invalid token report fields +// --------------------------------------------------------------------------- + +describe('Property 3: Invalid token report fields', () => { + /** + * InvalidTokenRef must have non-empty token, file, and line fields. + * When a suggestion exists, suggestion and editDistance must also be present. + * **Validates: Requirements 3.6, 4.5** + */ + + it('InvalidTokenRef without suggestion has required fields', () => { + const ref: InvalidTokenRef = { + token: '--ds-nonexistent-token', + file: 'src/components/button.scss', + line: 42, + }; + + expect(ref.token).toBeTruthy(); + expect(ref.token.length).toBeGreaterThan(0); + expect(ref.file).toBeTruthy(); + expect(ref.file.length).toBeGreaterThan(0); + expect(ref.line).toBeGreaterThan(0); + expect(ref.suggestion).toBeUndefined(); + expect(ref.editDistance).toBeUndefined(); + }); + + it('InvalidTokenRef with suggestion has all fields', () => { + const ref: InvalidTokenRef = { + token: '--ds-buton-color-bg', + file: 'src/components/button.scss', + line: 15, + suggestion: '--ds-button-color-bg', + editDistance: 1, + }; + + expect(ref.token).toBeTruthy(); + expect(ref.token.length).toBeGreaterThan(0); + expect(ref.file).toBeTruthy(); + expect(ref.file.length).toBeGreaterThan(0); + expect(ref.line).toBeGreaterThan(0); + expect(ref.suggestion).toBeTruthy(); + expect(ref.suggestion!.length).toBeGreaterThan(0); + expect(ref.editDistance).toBeDefined(); + expect(ref.editDistance).toBeGreaterThan(0); + expect(ref.editDistance).toBeLessThanOrEqual(3); + }); + + it('constructs correct InvalidTokenRef from findClosestToken result', () => { + const tokens = [ + makeToken('--ds-button-color-bg'), + makeToken('--ds-button-color-text'), + ]; + const dataset = buildDataset(tokens); + + const invalidName = '--ds-buton-color-bg'; + const suggestion = findClosestToken(invalidName, dataset, '--ds-'); + + const ref: InvalidTokenRef = { + token: invalidName, + file: 'components/button.component.scss', + line: 10, + ...(suggestion && { + suggestion: suggestion.name, + editDistance: suggestion.distance, + }), + }; + + // Required fields are non-empty + expect(ref.token).toBe(invalidName); + expect(ref.file).toBeTruthy(); + expect(ref.line).toBe(10); + + // Suggestion fields are present because a close match exists + expect(ref.suggestion).toBe('--ds-button-color-bg'); + expect(ref.editDistance).toBe(1); + }); + + it('constructs InvalidTokenRef without suggestion when no close match', () => { + const tokens = [makeToken('--ds-button-color-bg')]; + const dataset = buildDataset(tokens); + + const invalidName = '--ds-completely-unrelated-name'; + const suggestion = findClosestToken(invalidName, dataset, '--ds-'); + + const ref: InvalidTokenRef = { + token: invalidName, + file: 'components/card.component.scss', + line: 25, + ...(suggestion && { + suggestion: suggestion.name, + editDistance: suggestion.distance, + }), + }; + + expect(ref.token).toBe(invalidName); + expect(ref.file).toBeTruthy(); + expect(ref.line).toBe(25); + // No suggestion because the token is too far from any candidate + expect(ref.suggestion).toBeUndefined(); + expect(ref.editDistance).toBeUndefined(); + }); + + it('editDistance is always ≤ 3 when suggestion is present', () => { + const tokens = [ + makeToken('--semantic-color-primary-base'), + makeToken('--semantic-color-secondary-base'), + makeToken('--semantic-font-size-sm'), + ]; + const dataset = buildDataset(tokens); + + // Various typos that should produce suggestions + const typos = [ + '--semantic-color-primry-base', // 1 deletion + '--semantic-color-primary-bse', // 1 deletion + '--semantic-font-size-sn', // 1 substitution + ]; + + for (const typo of typos) { + const suggestion = findClosestToken(typo, dataset, '--semantic-'); + if (suggestion) { + const ref: InvalidTokenRef = { + token: typo, + file: 'test.scss', + line: 1, + suggestion: suggestion.name, + editDistance: suggestion.distance, + }; + expect(ref.editDistance).toBeLessThanOrEqual(3); + expect(ref.editDistance).toBeGreaterThan(0); + } + } + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/validate-mode.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/validate-mode.ts new file mode 100644 index 0000000..1b9dc3b --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/validate-mode.ts @@ -0,0 +1,194 @@ +import * as path from 'node:path'; + +import { parseScssValues } from '@push-based/styles-ast-utils'; + +import type { TokenDataset } from '../../shared/utils/token-dataset.js'; +import { levenshtein } from './edit-distance.js'; +import type { + ValidTokenRef, + InvalidTokenRef, + BrandSpecificWarning, + ValidateResult, +} from '../models/types.js'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface ValidateModeOptions { + tokenPrefixes: string[] | null; + brandName?: string; + componentName?: string; + cwd: string; +} + +// --------------------------------------------------------------------------- +// Public helpers (exported for independent testing) +// --------------------------------------------------------------------------- + +/** + * Extracts CSS custom property names from `var()` expressions. + * + * Example: `"var(--ds-button-bg) var(--semantic-color-primary)"` → + * `["--ds-button-bg", "--semantic-color-primary"]` + */ +export function extractVarReferences(value: string): string[] { + const matches = value.matchAll(/var\(\s*(--[\w-]+)/g); + return [...matches].map((m) => m[1]); +} + +/** + * Finds the closest token name within Levenshtein distance ≤ 3. + * Returns `null` when no candidate is close enough. + * Short-circuits immediately on an exact match (distance 0). + */ +export function findClosestToken( + name: string, + dataset: TokenDataset, + prefix: string, +): { name: string; distance: number } | null { + const candidates = dataset.getByPrefix(prefix); + let best: { name: string; distance: number } | null = null; + + for (const candidate of candidates) { + const dist = levenshtein(name, candidate.name); + if (dist === 0) return { name: candidate.name, distance: 0 }; + if (dist <= 3 && (best === null || dist < best.distance)) { + best = { name: candidate.name, distance: dist }; + } + } + + return best; +} + +// --------------------------------------------------------------------------- +// Brand-specific check helper +// --------------------------------------------------------------------------- + +function checkBrandSpecific( + tokenName: string, + tokenDataset: TokenDataset, + brandName: string, + relPath: string, + line: number, + brandWarnings: BrandSpecificWarning[], +): void { + // Find all entries for this exact token name across every scope + const allEntries = tokenDataset.tokens.filter((t) => t.name === tokenName); + if (allEntries.length === 0) return; + + // A token is "universal" if at least one entry has no brand scope + const hasUniversalEntry = allEntries.some((t) => !t.scope['brand']); + if (hasUniversalEntry) return; // available everywhere — no warning needed + + // Token only exists under specific brand scopes — collect which brands + const brandsWithToken = [ + ...new Set( + allEntries + .map((t) => t.scope['brand']) + .filter((b): b is string => b != null), + ), + ]; + + if (brandsWithToken.length > 0) { + brandWarnings.push({ + token: tokenName, + file: relPath, + line, + availableBrands: brandsWithToken, + }); + } +} + +// --------------------------------------------------------------------------- +// Main pipeline +// --------------------------------------------------------------------------- + +/** + * Runs the **validate** mode pipeline. + * + * For every style file: + * 1. Parse with `parseScssValues` to get classified entries. + * 2. Iterate consumptions and extract `var()` references. + * 3. For each reference, check if it starts with any of the configured prefixes. + * 4. Validate each matching reference against the `TokenDataset`. + * 5. Compute typo suggestions via Levenshtein distance (threshold ≤ 3). + * 6. Optionally check brand-specific token availability. + * + * When `tokenPrefixes` is null, all var() references found in the dataset are validated. + */ +export async function runValidateMode( + styleFiles: string[], + tokenDataset: TokenDataset, + options: ValidateModeOptions, +): Promise { + const semanticValid: ValidTokenRef[] = []; + const semanticInvalid: InvalidTokenRef[] = []; + const brandWarnings: BrandSpecificWarning[] = []; + + for (const filePath of styleFiles) { + const parsed = await parseScssValues(filePath); + const consumptions = parsed.getConsumptions(); + + for (const entry of consumptions) { + const tokenNames = extractVarReferences(entry.value); + const relPath = path.relative(options.cwd, filePath); + + for (const tokenName of tokenNames) { + // Determine which prefix this token matches, if any + const matchedPrefix = options.tokenPrefixes + ? options.tokenPrefixes.find((p) => tokenName.startsWith(p)) + : tokenDataset.getByName(tokenName) !== undefined + ? '' + : undefined; + + if (matchedPrefix === undefined) continue; + + const existing = tokenDataset.getByName(tokenName); + + if (existing) { + semanticValid.push({ + token: tokenName, + file: relPath, + line: entry.line, + }); + + // Brand-specific check + if (options.brandName) { + checkBrandSpecific( + tokenName, + tokenDataset, + options.brandName, + relPath, + entry.line, + brandWarnings, + ); + } + } else { + // Use the matched prefix for scoped candidate search; fall back to full scan + const searchPrefix = + matchedPrefix || tokenName.match(/^(--[\w]+-)/)?.[1] || '--'; + const suggestion = findClosestToken( + tokenName, + tokenDataset, + searchPrefix, + ); + semanticInvalid.push({ + token: tokenName, + file: relPath, + line: entry.line, + ...(suggestion && { + suggestion: suggestion.name, + editDistance: suggestion.distance, + }), + }); + } + } + } + } + + return { + semantic: { valid: semanticValid, invalid: semanticInvalid }, + ...(brandWarnings.length > 0 && { brandWarnings }), + }; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts index 10a5095..4a0ce2e 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts @@ -44,8 +44,11 @@ export async function loadTokenDataset( const files = discoverFiles(absRoot, tokens.filePattern); if (files.length === 0) { + const patternDisplay = Array.isArray(tokens.filePattern) + ? tokens.filePattern.join(', ') + : tokens.filePattern; return createEmptyTokenDataset( - `No files matched pattern '${tokens.filePattern}' in '${generatedStylesRoot}'`, + `No files matched pattern '${patternDisplay}' in '${generatedStylesRoot}'`, ); } @@ -90,13 +93,20 @@ export { createEmptyTokenDataset } from './token-dataset.js'; // --------------------------------------------------------------------------- /** - * Discovers files matching a glob-like pattern under the given root. + * Discovers files matching one or more glob patterns under the given root. */ -function discoverFiles(absRoot: string, filePattern: string): string[] { +function discoverFiles( + absRoot: string, + filePattern: string | string[], +): string[] { const allFiles = walkDirectorySync(absRoot); - const regex = globToRegex(filePattern); + const patterns = Array.isArray(filePattern) ? filePattern : [filePattern]; + const regexes = patterns.map(globToRegex); return allFiles - .filter((f) => regex.test(path.relative(absRoot, f).replace(/\\/g, '/'))) + .filter((f) => { + const rel = path.relative(absRoot, f).replace(/\\/g, '/'); + return regexes.some((re) => re.test(rel)); + }) .sort(); } diff --git a/packages/angular-mcp-server/src/lib/tools/ds/tools.ts b/packages/angular-mcp-server/src/lib/tools/ds/tools.ts index c79bd99..5ed27c2 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/tools.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/tools.ts @@ -15,6 +15,7 @@ import { diffComponentContractTools, listComponentContractsTools, } from './component-contract/index.js'; +import { auditTokenUsageTools } from './audit-token-usage/index.js'; import { getDsStoryDataTools } from './story-parser/index.js'; export const dsTools: ToolsConfig[] = [ @@ -35,4 +36,6 @@ export const dsTools: ToolsConfig[] = [ ...getDeprecatedCssClassesTools, // Story parser tools ...getDsStoryDataTools, + // Audit tools + ...auditTokenUsageTools, ]; diff --git a/packages/angular-mcp-server/src/lib/validation/angular-mcp-server-options.schema.ts b/packages/angular-mcp-server/src/lib/validation/angular-mcp-server-options.schema.ts index 8c2b3d4..b865e3b 100644 --- a/packages/angular-mcp-server/src/lib/validation/angular-mcp-server-options.schema.ts +++ b/packages/angular-mcp-server/src/lib/validation/angular-mcp-server-options.schema.ts @@ -6,7 +6,9 @@ const isRelativePath = (val: string) => !path.isAbsolute(val); export const TokensConfigSchema = z .object({ - filePattern: z.string().default('**/semantic.css'), + filePattern: z + .union([z.string(), z.array(z.string())]) + .default('**/semantic.css'), propertyPrefix: z.string().nullable().default(null), /** * How directory structure under generatedStylesRoot maps to token scope metadata. diff --git a/packages/angular-mcp-server/tsconfig.json b/packages/angular-mcp-server/tsconfig.json index cde27b8..7ffdc55 100644 --- a/packages/angular-mcp-server/tsconfig.json +++ b/packages/angular-mcp-server/tsconfig.json @@ -7,17 +7,20 @@ "path": "../shared/ds-component-coverage" }, { - "path": "../shared/styles-ast-utils" + "path": "../shared/angular-ast-utils" }, { - "path": "../shared/angular-ast-utils" + "path": "../shared/styles-ast-utils" }, { - "path": "../shared/utils" + "path": "../shared/typescript-ast-utils" }, { "path": "../shared/models" }, + { + "path": "../shared/utils" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/angular-mcp-server/tsconfig.lib.json b/packages/angular-mcp-server/tsconfig.lib.json index 42e345a..db70f7f 100644 --- a/packages/angular-mcp-server/tsconfig.lib.json +++ b/packages/angular-mcp-server/tsconfig.lib.json @@ -18,16 +18,19 @@ "path": "../shared/ds-component-coverage/tsconfig.lib.json" }, { - "path": "../shared/styles-ast-utils/tsconfig.lib.json" + "path": "../shared/angular-ast-utils/tsconfig.lib.json" }, { - "path": "../shared/angular-ast-utils/tsconfig.lib.json" + "path": "../shared/styles-ast-utils/tsconfig.lib.json" }, { - "path": "../shared/utils/tsconfig.lib.json" + "path": "../shared/typescript-ast-utils/tsconfig.lib.json" }, { "path": "../shared/models/tsconfig.lib.json" + }, + { + "path": "../shared/utils/tsconfig.lib.json" } ] } diff --git a/packages/angular-mcp/src/main.ts b/packages/angular-mcp/src/main.ts index 0e87201..27e63a7 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -45,6 +45,7 @@ const argv = yargs(hideBin(process.argv)) describe: 'Glob pattern used to discover token CSS files within ds.generatedStylesRoot (default: "**/semantic.css")', type: 'string', + array: true, }) .option('ds.tokens.propertyPrefix', { describe: @@ -90,7 +91,7 @@ const { workspaceRoot, ds } = argv as unknown as { uiRoot: string; generatedStylesRoot?: string; tokens?: { - filePattern?: string; + filePattern?: string | string[]; propertyPrefix?: string; scopeStrategy?: string; categoryInference?: string; diff --git a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts index ec8c97a..ea170ea 100644 --- a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -19,7 +19,7 @@ export type ScssClassification = 'declaration' | 'consumption' | 'plain'; export interface ScssPropertyEntry { /** CSS property name, e.g. 'color' or '--ds-button-bg' */ property: string; - /** CSS value, e.g. 'var(--semantic-color-primary)' */ + /** CSS value, e.g. 'var(--semantic-color-primary)' — does NOT include `!important` */ value: string; /** 1-based line number in the source file, or -1 if unavailable */ line: number; @@ -27,6 +27,8 @@ export interface ScssPropertyEntry { selector: string; /** Classification of this entry */ classification: ScssClassification; + /** Whether the declaration has `!important` */ + important: boolean; } /** @@ -118,8 +120,16 @@ export async function parseScssContent( const value = decl.value; const line = decl.source?.start?.line ?? -1; const classification = classifyEntry(property, value); - - entries.push({ property, value, line, selector, classification }); + const important = decl.important ?? false; + + entries.push({ + property, + value, + line, + selector, + classification, + important, + }); }, }); diff --git a/packages/shared/utils/src/lib/file/glob-utils.spec.ts b/packages/shared/utils/src/lib/file/glob-utils.spec.ts new file mode 100644 index 0000000..d30abbf --- /dev/null +++ b/packages/shared/utils/src/lib/file/glob-utils.spec.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { globToRegex } from './glob-utils.js'; + +describe('globToRegex', () => { + // ── Basic glob features ── + + it('matches exact filename', () => { + const re = globToRegex('foo.css'); + expect(re.test('foo.css')).toBe(true); + expect(re.test('bar.css')).toBe(false); + }); + + it('* matches single path segment', () => { + const re = globToRegex('*.css'); + expect(re.test('foo.css')).toBe(true); + expect(re.test('dir/foo.css')).toBe(false); + }); + + it('** matches recursive paths', () => { + const re = globToRegex('**/*.css'); + expect(re.test('foo.css')).toBe(true); + expect(re.test('a/foo.css')).toBe(true); + expect(re.test('a/b/c/foo.css')).toBe(true); + }); + + it('? matches single character', () => { + const re = globToRegex('fo?.css'); + expect(re.test('foo.css')).toBe(true); + expect(re.test('fob.css')).toBe(true); + expect(re.test('fooo.css')).toBe(false); + }); + + // ── Brace expansion ── + + it('{a,b} matches either alternative', () => { + const re = globToRegex('*.{scss,css}'); + expect(re.test('style.scss')).toBe(true); + expect(re.test('style.css')).toBe(true); + expect(re.test('style.less')).toBe(false); + }); + + it('{a,b} works with ** recursive pattern', () => { + const re = globToRegex('**/*.{scss,css}'); + expect(re.test('style.scss')).toBe(true); + expect(re.test('a/b/style.css')).toBe(true); + expect(re.test('a/b/style.less')).toBe(false); + }); + + it('single-item brace {a} matches that item', () => { + const re = globToRegex('*.{css}'); + expect(re.test('style.css')).toBe(true); + expect(re.test('style.scss')).toBe(false); + }); + + it('brace with three alternatives', () => { + const re = globToRegex('*.{css,scss,less}'); + expect(re.test('x.css')).toBe(true); + expect(re.test('x.scss')).toBe(true); + expect(re.test('x.less')).toBe(true); + expect(re.test('x.styl')).toBe(false); + }); + + // ── Literal special characters (regression for #1) ── + + it('literal parentheses in pattern are escaped', () => { + const re = globToRegex('foo(1).css'); + expect(re.test('foo(1).css')).toBe(true); + expect(re.test('foo1.css')).toBe(false); + }); + + it('literal pipe in pattern is escaped', () => { + const re = globToRegex('a|b.css'); + expect(re.test('a|b.css')).toBe(true); + expect(re.test('a.css')).toBe(false); + expect(re.test('b.css')).toBe(false); + }); + + // ── Combined patterns ── + + it('complex pattern with ** and braces', () => { + const re = globToRegex('**/components/**/*.{scss,css}'); + expect(re.test('components/button/style.scss')).toBe(true); + expect(re.test('src/components/card/card.css')).toBe(true); + expect(re.test('src/components/card/card.ts')).toBe(false); + }); +}); diff --git a/packages/shared/utils/src/lib/file/glob-utils.ts b/packages/shared/utils/src/lib/file/glob-utils.ts index fdaa05f..8681718 100644 --- a/packages/shared/utils/src/lib/file/glob-utils.ts +++ b/packages/shared/utils/src/lib/file/glob-utils.ts @@ -1,18 +1,53 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +/** + * Replaces brace patterns like `{scss,css}` with numbered sentinels + * and returns a map to restore them as regex alternation groups later. + * Only handles single-level (non-nested) brace expansion. + */ +function extractBraces(pattern: string): { + replaced: string; + sentinels: Map; +} { + let counter = 0; + const sentinels = new Map(); + + const replaced = pattern.replace( + /\{([^}]+)\}/g, + (_, alternatives: string) => { + const parts = alternatives.split(',').map((s: string) => s.trim()); + const key = ``; + sentinels.set(key, `(${parts.join('|')})`); + return key; + }, + ); + + return { replaced, sentinels }; +} + /** * Converts a glob pattern to a regular expression. - * Supports: `*` (single segment), `**` (recursive), `?` (single char) + * Supports: `*` (single segment), `**` (recursive), `?` (single char), + * `{a,b}` (brace expansion / alternation) */ export function globToRegex(pattern: string): RegExp { - let regexPattern = pattern + // 1. Extract brace groups into sentinels before escaping + const { replaced, sentinels } = extractBraces(pattern); + + let regexPattern = replaced + // Escape ALL regex specials (parens, pipes, etc. are now safe inside sentinels) .replace(/[.+^${}()|[\]\\]/g, '\\$&') .replace(/\?/g, '[^/]') .replace(/\*\*/g, '') .replace(/\*/g, '[^/]*') .replace(//g, '.*'); + // 2. Restore brace sentinels as regex alternation groups + for (const [key, value] of sentinels) { + regexPattern = regexPattern.replace(key, value); + } + if (pattern.startsWith('**/')) { regexPattern = regexPattern.replace(/^\.\*\//, ''); regexPattern = `^(?:.*\\/)?${regexPattern}`;