From 916e92c26f3d88c3b7f4279a7a655b9a46f7fa73 Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Mon, 13 Apr 2026 15:46:00 +0200 Subject: [PATCH 1/3] feat: add token audit tool --- .nvmrc | 2 +- .../audit-token-usage.tool.ts | 364 ++++++++++++ .../lib/tools/ds/audit-token-usage/index.ts | 24 + .../ds/audit-token-usage/models/schema.ts | 46 ++ .../ds/audit-token-usage/models/types.ts | 110 ++++ .../spec/audit-token-usage.tool.spec.ts | 397 +++++++++++++ .../spec/graceful-degradation.spec.ts | 356 ++++++++++++ .../audit-token-usage/utils/edit-distance.ts | 34 ++ .../utils/encapsulation-detector.ts | 79 +++ .../utils/override-classifier.ts | 73 +++ .../audit-token-usage/utils/overrides-mode.ts | 125 +++++ .../utils/spec/edit-distance.spec.ts | 121 ++++ .../utils/spec/overrides-mode.spec.ts | 520 ++++++++++++++++++ .../utils/spec/validate-mode.spec.ts | 351 ++++++++++++ .../audit-token-usage/utils/validate-mode.ts | 217 ++++++++ .../ds/shared/utils/token-dataset-loader.ts | 20 +- .../src/lib/tools/ds/tools.ts | 3 + .../angular-mcp-server-options.schema.ts | 4 +- packages/angular-mcp-server/tsconfig.json | 9 +- packages/angular-mcp-server/tsconfig.lib.json | 9 +- packages/angular-mcp/src/main.ts | 3 +- .../utils/src/lib/file/glob-utils.spec.ts | 86 +++ .../shared/utils/src/lib/file/glob-utils.ts | 39 +- 23 files changed, 2976 insertions(+), 16 deletions(-) create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/audit-token-usage.tool.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/index.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/schema.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/types.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/audit-token-usage.tool.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/graceful-degradation.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/edit-distance.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/override-classifier.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/overrides-mode.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/edit-distance.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/overrides-mode.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/validate-mode.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/validate-mode.ts create mode 100644 packages/shared/utils/src/lib/file/glob-utils.spec.ts 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/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..5a5c870 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/audit-token-usage.tool.ts @@ -0,0 +1,364 @@ +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 { 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 (for testing in Task 7.2) +// --------------------------------------------------------------------------- + +/** + * 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 + + validateResult.component.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(globToRegex); + return files.filter( + (f) => !regexes.some((re) => re.test(f.replace(/\\/g, '/'))), + ); +} + +// --------------------------------------------------------------------------- +// Persistence +// --------------------------------------------------------------------------- + +function generateFilename(directory: string): string { + const sanitised = directory.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'); + return filePath; +} + +// --------------------------------------------------------------------------- +// 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, component } = 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})`; + } + lines.push(entry); + } + } + + if (component.invalid.length > 0) { + lines.push(''); + lines.push(DIVIDER); + lines.push(`❌ Invalid component tokens (${component.invalid.length})`); + lines.push(DIVIDER); + for (const ref of component.invalid) { + let entry = ` ${ref.token} ${ref.file}:${ref.line}`; + if (ref.suggestion) { + entry += ` → did you mean "${ref.suggestion}"? (distance ${ref.editDistance})`; + } + 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 && item.classification !== item.mechanism) { + 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(` 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 prefixes + const semanticPrefix = + params.tokenPrefix ?? context.tokensConfig?.propertyPrefix ?? null; + const componentPrefix = + params.tokenPrefix ?? context.tokensConfig?.componentTokenPrefix ?? '--ds-'; + + // 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; + + // 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, { + semanticPrefix, + componentPrefix, + 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, + componentPrefix, + semanticPrefix, + cwd: context.cwd, + workspaceRoot: context.workspaceRoot, + }); + } + + // 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 + const summary = buildSummary(validateResult, overridesResult); + + // 9. Assemble result + const result: AuditTokenUsageResult = { + ...(validateResult && { validate: validateResult }), + ...(overridesResult && { overrides: overridesResult }), + summary, + diagnostics, + }; + + // 10. 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..1af46cd --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/models/types.ts @@ -0,0 +1,110 @@ +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[]; + }; + component: { + valid: ValidTokenRef[]; + invalid: InvalidTokenRef[]; + }; + brandWarnings?: BrandSpecificWarning[]; +} + +// ============================================================================ +// Overrides mode results +// ============================================================================ + +export type OverrideMechanism = + | 'host' + | 'ng-deep' + | 'class-selector' + | 'root-theme' + | 'important' + | 'encapsulation-none'; + +export type OverrideClassification = + | 'legitimate' + | 'component-override' + | 'inline-override' + | 'deep-override' + | 'important-override' + | 'encapsulation-none' + | '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..ce85276 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/audit-token-usage.tool.spec.ts @@ -0,0 +1,397 @@ +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, + componentInvalidCount: number, +): ValidateResult { + return { + semantic: { + valid: [], + invalid: Array.from({ length: semanticInvalidCount }, (_, i) => ({ + token: `--semantic-invalid-${i}`, + file: `file-${i}.scss`, + line: i + 1, + })), + }, + component: { + valid: [], + invalid: Array.from({ length: componentInvalidCount }, (_, i) => ({ + token: `--ds-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', () => { + /** + * For any combination of active modes, the AuditTokenUsageResult must + * contain a key for each active mode and must not contain a key for + * inactive modes. The summary and diagnostics keys must always be present. + * **Validates: Requirements 11.1** + */ + + it('both modes active: result has validate, overrides, summary, diagnostics', () => { + const result = assembleResult( + ['validate', 'overrides'], + makeValidateResult(1, 0), + 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, 1), + undefined, + ); + + 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, 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, 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, 0), + makeOverridesResult(1), + ); + + expect(result.diagnostics).toEqual([]); + }); + + it('summary.byMode contains only keys for active modes with results', () => { + // Only validate active + const validateOnly = assembleResult( + ['validate'], + makeValidateResult(2, 1), + undefined, + ); + expect(validateOnly.summary.byMode).toHaveProperty('validate'); + expect(validateOnly.summary.byMode).not.toHaveProperty('overrides'); + + // Only overrides active + const overridesOnly = assembleResult( + ['overrides'], + undefined, + makeOverridesResult(3), + ); + expect(overridesOnly.summary.byMode).not.toHaveProperty('validate'); + expect(overridesOnly.summary.byMode).toHaveProperty('overrides'); + + // Both active + const both = assembleResult( + ['validate', 'overrides'], + makeValidateResult(1, 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', () => { + /** + * summary.totalIssues must equal the sum of validate.semantic.invalid.length + + * validate.component.invalid.length (when validate is present) plus + * overrides.items.length (when overrides is present). + * summary.byMode.validate must equal the count of invalid tokens, and + * summary.byMode.overrides must equal the count of override items. + * **Validates: Requirements 11.4** + */ + + it('totalIssues equals invalid tokens + override items (both modes)', () => { + const validateResult = makeValidateResult(3, 2); // 5 invalid tokens + const overridesResult = makeOverridesResult(4); // 4 overrides + const summary = buildSummary(validateResult, overridesResult); + + const expectedValidateIssues = + validateResult.semantic.invalid.length + + validateResult.component.invalid.length; + const expectedOverridesIssues = overridesResult.items.length; + + expect(summary.totalIssues).toBe( + expectedValidateIssues + expectedOverridesIssues, + ); + expect(summary.totalIssues).toBe(9); + }); + + it('byMode.validate equals semantic.invalid + component.invalid count', () => { + const validateResult = makeValidateResult(4, 3); // 7 invalid tokens + const summary = buildSummary(validateResult, undefined); + + expect(summary.byMode.validate).toBe( + validateResult.semantic.invalid.length + + validateResult.component.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, 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(2, 3); + 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, 2); // 8 invalid + const overridesResult = makeOverridesResult(3); // 3 overrides + 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(50, 30); // 80 invalid + const overridesResult = makeOverridesResult(100); // 100 overrides + 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(2, 1), + 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, 0), + undefined, + ); + 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, 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..4506be2 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/spec/graceful-degradation.spec.ts @@ -0,0 +1,356 @@ +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: [] }, + component: { 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/encapsulation-detector.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts new file mode 100644 index 0000000..541cd08 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts @@ -0,0 +1,79 @@ +import * as ts from 'typescript'; +import { readFile, access } from 'node:fs/promises'; +import * as path from 'node:path'; +import { + getDecorators, + isComponentDecorator, +} from '@push-based/typescript-ast-utils'; + +/** + * Scans for .ts files adjacent to the given style files and detects + * components with ViewEncapsulation.None. + * + * Convention: `foo.component.scss` → `foo.component.ts` + * + * Returns a Set of style file paths whose component uses None encapsulation. + */ +export async function detectViewEncapsulationNone( + styleFiles: string[], +): Promise> { + const result = new Set(); + + for (const styleFile of styleFiles) { + const dir = path.dirname(styleFile); + const baseName = path.basename(styleFile, path.extname(styleFile)); + // Convention: foo.component.scss → foo.component.ts + const tsFile = path.join(dir, baseName + '.ts'); + + try { + await access(tsFile); + } catch { + continue; + } + + const content = await readFile(tsFile, 'utf-8'); + const sourceFile = ts.createSourceFile( + tsFile, + content, + ts.ScriptTarget.Latest, + true, + ); + + ts.forEachChild(sourceFile, (node) => { + if (ts.isClassDeclaration(node)) { + const decorators = getDecorators(node); + for (const decorator of decorators) { + if (isComponentDecorator(decorator)) { + if (hasEncapsulationNone(decorator)) { + result.add(styleFile); + } + } + } + } + }); + } + + return result; +} + +/** + * Checks whether a @Component decorator contains `encapsulation: ViewEncapsulation.None`. + */ +function hasEncapsulationNone(decorator: ts.Decorator): boolean { + const expr = decorator.expression; + if (!ts.isCallExpression(expr)) return false; + + for (const arg of expr.arguments) { + if (!ts.isObjectLiteralExpression(arg)) continue; + for (const prop of arg.properties) { + if ( + ts.isPropertyAssignment(prop) && + prop.name.getText() === 'encapsulation' + ) { + const value = prop.initializer.getText(); + if (value.includes('None')) return true; + } + } + } + return false; +} 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..dee4dbe --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/override-classifier.ts @@ -0,0 +1,73 @@ +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. `ViewEncapsulation.None` on the component + * 3. `::ng-deep` in selector + * 4. `:root[data-theme` in selector + * 5. `:host` in selector + * 6. Class selector (`.className`) in selector + * 7. Fallback: `host` + */ +export function detectMechanism( + entry: ScssPropertyEntry, + isEncapsulationNone: boolean, +): OverrideMechanism { + const selector = entry.selector; + if (entry.value.includes('!important')) return 'important'; + if (isEncapsulationNone) return 'encapsulation-none'; + 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 'host'; // fallback for :root or bare selectors +} + +// --------------------------------------------------------------------------- +// Override classification +// --------------------------------------------------------------------------- + +/** + * Classifies a token override by intent. + * + * Classification priority: + * 1. `encapsulation-none` — component uses `ViewEncapsulation.None` + * 2. `important-override` — value contains `!important` + * 3. `deep-override` — selector uses `::ng-deep` + * 4. `legitimate` — original token has a theme scope, or selector is `:root[data-theme` + * 5. `component-override` — selector uses `:host` + * 6. `inline-override` — selector uses a class selector + * 7. `scope-violation` — fallback for unrecognised patterns + */ +export function classifyOverride( + entry: ScssPropertyEntry, + originalToken: TokenEntry | undefined, + isEncapsulationNone: boolean, +): OverrideClassification { + if (isEncapsulationNone) return 'encapsulation-none'; + if (entry.value.includes('!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..59cfe97 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/overrides-mode.ts @@ -0,0 +1,125 @@ +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 { detectViewEncapsulationNone } from './encapsulation-detector.js'; +import type { OverrideItem, OverridesResult } from '../models/types.js'; + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface OverridesModeOptions { + tokenDataset: TokenDataset | null; + componentPrefix: string; + semanticPrefix: string | null; + cwd: string; + workspaceRoot: string; +} + +// --------------------------------------------------------------------------- +// Main pipeline +// --------------------------------------------------------------------------- + +/** + * Runs the **overrides** mode pipeline. + * + * For every style file: + * 1. Detect `ViewEncapsulation.None` components. + * 2. Parse with `parseScssValues` to get classified entries. + * 3. Iterate declarations (token overrides) and consumptions (`!important`). + * 4. Detect the override mechanism for each entry. + * 5. 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 items: OverrideItem[] = []; + const mechanismCounts: Record = {}; + const classificationCounts: Record = {}; + + // Detect ViewEncapsulation.None components + const encapsulationNoneFiles = await detectViewEncapsulationNone(styleFiles); + + for (const filePath of styleFiles) { + const parsed = await parseScssValues(filePath, { + componentTokenPrefix: options.componentPrefix, + }); + const declarations = parsed.getDeclarations(); + const relPath = path.relative(options.cwd, filePath); + + // Check if this file's component has ViewEncapsulation.None + const isEncapsulationNone = encapsulationNoneFiles.has(filePath); + + // --- Token declarations = overrides in consumer files --- + for (const entry of declarations) { + const mechanism = detectMechanism(entry, isEncapsulationNone); + mechanismCounts[mechanism] = (mechanismCounts[mechanism] ?? 0) + 1; + + const item: OverrideItem = { + file: relPath, + line: entry.line, + token: entry.property, + newValue: entry.value, + mechanism, + }; + + // Add originalValue and classification when token dataset is available + if (options.tokenDataset) { + const original = options.tokenDataset.getByName(entry.property); + if (original) { + item.originalValue = original.value; + } + const classification = classifyOverride( + entry, + original, + isEncapsulationNone, + ); + item.classification = classification; + classificationCounts[classification] = + (classificationCounts[classification] ?? 0) + 1; + } + + items.push(item); + } + + // --- Consumptions with !important --- + const consumptions = parsed.getConsumptions(); + for (const entry of consumptions) { + if (entry.value.includes('!important')) { + const mechanism = 'important' as const; + mechanismCounts[mechanism] = (mechanismCounts[mechanism] ?? 0) + 1; + + items.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, + 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..f44519c --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/overrides-mode.spec.ts @@ -0,0 +1,520 @@ +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', + 'encapsulation-none', +]; + +function makeEntry( + overrides: Partial = {}, +): ScssPropertyEntry { + return { + property: '--ds-button-color-bg', + value: 'red', + line: 10, + selector: ':host', + classification: 'declaration', + ...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, + isEncapsulationNone: boolean, + relPath: string, +): OverrideItem { + const mechanism = detectMechanism(entry, isEncapsulationNone); + 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, false, '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, false, '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, false, '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, false, '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, false, '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, false, 'file.scss'); + expect(item.line).toBe(line); + } + }); + + it('handles encapsulation-none flag for declaration entries', () => { + const entry = makeEntry({ + selector: ':host', + classification: 'declaration', + }); + const item = buildOverrideItem(entry, true, 'encap.scss'); + + expect(item.token).toBe(entry.property); + expect(item.newValue).toBe(entry.value); + expect(item.mechanism).toBe('encapsulation-none'); + expect(VALID_MECHANISMS).toContain(item.mechanism); + }); +}); + +// --------------------------------------------------------------------------- +// 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 value contains !important', () => { + const entry = makeEntry({ value: 'red !important', selector: ':host' }); + expect(detectMechanism(entry, false)).toBe('important'); + }); + + it('!important takes priority over encapsulation-none', () => { + const entry = makeEntry({ value: 'red !important', selector: ':host' }); + expect(detectMechanism(entry, true)).toBe('important'); + }); + + it('!important takes priority over ::ng-deep', () => { + const entry = makeEntry({ + value: 'red !important', + selector: '::ng-deep .child', + }); + expect(detectMechanism(entry, false)).toBe('important'); + }); + + it('!important takes priority over :root[data-theme]', () => { + const entry = makeEntry({ + value: 'blue !important', + selector: ':root[data-theme="dark"]', + }); + expect(detectMechanism(entry, false)).toBe('important'); + }); + + it('!important takes priority over class selector', () => { + const entry = makeEntry({ + value: '10px !important', + selector: '.my-class', + }); + expect(detectMechanism(entry, false)).toBe('important'); + }); + }); + + // --- Priority 2: encapsulation-none --- + describe('encapsulation-none (priority 2)', () => { + it('returns "encapsulation-none" when flag is true and no !important', () => { + const entry = makeEntry({ value: 'red', selector: ':host' }); + expect(detectMechanism(entry, true)).toBe('encapsulation-none'); + }); + + it('encapsulation-none takes priority over ::ng-deep', () => { + const entry = makeEntry({ value: 'red', selector: '::ng-deep .child' }); + expect(detectMechanism(entry, true)).toBe('encapsulation-none'); + }); + + it('encapsulation-none takes priority over :root[data-theme]', () => { + const entry = makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }); + expect(detectMechanism(entry, true)).toBe('encapsulation-none'); + }); + + it('encapsulation-none takes priority over class selector', () => { + const entry = makeEntry({ value: 'red', selector: '.wrapper' }); + expect(detectMechanism(entry, true)).toBe('encapsulation-none'); + }); + }); + + // --- Priority 3: ::ng-deep --- + describe('::ng-deep (priority 3)', () => { + it('returns "ng-deep" when selector contains ::ng-deep', () => { + const entry = makeEntry({ value: 'red', selector: '::ng-deep .child' }); + expect(detectMechanism(entry, false)).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, false)).toBe('ng-deep'); + }); + }); + + // --- Priority 4: :root[data-theme] --- + describe(':root[data-theme] (priority 4)', () => { + it('returns "root-theme" when selector contains :root[data-theme', () => { + const entry = makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }); + expect(detectMechanism(entry, false)).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, false)).toBe('root-theme'); + }); + }); + + // --- Priority 5: :host --- + describe(':host (priority 5)', () => { + it('returns "host" when selector contains :host', () => { + const entry = makeEntry({ value: 'red', selector: ':host' }); + expect(detectMechanism(entry, false)).toBe('host'); + }); + + it('returns "host" for :host with context selector', () => { + const entry = makeEntry({ value: 'red', selector: ':host(.active)' }); + expect(detectMechanism(entry, false)).toBe('host'); + }); + + it('returns "host" for :host-context', () => { + const entry = makeEntry({ + value: 'red', + selector: ':host-context(.theme-dark)', + }); + expect(detectMechanism(entry, false)).toBe('host'); + }); + }); + + // --- Priority 6: class-selector --- + describe('class-selector (priority 6)', () => { + it('returns "class-selector" for a simple class selector', () => { + const entry = makeEntry({ value: 'red', selector: '.my-class' }); + expect(detectMechanism(entry, false)).toBe('class-selector'); + }); + + it('returns "class-selector" for compound class selector', () => { + const entry = makeEntry({ value: 'red', selector: '.wrapper .inner' }); + expect(detectMechanism(entry, false)).toBe('class-selector'); + }); + + it('returns "class-selector" for element.class selector', () => { + const entry = makeEntry({ value: 'red', selector: 'div.container' }); + expect(detectMechanism(entry, false)).toBe('class-selector'); + }); + }); + + // --- Fallback: host --- + describe('fallback to host', () => { + it('returns "host" for bare element selector', () => { + const entry = makeEntry({ value: 'red', selector: 'div' }); + expect(detectMechanism(entry, false)).toBe('host'); + }); + + it('returns "host" for empty selector', () => { + const entry = makeEntry({ value: 'red', selector: '' }); + expect(detectMechanism(entry, false)).toBe('host'); + }); + + it('returns "host" for :root without data-theme', () => { + const entry = makeEntry({ value: 'red', selector: ':root' }); + expect(detectMechanism(entry, false)).toBe('host'); + }); + }); + + // --- Determinism --- + describe('determinism', () => { + it('same inputs always produce the same output', () => { + const testCases: Array<{ + entry: ScssPropertyEntry; + isEncapNone: boolean; + }> = [ + { + entry: makeEntry({ value: 'red !important', selector: ':host' }), + isEncapNone: false, + }, + { + entry: makeEntry({ value: 'red', selector: ':host' }), + isEncapNone: true, + }, + { + entry: makeEntry({ value: 'red', selector: '::ng-deep .child' }), + isEncapNone: false, + }, + { + entry: makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }), + isEncapNone: false, + }, + { + entry: makeEntry({ value: 'red', selector: ':host' }), + isEncapNone: false, + }, + { + entry: makeEntry({ value: 'red', selector: '.my-class' }), + isEncapNone: false, + }, + { + entry: makeEntry({ value: 'red', selector: 'div' }), + isEncapNone: false, + }, + ]; + + for (const { entry, isEncapNone } of testCases) { + const result1 = detectMechanism(entry, isEncapNone); + const result2 = detectMechanism(entry, isEncapNone); + const result3 = detectMechanism(entry, isEncapNone); + expect(result1).toBe(result2); + expect(result2).toBe(result3); + } + }); + + it('returns consistent results across all 6 mechanism types', () => { + const cases: Array<{ + entry: ScssPropertyEntry; + isEncapNone: boolean; + expected: OverrideMechanism; + }> = [ + { + entry: makeEntry({ value: 'red !important', selector: ':host' }), + isEncapNone: false, + expected: 'important', + }, + { + entry: makeEntry({ value: 'red', selector: ':host' }), + isEncapNone: true, + expected: 'encapsulation-none', + }, + { + entry: makeEntry({ value: 'red', selector: '::ng-deep .child' }), + isEncapNone: false, + expected: 'ng-deep', + }, + { + entry: makeEntry({ + value: 'red', + selector: ':root[data-theme="dark"]', + }), + isEncapNone: false, + expected: 'root-theme', + }, + { + entry: makeEntry({ value: 'red', selector: ':host' }), + isEncapNone: false, + expected: 'host', + }, + { + entry: makeEntry({ value: 'red', selector: '.wrapper' }), + isEncapNone: false, + expected: 'class-selector', + }, + ]; + + for (const { entry, isEncapNone, expected } of cases) { + expect(detectMechanism(entry, isEncapNone)).toBe(expected); + } + }); + }); +}); + +// --------------------------------------------------------------------------- +// classifyOverride — supplementary tests +// --------------------------------------------------------------------------- + +describe('classifyOverride', () => { + it('returns "encapsulation-none" when isEncapsulationNone is true', () => { + const entry = makeEntry({ selector: ':host' }); + expect(classifyOverride(entry, undefined, true)).toBe('encapsulation-none'); + }); + + it('returns "important-override" when value contains !important', () => { + const entry = makeEntry({ value: 'red !important', selector: ':host' }); + expect(classifyOverride(entry, undefined, false)).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, false)).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, false)).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, false)).toBe('legitimate'); + }); + + it('returns "component-override" when selector contains :host', () => { + const entry = makeEntry({ value: 'red', selector: ':host' }); + expect(classifyOverride(entry, undefined, false)).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, false)).toBe('inline-override'); + }); + + it('returns "scope-violation" as fallback', () => { + const entry = makeEntry({ value: 'red', selector: 'div' }); + expect(classifyOverride(entry, undefined, false)).toBe('scope-violation'); + }); +}); 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..505c779 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/validate-mode.ts @@ -0,0 +1,217 @@ +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 { + semanticPrefix: string | null; + componentPrefix: string; + 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. + */ +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 <= 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. Validate each reference against the `TokenDataset`. + * 4. Compute typo suggestions via Levenshtein distance (threshold ≤ 3). + * 5. Optionally check brand-specific token availability. + */ +export async function runValidateMode( + styleFiles: string[], + tokenDataset: TokenDataset, + options: ValidateModeOptions, +): Promise { + const semanticValid: ValidTokenRef[] = []; + const semanticInvalid: InvalidTokenRef[] = []; + const componentValid: ValidTokenRef[] = []; + const componentInvalid: InvalidTokenRef[] = []; + const brandWarnings: BrandSpecificWarning[] = []; + + for (const filePath of styleFiles) { + const parsed = await parseScssValues(filePath, { + componentTokenPrefix: options.componentPrefix, + }); + 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) { + // --- Semantic token validation --- + if ( + options.semanticPrefix && + tokenName.startsWith(options.semanticPrefix) + ) { + 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 { + const suggestion = findClosestToken( + tokenName, + tokenDataset, + options.semanticPrefix, + ); + semanticInvalid.push({ + token: tokenName, + file: relPath, + line: entry.line, + ...(suggestion && { + suggestion: suggestion.name, + editDistance: suggestion.distance, + }), + }); + } + } + + // --- Component token validation --- + if (tokenName.startsWith(options.componentPrefix)) { + const existing = tokenDataset.getByName(tokenName); + + if (existing) { + componentValid.push({ + token: tokenName, + file: relPath, + line: entry.line, + }); + } else { + const suggestion = findClosestToken( + tokenName, + tokenDataset, + options.componentPrefix, + ); + componentInvalid.push({ + token: tokenName, + file: relPath, + line: entry.line, + ...(suggestion && { + suggestion: suggestion.name, + editDistance: suggestion.distance, + }), + }); + } + } + } + } + } + + return { + semantic: { valid: semanticValid, invalid: semanticInvalid }, + component: { valid: componentValid, invalid: componentInvalid }, + ...(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..724feda 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/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}`; From bbc772b5971a6bc384909d78af9448c9b4c4c41a Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Wed, 15 Apr 2026 08:53:16 +0200 Subject: [PATCH 2/3] refactor: skip encapsulation detecting --- .../audit-token-usage.tool.ts | 80 ++++--- .../ds/audit-token-usage/models/types.ts | 8 +- .../spec/audit-token-usage.tool.spec.ts | 112 +++------ .../spec/graceful-degradation.spec.ts | 1 - .../utils/encapsulation-detector.ts | 79 ------- .../utils/override-classifier.ts | 38 ++-- .../audit-token-usage/utils/overrides-mode.ts | 103 +++++---- .../utils/spec/overrides-mode.spec.ts | 213 ++++++------------ .../audit-token-usage/utils/validate-mode.ts | 44 +--- 9 files changed, 209 insertions(+), 469 deletions(-) delete mode 100644 packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts 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 index 5a5c870..bcbfa16 100644 --- 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 @@ -10,6 +10,7 @@ import { 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'; @@ -31,9 +32,22 @@ import { runOverridesMode } from './utils/overrides-mode.js'; const AUDIT_OUTPUT_SUBDIR = 'audit-token-usage'; // --------------------------------------------------------------------------- -// Exported helpers (for testing in Task 7.2) +// Exported helpers // --------------------------------------------------------------------------- +/** + * Derives the token prefix from the dataset when not explicitly configured. + * Extracts the leading segment from the first token name + * (e.g. '--semantic-color-primary' → '--semantic-'). + * Falls back to '--semantic-' if the dataset is empty or the pattern doesn't match. + */ +export function deriveSemanticPrefix(dataset: TokenDataset): string { + const first = dataset.tokens[0]; + if (!first) return '--semantic-'; + const match = first.name.match(/^(--[\w]+-)/); + return match ? match[1] : '--semantic-'; +} + /** * Resolves the active audit modes from the user-provided `modes` parameter. * Default (`undefined` or `'all'`) → both modes. @@ -55,19 +69,13 @@ export function buildSummary( overridesResult: OverridesResult | undefined, ): AuditSummary { const validateIssues = validateResult - ? validateResult.semantic.invalid.length + - validateResult.component.invalid.length + ? 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; - } + if (validateResult !== undefined) byMode.validate = validateIssues; + if (overridesResult !== undefined) byMode.overrides = overridesIssues; return { totalIssues: validateIssues + overridesIssues, @@ -84,10 +92,8 @@ function applyExcludePatterns( 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(globToRegex); return files.filter( (f) => !regexes.some((re) => re.test(f.replace(/\\/g, '/'))), @@ -107,13 +113,12 @@ async function persistResult( result: AuditTokenUsageResult, directory: string, cwd: string, -): Promise { +): 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'); - return filePath; } // --------------------------------------------------------------------------- @@ -152,7 +157,7 @@ export function formatAuditResult(result: AuditTokenUsageResult): string[] { // ── Invalid tokens (validate mode) ── if (result.validate) { - const { semantic, component } = result.validate; + const { semantic } = result.validate; if (semantic.invalid.length > 0) { lines.push(''); @@ -163,20 +168,8 @@ export function formatAuditResult(result: AuditTokenUsageResult): string[] { let entry = ` ${ref.token} ${ref.file}:${ref.line}`; if (ref.suggestion) { entry += ` → did you mean "${ref.suggestion}"? (distance ${ref.editDistance})`; - } - lines.push(entry); - } - } - - if (component.invalid.length > 0) { - lines.push(''); - lines.push(DIVIDER); - lines.push(`❌ Invalid component tokens (${component.invalid.length})`); - lines.push(DIVIDER); - for (const ref of component.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); } @@ -205,7 +198,7 @@ export function formatAuditResult(result: AuditTokenUsageResult): string[] { lines.push(DIVIDER); for (const item of result.overrides.items) { let entry = ` ${item.token} ${item.file}:${item.line} [${item.mechanism}]`; - if (item.classification && item.classification !== item.mechanism) { + if (item.classification) { entry += ` (${item.classification})`; } if (item.newValue) { @@ -219,7 +212,7 @@ export function formatAuditResult(result: AuditTokenUsageResult): string[] { } lines.push(''); - lines.push(` Mechanism breakdown:`); + lines.push(`\r\n Mechanism breakdown:`); for (const [mechanism, count] of Object.entries( result.overrides.byMechanism, )) { @@ -227,6 +220,7 @@ export function formatAuditResult(result: AuditTokenUsageResult): string[] { } if (result.overrides.byClassification) { + lines.push(''); lines.push(` Classification breakdown:`); for (const [classification, count] of Object.entries( result.overrides.byClassification, @@ -255,11 +249,9 @@ async function handleAuditTokenUsage( let styleFiles = await findStyleFiles(absDir); styleFiles = applyExcludePatterns(styleFiles, params.excludePatterns); - // 3. Resolve token prefixes - const semanticPrefix = + // 3. Resolve token prefix: explicit param > config > derived from dataset > null + const configuredPrefix = params.tokenPrefix ?? context.tokensConfig?.propertyPrefix ?? null; - const componentPrefix = - params.tokenPrefix ?? context.tokensConfig?.componentTokenPrefix ?? '--ds-'; // 4. Load token dataset (if generatedStylesRoot available) const tokenDataset = @@ -275,6 +267,13 @@ async function handleAuditTokenUsage( let validateResult: ValidateResult | undefined; let overridesResult: OverridesResult | undefined; + // Derive tokenPrefix: configured value takes priority, otherwise infer from dataset + const tokenPrefix = + configuredPrefix ?? + (tokenDataset && !tokenDataset.isEmpty + ? deriveSemanticPrefix(tokenDataset) + : null); + // 5. Run validate mode if (activeModes.includes('validate')) { if (!tokenDataset) { @@ -288,8 +287,7 @@ async function handleAuditTokenUsage( ); } else { validateResult = await runValidateMode(styleFiles, tokenDataset, { - semanticPrefix, - componentPrefix, + tokenPrefix, brandName: params.brandName, componentName: params.componentName, cwd: context.cwd, @@ -306,8 +304,7 @@ async function handleAuditTokenUsage( } overridesResult = await runOverridesMode(styleFiles, { tokenDataset, - componentPrefix, - semanticPrefix, + tokenPrefix, cwd: context.cwd, workspaceRoot: context.workspaceRoot, }); @@ -331,10 +328,9 @@ async function handleAuditTokenUsage( ); } - // 8. Build summary + // 8. Build summary and assemble result const summary = buildSummary(validateResult, overridesResult); - // 9. Assemble result const result: AuditTokenUsageResult = { ...(validateResult && { validate: validateResult }), ...(overridesResult && { overrides: overridesResult }), @@ -342,7 +338,7 @@ async function handleAuditTokenUsage( diagnostics, }; - // 10. Persist if requested + // 9. Persist if requested if (params.saveAsFile) { await persistResult(result, params.directory, context.cwd); } 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 index 1af46cd..e8af132 100644 --- 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 @@ -46,10 +46,6 @@ export interface ValidateResult { valid: ValidTokenRef[]; invalid: InvalidTokenRef[]; }; - component: { - valid: ValidTokenRef[]; - invalid: InvalidTokenRef[]; - }; brandWarnings?: BrandSpecificWarning[]; } @@ -62,8 +58,7 @@ export type OverrideMechanism = | 'ng-deep' | 'class-selector' | 'root-theme' - | 'important' - | 'encapsulation-none'; + | 'important'; export type OverrideClassification = | 'legitimate' @@ -71,7 +66,6 @@ export type OverrideClassification = | 'inline-override' | 'deep-override' | 'important-override' - | 'encapsulation-none' | 'scope-violation'; export interface OverrideItem { 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 index ce85276..aceb1b8 100644 --- 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 @@ -24,10 +24,7 @@ import type { // Helpers // --------------------------------------------------------------------------- -function makeValidateResult( - semanticInvalidCount: number, - componentInvalidCount: number, -): ValidateResult { +function makeValidateResult(semanticInvalidCount: number): ValidateResult { return { semantic: { valid: [], @@ -37,14 +34,6 @@ function makeValidateResult( line: i + 1, })), }, - component: { - valid: [], - invalid: Array.from({ length: componentInvalidCount }, (_, i) => ({ - token: `--ds-invalid-${i}`, - file: `file-${i}.scss`, - line: i + 1, - })), - }, }; } @@ -112,17 +101,10 @@ describe('resolveActiveModes', () => { // --------------------------------------------------------------------------- describe('Property 6: Output structure matches active modes', () => { - /** - * For any combination of active modes, the AuditTokenUsageResult must - * contain a key for each active mode and must not contain a key for - * inactive modes. The summary and diagnostics keys must always be present. - * **Validates: Requirements 11.1** - */ - it('both modes active: result has validate, overrides, summary, diagnostics', () => { const result = assembleResult( ['validate', 'overrides'], - makeValidateResult(1, 0), + makeValidateResult(1), makeOverridesResult(2), ); @@ -133,11 +115,7 @@ describe('Property 6: Output structure matches active modes', () => { }); it('only validate active: result has validate but not overrides', () => { - const result = assembleResult( - ['validate'], - makeValidateResult(2, 1), - undefined, - ); + const result = assembleResult(['validate'], makeValidateResult(2)); expect(result).toHaveProperty('validate'); expect(result).not.toHaveProperty('overrides'); @@ -161,7 +139,7 @@ describe('Property 6: Output structure matches active modes', () => { it('summary is always present even with zero issues', () => { const result = assembleResult( ['validate', 'overrides'], - makeValidateResult(0, 0), + makeValidateResult(0), makeOverridesResult(0), ); @@ -171,12 +149,9 @@ describe('Property 6: Output structure matches active modes', () => { }); it('diagnostics is always present and is an array', () => { - const result = assembleResult( - ['validate'], - makeValidateResult(0, 0), - undefined, - ['validate mode skipped: token dataset is empty'], - ); + 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); @@ -185,7 +160,7 @@ describe('Property 6: Output structure matches active modes', () => { it('diagnostics is an empty array when no diagnostics exist', () => { const result = assembleResult( ['validate', 'overrides'], - makeValidateResult(1, 0), + makeValidateResult(1), makeOverridesResult(1), ); @@ -193,16 +168,10 @@ describe('Property 6: Output structure matches active modes', () => { }); it('summary.byMode contains only keys for active modes with results', () => { - // Only validate active - const validateOnly = assembleResult( - ['validate'], - makeValidateResult(2, 1), - undefined, - ); + const validateOnly = assembleResult(['validate'], makeValidateResult(2)); expect(validateOnly.summary.byMode).toHaveProperty('validate'); expect(validateOnly.summary.byMode).not.toHaveProperty('overrides'); - // Only overrides active const overridesOnly = assembleResult( ['overrides'], undefined, @@ -211,10 +180,9 @@ describe('Property 6: Output structure matches active modes', () => { expect(overridesOnly.summary.byMode).not.toHaveProperty('validate'); expect(overridesOnly.summary.byMode).toHaveProperty('overrides'); - // Both active const both = assembleResult( ['validate', 'overrides'], - makeValidateResult(1, 1), + makeValidateResult(1), makeOverridesResult(2), ); expect(both.summary.byMode).toHaveProperty('validate'); @@ -227,39 +195,22 @@ describe('Property 6: Output structure matches active modes', () => { // --------------------------------------------------------------------------- describe('Property 7: Summary counts match actual issues', () => { - /** - * summary.totalIssues must equal the sum of validate.semantic.invalid.length + - * validate.component.invalid.length (when validate is present) plus - * overrides.items.length (when overrides is present). - * summary.byMode.validate must equal the count of invalid tokens, and - * summary.byMode.overrides must equal the count of override items. - * **Validates: Requirements 11.4** - */ - it('totalIssues equals invalid tokens + override items (both modes)', () => { - const validateResult = makeValidateResult(3, 2); // 5 invalid tokens - const overridesResult = makeOverridesResult(4); // 4 overrides + const validateResult = makeValidateResult(5); + const overridesResult = makeOverridesResult(4); const summary = buildSummary(validateResult, overridesResult); - const expectedValidateIssues = - validateResult.semantic.invalid.length + - validateResult.component.invalid.length; - const expectedOverridesIssues = overridesResult.items.length; - expect(summary.totalIssues).toBe( - expectedValidateIssues + expectedOverridesIssues, + validateResult.semantic.invalid.length + overridesResult.items.length, ); expect(summary.totalIssues).toBe(9); }); - it('byMode.validate equals semantic.invalid + component.invalid count', () => { - const validateResult = makeValidateResult(4, 3); // 7 invalid tokens + 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 + - validateResult.component.invalid.length, - ); + expect(summary.byMode.validate).toBe(validateResult.semantic.invalid.length); expect(summary.byMode.validate).toBe(7); }); @@ -272,10 +223,7 @@ describe('Property 7: Summary counts match actual issues', () => { }); it('totalIssues is 0 when both modes have zero issues', () => { - const summary = buildSummary( - makeValidateResult(0, 0), - makeOverridesResult(0), - ); + const summary = buildSummary(makeValidateResult(0), makeOverridesResult(0)); expect(summary.totalIssues).toBe(0); expect(summary.byMode.validate).toBe(0); @@ -283,7 +231,7 @@ describe('Property 7: Summary counts match actual issues', () => { }); it('totalIssues counts only validate issues when overrides is undefined', () => { - const validateResult = makeValidateResult(2, 3); + const validateResult = makeValidateResult(5); const summary = buildSummary(validateResult, undefined); expect(summary.totalIssues).toBe(5); @@ -309,8 +257,8 @@ describe('Property 7: Summary counts match actual issues', () => { }); it('byMode breakdown sums to totalIssues', () => { - const validateResult = makeValidateResult(6, 2); // 8 invalid - const overridesResult = makeOverridesResult(3); // 3 overrides + const validateResult = makeValidateResult(6); + const overridesResult = makeOverridesResult(3); const summary = buildSummary(validateResult, overridesResult); const byModeSum = @@ -319,8 +267,8 @@ describe('Property 7: Summary counts match actual issues', () => { }); it('handles large counts correctly', () => { - const validateResult = makeValidateResult(50, 30); // 80 invalid - const overridesResult = makeOverridesResult(100); // 100 overrides + const validateResult = makeValidateResult(80); + const overridesResult = makeOverridesResult(100); const summary = buildSummary(validateResult, overridesResult); expect(summary.totalIssues).toBe(180); @@ -337,7 +285,7 @@ describe('formatAuditResult', () => { it('includes summary line with total issues', () => { const result = assembleResult( ['validate', 'overrides'], - makeValidateResult(2, 1), + makeValidateResult(3), makeOverridesResult(3), ); const lines = formatAuditResult(result); @@ -347,22 +295,14 @@ describe('formatAuditResult', () => { }); it('includes invalid token count in summary line', () => { - const result = assembleResult( - ['validate'], - makeValidateResult(3, 0), - undefined, - ); + 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 result = assembleResult(['overrides'], undefined, makeOverridesResult(5)); const lines = formatAuditResult(result); expect(lines[0]).toContain('5 override(s)'); @@ -384,7 +324,7 @@ describe('formatAuditResult', () => { it('returns array of strings', () => { const result = assembleResult( ['validate', 'overrides'], - makeValidateResult(1, 1), + makeValidateResult(1), makeOverridesResult(1), ); const lines = formatAuditResult(result); 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 index 4506be2..64f4422 100644 --- 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 @@ -48,7 +48,6 @@ function simulateValidateDegradation( // Would run validate mode — return a minimal valid result validateResult = { semantic: { valid: [], invalid: [] }, - component: { valid: [], invalid: [] }, }; } diff --git a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts deleted file mode 100644 index 541cd08..0000000 --- a/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/encapsulation-detector.ts +++ /dev/null @@ -1,79 +0,0 @@ -import * as ts from 'typescript'; -import { readFile, access } from 'node:fs/promises'; -import * as path from 'node:path'; -import { - getDecorators, - isComponentDecorator, -} from '@push-based/typescript-ast-utils'; - -/** - * Scans for .ts files adjacent to the given style files and detects - * components with ViewEncapsulation.None. - * - * Convention: `foo.component.scss` → `foo.component.ts` - * - * Returns a Set of style file paths whose component uses None encapsulation. - */ -export async function detectViewEncapsulationNone( - styleFiles: string[], -): Promise> { - const result = new Set(); - - for (const styleFile of styleFiles) { - const dir = path.dirname(styleFile); - const baseName = path.basename(styleFile, path.extname(styleFile)); - // Convention: foo.component.scss → foo.component.ts - const tsFile = path.join(dir, baseName + '.ts'); - - try { - await access(tsFile); - } catch { - continue; - } - - const content = await readFile(tsFile, 'utf-8'); - const sourceFile = ts.createSourceFile( - tsFile, - content, - ts.ScriptTarget.Latest, - true, - ); - - ts.forEachChild(sourceFile, (node) => { - if (ts.isClassDeclaration(node)) { - const decorators = getDecorators(node); - for (const decorator of decorators) { - if (isComponentDecorator(decorator)) { - if (hasEncapsulationNone(decorator)) { - result.add(styleFile); - } - } - } - } - }); - } - - return result; -} - -/** - * Checks whether a @Component decorator contains `encapsulation: ViewEncapsulation.None`. - */ -function hasEncapsulationNone(decorator: ts.Decorator): boolean { - const expr = decorator.expression; - if (!ts.isCallExpression(expr)) return false; - - for (const arg of expr.arguments) { - if (!ts.isObjectLiteralExpression(arg)) continue; - for (const prop of arg.properties) { - if ( - ts.isPropertyAssignment(prop) && - prop.name.getText() === 'encapsulation' - ) { - const value = prop.initializer.getText(); - if (value.includes('None')) return true; - } - } - } - return false; -} 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 index dee4dbe..51a1cd8 100644 --- 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 @@ -13,22 +13,20 @@ import type { /** * Determines the CSS mechanism used for a token override. * + * Note: `ViewEncapsulation.None` is handled separately in the encapsulation + * section, so it's not included here. + * * Priority order (first match wins): * 1. `!important` in value - * 2. `ViewEncapsulation.None` on the component - * 3. `::ng-deep` in selector - * 4. `:root[data-theme` in selector - * 5. `:host` in selector - * 6. Class selector (`.className`) in selector - * 7. Fallback: `host` + * 2. `::ng-deep` in selector + * 3. `:root[data-theme` in selector + * 4. `:host` in selector + * 5. Class selector (`.className`) in selector + * 6. Fallback: `host` */ -export function detectMechanism( - entry: ScssPropertyEntry, - isEncapsulationNone: boolean, -): OverrideMechanism { +export function detectMechanism(entry: ScssPropertyEntry): OverrideMechanism { const selector = entry.selector; if (entry.value.includes('!important')) return 'important'; - if (isEncapsulationNone) return 'encapsulation-none'; if (selector.includes('::ng-deep')) return 'ng-deep'; if (selector.includes(':root[data-theme')) return 'root-theme'; if (selector.includes(':host')) return 'host'; @@ -44,21 +42,21 @@ export function detectMechanism( /** * Classifies a token override by intent. * + * Note: `encapsulation-none` is handled separately in the encapsulation + * section, so it's not included here. + * * Classification priority: - * 1. `encapsulation-none` — component uses `ViewEncapsulation.None` - * 2. `important-override` — value contains `!important` - * 3. `deep-override` — selector uses `::ng-deep` - * 4. `legitimate` — original token has a theme scope, or selector is `:root[data-theme` - * 5. `component-override` — selector uses `:host` - * 6. `inline-override` — selector uses a class selector - * 7. `scope-violation` — fallback for unrecognised patterns + * 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, - isEncapsulationNone: boolean, ): OverrideClassification { - if (isEncapsulationNone) return 'encapsulation-none'; if (entry.value.includes('!important')) return 'important-override'; if (entry.selector.includes('::ng-deep')) return 'deep-override'; 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 index 59cfe97..5a9dc5e 100644 --- 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 @@ -4,7 +4,6 @@ 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 { detectViewEncapsulationNone } from './encapsulation-detector.js'; import type { OverrideItem, OverridesResult } from '../models/types.js'; // --------------------------------------------------------------------------- @@ -13,8 +12,7 @@ import type { OverrideItem, OverridesResult } from '../models/types.js'; export interface OverridesModeOptions { tokenDataset: TokenDataset | null; - componentPrefix: string; - semanticPrefix: string | null; + tokenPrefix: string | null; cwd: string; workspaceRoot: string; } @@ -27,11 +25,10 @@ export interface OverridesModeOptions { * Runs the **overrides** mode pipeline. * * For every style file: - * 1. Detect `ViewEncapsulation.None` components. - * 2. Parse with `parseScssValues` to get classified entries. - * 3. Iterate declarations (token overrides) and consumptions (`!important`). - * 4. Detect the override mechanism for each entry. - * 5. Optionally classify the override when `tokenDataset` is available. + * 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. @@ -40,26 +37,30 @@ export async function runOverridesMode( styleFiles: string[], options: OverridesModeOptions, ): Promise { - const items: OverrideItem[] = []; + const overrideItems: OverrideItem[] = []; const mechanismCounts: Record = {}; const classificationCounts: Record = {}; - // Detect ViewEncapsulation.None components - const encapsulationNoneFiles = await detectViewEncapsulationNone(styleFiles); - for (const filePath of styleFiles) { - const parsed = await parseScssValues(filePath, { - componentTokenPrefix: options.componentPrefix, - }); + const parsed = await parseScssValues(filePath); const declarations = parsed.getDeclarations(); + const consumptions = parsed.getConsumptions(); const relPath = path.relative(options.cwd, filePath); - // Check if this file's component has ViewEncapsulation.None - const isEncapsulationNone = encapsulationNoneFiles.has(filePath); - - // --- Token declarations = overrides in consumer files --- + // --- Token declarations --- for (const entry of declarations) { - const mechanism = detectMechanism(entry, isEncapsulationNone); + 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 = { @@ -70,53 +71,57 @@ export async function runOverridesMode( mechanism, }; - // Add originalValue and classification when token dataset is available if (options.tokenDataset) { const original = options.tokenDataset.getByName(entry.property); if (original) { item.originalValue = original.value; } - const classification = classifyOverride( - entry, - original, - isEncapsulationNone, - ); + const classification = classifyOverride(entry, original); item.classification = classification; classificationCounts[classification] = (classificationCounts[classification] ?? 0) + 1; } - items.push(item); + overrideItems.push(item); } - // --- Consumptions with !important --- - const consumptions = parsed.getConsumptions(); + // --- Token consumptions — detect !important only --- for (const entry of consumptions) { - if (entry.value.includes('!important')) { - const mechanism = 'important' as const; - mechanismCounts[mechanism] = (mechanismCounts[mechanism] ?? 0) + 1; - - items.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; - } + if (!entry.value.includes('!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, + 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/overrides-mode.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/spec/overrides-mode.spec.ts index f44519c..4ec8240 100644 --- 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 @@ -11,6 +11,9 @@ import type { OverrideMechanism, OverrideItem } from '../../models/types.js'; * 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 + * + * Note: ViewEncapsulation.None is now handled separately in the encapsulation section, + * so it's not tested here as a mechanism. */ // --------------------------------------------------------------------------- @@ -23,7 +26,6 @@ const VALID_MECHANISMS: OverrideMechanism[] = [ 'class-selector', 'root-theme', 'important', - 'encapsulation-none', ]; function makeEntry( @@ -50,13 +52,15 @@ function makeTokenEntry( /** * Simulates the overrides-mode pipeline mapping for a single declaration entry. * This mirrors the core logic in `runOverridesMode` without file I/O. + * + * Note: Encapsulation-none entries are now handled separately and don't go + * through this path. */ function buildOverrideItem( entry: ScssPropertyEntry, - isEncapsulationNone: boolean, relPath: string, ): OverrideItem { - const mechanism = detectMechanism(entry, isEncapsulationNone); + const mechanism = detectMechanism(entry); return { file: relPath, line: entry.line, @@ -86,7 +90,7 @@ describe('Property 4: Declaration completeness', () => { classification: 'declaration', }); - const item = buildOverrideItem(entry, false, 'src/card.component.scss'); + const item = buildOverrideItem(entry, 'src/card.component.scss'); expect(item.token).toBe(entry.property); expect(item.newValue).toBe(entry.value); @@ -118,7 +122,7 @@ describe('Property 4: Declaration completeness', () => { ]; const items = entries.map((e) => - buildOverrideItem(e, false, 'components/button.scss'), + buildOverrideItem(e, 'components/button.scss'), ); expect(items).toHaveLength(entries.length); @@ -143,7 +147,7 @@ describe('Property 4: Declaration completeness', () => { for (const selector of selectors) { const entry = makeEntry({ selector, classification: 'declaration' }); - const item = buildOverrideItem(entry, false, 'test.scss'); + const item = buildOverrideItem(entry, 'test.scss'); expect(VALID_MECHANISMS).toContain(item.mechanism); } }); @@ -157,7 +161,7 @@ describe('Property 4: Declaration completeness', () => { for (const tokenName of tokenNames) { const entry = makeEntry({ property: tokenName }); - const item = buildOverrideItem(entry, false, 'file.scss'); + const item = buildOverrideItem(entry, 'file.scss'); expect(item.token).toBe(tokenName); } }); @@ -173,7 +177,7 @@ describe('Property 4: Declaration completeness', () => { for (const value of values) { const entry = makeEntry({ value }); - const item = buildOverrideItem(entry, false, 'file.scss'); + const item = buildOverrideItem(entry, 'file.scss'); expect(item.newValue).toBe(value); } }); @@ -183,23 +187,10 @@ describe('Property 4: Declaration completeness', () => { for (const line of lines) { const entry = makeEntry({ line }); - const item = buildOverrideItem(entry, false, 'file.scss'); + const item = buildOverrideItem(entry, 'file.scss'); expect(item.line).toBe(line); } }); - - it('handles encapsulation-none flag for declaration entries', () => { - const entry = makeEntry({ - selector: ':host', - classification: 'declaration', - }); - const item = buildOverrideItem(entry, true, 'encap.scss'); - - expect(item.token).toBe(entry.property); - expect(item.newValue).toBe(entry.value); - expect(item.mechanism).toBe('encapsulation-none'); - expect(VALID_MECHANISMS).toContain(item.mechanism); - }); }); // --------------------------------------------------------------------------- @@ -211,6 +202,10 @@ describe('Property 5: Mechanism determinism', () => { * detectMechanism returns expected mechanism for each selector/value * combination following priority rules, and same inputs always produce * same output. + * + * Note: ViewEncapsulation.None is now handled separately in the encapsulation + * section, so it's not tested here. + * * **Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5** */ @@ -218,12 +213,7 @@ describe('Property 5: Mechanism determinism', () => { describe('!important (highest priority)', () => { it('returns "important" when value contains !important', () => { const entry = makeEntry({ value: 'red !important', selector: ':host' }); - expect(detectMechanism(entry, false)).toBe('important'); - }); - - it('!important takes priority over encapsulation-none', () => { - const entry = makeEntry({ value: 'red !important', selector: ':host' }); - expect(detectMechanism(entry, true)).toBe('important'); + expect(detectMechanism(entry)).toBe('important'); }); it('!important takes priority over ::ng-deep', () => { @@ -231,7 +221,7 @@ describe('Property 5: Mechanism determinism', () => { value: 'red !important', selector: '::ng-deep .child', }); - expect(detectMechanism(entry, false)).toBe('important'); + expect(detectMechanism(entry)).toBe('important'); }); it('!important takes priority over :root[data-theme]', () => { @@ -239,7 +229,7 @@ describe('Property 5: Mechanism determinism', () => { value: 'blue !important', selector: ':root[data-theme="dark"]', }); - expect(detectMechanism(entry, false)).toBe('important'); + expect(detectMechanism(entry)).toBe('important'); }); it('!important takes priority over class selector', () => { @@ -247,41 +237,15 @@ describe('Property 5: Mechanism determinism', () => { value: '10px !important', selector: '.my-class', }); - expect(detectMechanism(entry, false)).toBe('important'); + expect(detectMechanism(entry)).toBe('important'); }); }); - // --- Priority 2: encapsulation-none --- - describe('encapsulation-none (priority 2)', () => { - it('returns "encapsulation-none" when flag is true and no !important', () => { - const entry = makeEntry({ value: 'red', selector: ':host' }); - expect(detectMechanism(entry, true)).toBe('encapsulation-none'); - }); - - it('encapsulation-none takes priority over ::ng-deep', () => { - const entry = makeEntry({ value: 'red', selector: '::ng-deep .child' }); - expect(detectMechanism(entry, true)).toBe('encapsulation-none'); - }); - - it('encapsulation-none takes priority over :root[data-theme]', () => { - const entry = makeEntry({ - value: 'red', - selector: ':root[data-theme="dark"]', - }); - expect(detectMechanism(entry, true)).toBe('encapsulation-none'); - }); - - it('encapsulation-none takes priority over class selector', () => { - const entry = makeEntry({ value: 'red', selector: '.wrapper' }); - expect(detectMechanism(entry, true)).toBe('encapsulation-none'); - }); - }); - - // --- Priority 3: ::ng-deep --- - describe('::ng-deep (priority 3)', () => { + // --- 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, false)).toBe('ng-deep'); + expect(detectMechanism(entry)).toBe('ng-deep'); }); it('returns "ng-deep" for ::ng-deep with :host prefix', () => { @@ -289,36 +253,36 @@ describe('Property 5: Mechanism determinism', () => { value: 'red', selector: ':host ::ng-deep .inner', }); - expect(detectMechanism(entry, false)).toBe('ng-deep'); + expect(detectMechanism(entry)).toBe('ng-deep'); }); }); - // --- Priority 4: :root[data-theme] --- - describe(':root[data-theme] (priority 4)', () => { + // --- 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, false)).toBe('root-theme'); + 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, false)).toBe('root-theme'); + expect(detectMechanism(entry)).toBe('root-theme'); }); }); - // --- Priority 5: :host --- - describe(':host (priority 5)', () => { + // --- Priority 4: :host --- + describe(':host (priority 4)', () => { it('returns "host" when selector contains :host', () => { const entry = makeEntry({ value: 'red', selector: ':host' }); - expect(detectMechanism(entry, false)).toBe('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, false)).toBe('host'); + expect(detectMechanism(entry)).toBe('host'); }); it('returns "host" for :host-context', () => { @@ -326,25 +290,25 @@ describe('Property 5: Mechanism determinism', () => { value: 'red', selector: ':host-context(.theme-dark)', }); - expect(detectMechanism(entry, false)).toBe('host'); + expect(detectMechanism(entry)).toBe('host'); }); }); - // --- Priority 6: class-selector --- - describe('class-selector (priority 6)', () => { + // --- 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, false)).toBe('class-selector'); + 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, false)).toBe('class-selector'); + 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, false)).toBe('class-selector'); + expect(detectMechanism(entry)).toBe('class-selector'); }); }); @@ -352,88 +316,52 @@ describe('Property 5: Mechanism determinism', () => { describe('fallback to host', () => { it('returns "host" for bare element selector', () => { const entry = makeEntry({ value: 'red', selector: 'div' }); - expect(detectMechanism(entry, false)).toBe('host'); + expect(detectMechanism(entry)).toBe('host'); }); it('returns "host" for empty selector', () => { const entry = makeEntry({ value: 'red', selector: '' }); - expect(detectMechanism(entry, false)).toBe('host'); + expect(detectMechanism(entry)).toBe('host'); }); it('returns "host" for :root without data-theme', () => { const entry = makeEntry({ value: 'red', selector: ':root' }); - expect(detectMechanism(entry, false)).toBe('host'); + expect(detectMechanism(entry)).toBe('host'); }); }); // --- Determinism --- describe('determinism', () => { it('same inputs always produce the same output', () => { - const testCases: Array<{ - entry: ScssPropertyEntry; - isEncapNone: boolean; - }> = [ - { - entry: makeEntry({ value: 'red !important', selector: ':host' }), - isEncapNone: false, - }, - { - entry: makeEntry({ value: 'red', selector: ':host' }), - isEncapNone: true, - }, - { - entry: makeEntry({ value: 'red', selector: '::ng-deep .child' }), - isEncapNone: false, - }, - { - entry: makeEntry({ - value: 'red', - selector: ':root[data-theme="dark"]', - }), - isEncapNone: false, - }, - { - entry: makeEntry({ value: 'red', selector: ':host' }), - isEncapNone: false, - }, - { - entry: makeEntry({ value: 'red', selector: '.my-class' }), - isEncapNone: false, - }, - { - entry: makeEntry({ value: 'red', selector: 'div' }), - isEncapNone: false, - }, + const testCases: ScssPropertyEntry[] = [ + makeEntry({ value: 'red !important', 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, isEncapNone } of testCases) { - const result1 = detectMechanism(entry, isEncapNone); - const result2 = detectMechanism(entry, isEncapNone); - const result3 = detectMechanism(entry, isEncapNone); + 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 6 mechanism types', () => { + it('returns consistent results across all 5 mechanism types', () => { const cases: Array<{ entry: ScssPropertyEntry; - isEncapNone: boolean; expected: OverrideMechanism; }> = [ { entry: makeEntry({ value: 'red !important', selector: ':host' }), - isEncapNone: false, expected: 'important', }, - { - entry: makeEntry({ value: 'red', selector: ':host' }), - isEncapNone: true, - expected: 'encapsulation-none', - }, { entry: makeEntry({ value: 'red', selector: '::ng-deep .child' }), - isEncapNone: false, expected: 'ng-deep', }, { @@ -441,23 +369,20 @@ describe('Property 5: Mechanism determinism', () => { value: 'red', selector: ':root[data-theme="dark"]', }), - isEncapNone: false, expected: 'root-theme', }, { entry: makeEntry({ value: 'red', selector: ':host' }), - isEncapNone: false, expected: 'host', }, { entry: makeEntry({ value: 'red', selector: '.wrapper' }), - isEncapNone: false, expected: 'class-selector', }, ]; - for (const { entry, isEncapNone, expected } of cases) { - expect(detectMechanism(entry, isEncapNone)).toBe(expected); + for (const { entry, expected } of cases) { + expect(detectMechanism(entry)).toBe(expected); } }); }); @@ -468,21 +393,19 @@ describe('Property 5: Mechanism determinism', () => { // --------------------------------------------------------------------------- describe('classifyOverride', () => { - it('returns "encapsulation-none" when isEncapsulationNone is true', () => { - const entry = makeEntry({ selector: ':host' }); - expect(classifyOverride(entry, undefined, true)).toBe('encapsulation-none'); - }); + /** + * Note: ViewEncapsulation.None is now handled separately in the encapsulation + * section, so classifyOverride no longer handles it. + */ it('returns "important-override" when value contains !important', () => { const entry = makeEntry({ value: 'red !important', selector: ':host' }); - expect(classifyOverride(entry, undefined, false)).toBe( - 'important-override', - ); + 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, false)).toBe('deep-override'); + expect(classifyOverride(entry, undefined)).toBe('deep-override'); }); it('returns "legitimate" when original token has theme scope', () => { @@ -490,7 +413,7 @@ describe('classifyOverride', () => { const original = makeTokenEntry('--ds-button-bg', '#000', { theme: 'dark', }); - expect(classifyOverride(entry, original, false)).toBe('legitimate'); + expect(classifyOverride(entry, original)).toBe('legitimate'); }); it('returns "legitimate" when selector contains :root[data-theme', () => { @@ -498,23 +421,21 @@ describe('classifyOverride', () => { value: 'red', selector: ':root[data-theme="dark"]', }); - expect(classifyOverride(entry, undefined, false)).toBe('legitimate'); + 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, false)).toBe( - 'component-override', - ); + 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, false)).toBe('inline-override'); + expect(classifyOverride(entry, undefined)).toBe('inline-override'); }); it('returns "scope-violation" as fallback', () => { const entry = makeEntry({ value: 'red', selector: 'div' }); - expect(classifyOverride(entry, undefined, false)).toBe('scope-violation'); + expect(classifyOverride(entry, undefined)).toBe('scope-violation'); }); }); 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 index 505c779..8024d83 100644 --- 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 @@ -16,8 +16,7 @@ import type { // --------------------------------------------------------------------------- export interface ValidateModeOptions { - semanticPrefix: string | null; - componentPrefix: string; + tokenPrefix: string | null; brandName?: string; componentName?: string; cwd: string; @@ -120,14 +119,10 @@ export async function runValidateMode( ): Promise { const semanticValid: ValidTokenRef[] = []; const semanticInvalid: InvalidTokenRef[] = []; - const componentValid: ValidTokenRef[] = []; - const componentInvalid: InvalidTokenRef[] = []; const brandWarnings: BrandSpecificWarning[] = []; for (const filePath of styleFiles) { - const parsed = await parseScssValues(filePath, { - componentTokenPrefix: options.componentPrefix, - }); + const parsed = await parseScssValues(filePath); const consumptions = parsed.getConsumptions(); for (const entry of consumptions) { @@ -137,8 +132,8 @@ export async function runValidateMode( for (const tokenName of tokenNames) { // --- Semantic token validation --- if ( - options.semanticPrefix && - tokenName.startsWith(options.semanticPrefix) + options.tokenPrefix && + tokenName.startsWith(options.tokenPrefix) ) { const existing = tokenDataset.getByName(tokenName); @@ -164,7 +159,7 @@ export async function runValidateMode( const suggestion = findClosestToken( tokenName, tokenDataset, - options.semanticPrefix, + options.tokenPrefix, ); semanticInvalid.push({ token: tokenName, @@ -177,41 +172,12 @@ export async function runValidateMode( }); } } - - // --- Component token validation --- - if (tokenName.startsWith(options.componentPrefix)) { - const existing = tokenDataset.getByName(tokenName); - - if (existing) { - componentValid.push({ - token: tokenName, - file: relPath, - line: entry.line, - }); - } else { - const suggestion = findClosestToken( - tokenName, - tokenDataset, - options.componentPrefix, - ); - componentInvalid.push({ - token: tokenName, - file: relPath, - line: entry.line, - ...(suggestion && { - suggestion: suggestion.name, - editDistance: suggestion.distance, - }), - }); - } - } } } } return { semantic: { valid: semanticValid, invalid: semanticInvalid }, - component: { valid: componentValid, invalid: componentInvalid }, ...(brandWarnings.length > 0 && { brandWarnings }), }; } From 4834c851b1bcfd51ca7f3091cb04b9c2f9917eaa Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Wed, 15 Apr 2026 12:28:37 +0200 Subject: [PATCH 3/3] fix(audit-token-usage): multi-prefix validation, !important detection, unknown mechanism fallback --- docs/architecture-internal-design.md | 2 +- docs/tools.md | 20 ++ .../audit-token-usage.tool.ts | 54 ++-- .../ds/audit-token-usage/models/types.ts | 3 +- .../spec/audit-token-usage.tool.spec.ts | 19 +- .../utils/override-classifier.ts | 14 +- .../audit-token-usage/utils/overrides-mode.ts | 3 +- .../utils/spec/overrides-mode.spec.ts | 271 ++++++++++++++++-- .../audit-token-usage/utils/validate-mode.ts | 93 +++--- packages/angular-mcp/src/main.ts | 2 +- .../src/lib/scss-value-parser.ts | 16 +- 11 files changed, 381 insertions(+), 116 deletions(-) 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 index bcbfa16..b0c7e22 100644 --- 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 @@ -36,16 +36,21 @@ const AUDIT_OUTPUT_SUBDIR = 'audit-token-usage'; // --------------------------------------------------------------------------- /** - * Derives the token prefix from the dataset when not explicitly configured. - * Extracts the leading segment from the first token name - * (e.g. '--semantic-color-primary' → '--semantic-'). - * Falls back to '--semantic-' if the dataset is empty or the pattern doesn't match. + * 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 deriveSemanticPrefix(dataset: TokenDataset): string { - const first = dataset.tokens[0]; - if (!first) return '--semantic-'; - const match = first.name.match(/^(--[\w]+-)/); - return match ? match[1] : '--semantic-'; +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-']; } /** @@ -94,7 +99,7 @@ function applyExcludePatterns( if (!patterns) return files; const normalized = Array.isArray(patterns) ? patterns : [patterns]; if (normalized.length === 0) return files; - const regexes = normalized.map(globToRegex); + const regexes = normalized.map((p) => globToRegex(p.replace(/\\/g, '/'))); return files.filter( (f) => !regexes.some((re) => re.test(f.replace(/\\/g, '/'))), ); @@ -105,7 +110,10 @@ function applyExcludePatterns( // --------------------------------------------------------------------------- function generateFilename(directory: string): string { - const sanitised = directory.replace(/[/\\]/g, '-').replace(/^-+|-+$/g, ''); + const sanitised = directory + .replace(/^\.?[/\\]+/, '') // strip leading ./ or / + .replace(/[/\\]/g, '-') + .replace(/^-+|-+$/g, ''); return `${sanitised}-audit.json`; } @@ -212,7 +220,7 @@ export function formatAuditResult(result: AuditTokenUsageResult): string[] { } lines.push(''); - lines.push(`\r\n Mechanism breakdown:`); + lines.push(' Mechanism breakdown:'); for (const [mechanism, count] of Object.entries( result.overrides.byMechanism, )) { @@ -249,7 +257,7 @@ async function handleAuditTokenUsage( let styleFiles = await findStyleFiles(absDir); styleFiles = applyExcludePatterns(styleFiles, params.excludePatterns); - // 3. Resolve token prefix: explicit param > config > derived from dataset > null + // 3. Resolve token prefix: explicit param > config > null const configuredPrefix = params.tokenPrefix ?? context.tokensConfig?.propertyPrefix ?? null; @@ -267,12 +275,17 @@ async function handleAuditTokenUsage( let validateResult: ValidateResult | undefined; let overridesResult: OverridesResult | undefined; - // Derive tokenPrefix: configured value takes priority, otherwise infer from dataset - const tokenPrefix = - configuredPrefix ?? - (tokenDataset && !tokenDataset.isEmpty - ? deriveSemanticPrefix(tokenDataset) - : null); + // 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')) { @@ -287,7 +300,7 @@ async function handleAuditTokenUsage( ); } else { validateResult = await runValidateMode(styleFiles, tokenDataset, { - tokenPrefix, + tokenPrefixes, brandName: params.brandName, componentName: params.componentName, cwd: context.cwd, @@ -306,7 +319,6 @@ async function handleAuditTokenUsage( tokenDataset, tokenPrefix, cwd: context.cwd, - workspaceRoot: context.workspaceRoot, }); } 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 index e8af132..b7443e5 100644 --- 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 @@ -58,7 +58,8 @@ export type OverrideMechanism = | 'ng-deep' | 'class-selector' | 'root-theme' - | 'important'; + | 'important' + | 'unknown'; export type OverrideClassification = | 'legitimate' 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 index aceb1b8..1f2ff7b 100644 --- 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 @@ -149,9 +149,12 @@ describe('Property 6: Output structure matches active modes', () => { }); it('diagnostics is always present and is an array', () => { - const result = assembleResult(['validate'], makeValidateResult(0), undefined, [ - 'validate mode skipped: token dataset is empty', - ]); + 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); @@ -210,7 +213,9 @@ describe('Property 7: Summary counts match actual issues', () => { const validateResult = makeValidateResult(7); const summary = buildSummary(validateResult, undefined); - expect(summary.byMode.validate).toBe(validateResult.semantic.invalid.length); + expect(summary.byMode.validate).toBe( + validateResult.semantic.invalid.length, + ); expect(summary.byMode.validate).toBe(7); }); @@ -302,7 +307,11 @@ describe('formatAuditResult', () => { }); it('includes override count in summary line', () => { - const result = assembleResult(['overrides'], undefined, makeOverridesResult(5)); + const result = assembleResult( + ['overrides'], + undefined, + makeOverridesResult(5), + ); const lines = formatAuditResult(result); expect(lines[0]).toContain('5 override(s)'); 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 index 51a1cd8..eb08fb1 100644 --- 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 @@ -13,26 +13,23 @@ import type { /** * Determines the CSS mechanism used for a token override. * - * Note: `ViewEncapsulation.None` is handled separately in the encapsulation - * section, so it's not included here. - * * 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: `host` + * 6. Fallback: `unknown` (bare element selectors, `:root` without data-theme, etc.) */ export function detectMechanism(entry: ScssPropertyEntry): OverrideMechanism { const selector = entry.selector; - if (entry.value.includes('!important')) return 'important'; + 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 'host'; // fallback for :root or bare selectors + return 'unknown'; } // --------------------------------------------------------------------------- @@ -42,9 +39,6 @@ export function detectMechanism(entry: ScssPropertyEntry): OverrideMechanism { /** * Classifies a token override by intent. * - * Note: `encapsulation-none` is handled separately in the encapsulation - * section, so it's not included here. - * * Classification priority: * 1. `important-override` — value contains `!important` * 2. `deep-override` — selector uses `::ng-deep` @@ -57,7 +51,7 @@ export function classifyOverride( entry: ScssPropertyEntry, originalToken: TokenEntry | undefined, ): OverrideClassification { - if (entry.value.includes('!important')) return 'important-override'; + 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) 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 index 5a9dc5e..000e37f 100644 --- 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 @@ -14,7 +14,6 @@ export interface OverridesModeOptions { tokenDataset: TokenDataset | null; tokenPrefix: string | null; cwd: string; - workspaceRoot: string; } // --------------------------------------------------------------------------- @@ -87,7 +86,7 @@ export async function runOverridesMode( // --- Token consumptions — detect !important only --- for (const entry of consumptions) { - if (!entry.value.includes('!important')) continue; + if (!entry.important) continue; const tokenMatch = entry.value.match(/var\(\s*(--[\w-]+)/); if (!tokenMatch) continue; 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 index 4ec8240..cd1c222 100644 --- 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 @@ -11,9 +11,6 @@ import type { OverrideMechanism, OverrideItem } from '../../models/types.js'; * 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 - * - * Note: ViewEncapsulation.None is now handled separately in the encapsulation section, - * so it's not tested here as a mechanism. */ // --------------------------------------------------------------------------- @@ -26,6 +23,7 @@ const VALID_MECHANISMS: OverrideMechanism[] = [ 'class-selector', 'root-theme', 'important', + 'unknown', ]; function makeEntry( @@ -37,6 +35,7 @@ function makeEntry( line: 10, selector: ':host', classification: 'declaration', + important: false, ...overrides, }; } @@ -52,9 +51,6 @@ function makeTokenEntry( /** * Simulates the overrides-mode pipeline mapping for a single declaration entry. * This mirrors the core logic in `runOverridesMode` without file I/O. - * - * Note: Encapsulation-none entries are now handled separately and don't go - * through this path. */ function buildOverrideItem( entry: ScssPropertyEntry, @@ -203,22 +199,19 @@ describe('Property 5: Mechanism determinism', () => { * combination following priority rules, and same inputs always produce * same output. * - * Note: ViewEncapsulation.None is now handled separately in the encapsulation - * section, so it's not tested here. - * * **Validates: Requirements 7.1, 7.2, 7.3, 7.4, 7.5** */ // --- Priority 1: !important --- describe('!important (highest priority)', () => { - it('returns "important" when value contains !important', () => { - const entry = makeEntry({ value: 'red !important', selector: ':host' }); + 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({ - value: 'red !important', + important: true, selector: '::ng-deep .child', }); expect(detectMechanism(entry)).toBe('important'); @@ -226,7 +219,7 @@ describe('Property 5: Mechanism determinism', () => { it('!important takes priority over :root[data-theme]', () => { const entry = makeEntry({ - value: 'blue !important', + important: true, selector: ':root[data-theme="dark"]', }); expect(detectMechanism(entry)).toBe('important'); @@ -234,7 +227,7 @@ describe('Property 5: Mechanism determinism', () => { it('!important takes priority over class selector', () => { const entry = makeEntry({ - value: '10px !important', + important: true, selector: '.my-class', }); expect(detectMechanism(entry)).toBe('important'); @@ -312,21 +305,21 @@ describe('Property 5: Mechanism determinism', () => { }); }); - // --- Fallback: host --- - describe('fallback to host', () => { - it('returns "host" for bare element 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('host'); + expect(detectMechanism(entry)).toBe('unknown'); }); - it('returns "host" for empty selector', () => { + it('returns "unknown" for empty selector', () => { const entry = makeEntry({ value: 'red', selector: '' }); - expect(detectMechanism(entry)).toBe('host'); + expect(detectMechanism(entry)).toBe('unknown'); }); - it('returns "host" for :root without data-theme', () => { + it('returns "unknown" for :root without data-theme', () => { const entry = makeEntry({ value: 'red', selector: ':root' }); - expect(detectMechanism(entry)).toBe('host'); + expect(detectMechanism(entry)).toBe('unknown'); }); }); @@ -334,7 +327,7 @@ describe('Property 5: Mechanism determinism', () => { describe('determinism', () => { it('same inputs always produce the same output', () => { const testCases: ScssPropertyEntry[] = [ - makeEntry({ value: 'red !important', selector: ':host' }), + 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' }), @@ -357,7 +350,7 @@ describe('Property 5: Mechanism determinism', () => { expected: OverrideMechanism; }> = [ { - entry: makeEntry({ value: 'red !important', selector: ':host' }), + entry: makeEntry({ important: true, selector: ':host' }), expected: 'important', }, { @@ -393,13 +386,8 @@ describe('Property 5: Mechanism determinism', () => { // --------------------------------------------------------------------------- describe('classifyOverride', () => { - /** - * Note: ViewEncapsulation.None is now handled separately in the encapsulation - * section, so classifyOverride no longer handles it. - */ - - it('returns "important-override" when value contains !important', () => { - const entry = makeEntry({ value: 'red !important', selector: ':host' }); + it('returns "important-override" when entry has !important', () => { + const entry = makeEntry({ important: true, selector: ':host' }); expect(classifyOverride(entry, undefined)).toBe('important-override'); }); @@ -439,3 +427,224 @@ describe('classifyOverride', () => { 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/validate-mode.ts b/packages/angular-mcp-server/src/lib/tools/ds/audit-token-usage/utils/validate-mode.ts index 8024d83..1b9dc3b 100644 --- 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 @@ -16,7 +16,7 @@ import type { // --------------------------------------------------------------------------- export interface ValidateModeOptions { - tokenPrefix: string | null; + tokenPrefixes: string[] | null; brandName?: string; componentName?: string; cwd: string; @@ -40,6 +40,7 @@ export function extractVarReferences(value: string): string[] { /** * 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, @@ -51,6 +52,7 @@ export function findClosestToken( 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 }; } @@ -108,9 +110,12 @@ function checkBrandSpecific( * For every style file: * 1. Parse with `parseScssValues` to get classified entries. * 2. Iterate consumptions and extract `var()` references. - * 3. Validate each reference against the `TokenDataset`. - * 4. Compute typo suggestions via Levenshtein distance (threshold ≤ 3). - * 5. Optionally check brand-specific token availability. + * 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[], @@ -130,47 +135,53 @@ export async function runValidateMode( const relPath = path.relative(options.cwd, filePath); for (const tokenName of tokenNames) { - // --- Semantic token validation --- - if ( - options.tokenPrefix && - tokenName.startsWith(options.tokenPrefix) - ) { - 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 { - const suggestion = findClosestToken( + // 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.tokenPrefix, + options.brandName, + relPath, + entry.line, + brandWarnings, ); - semanticInvalid.push({ - token: tokenName, - file: relPath, - line: entry.line, - ...(suggestion && { - suggestion: suggestion.name, - editDistance: suggestion.distance, - }), - }); } + } 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, + }), + }); } } } diff --git a/packages/angular-mcp/src/main.ts b/packages/angular-mcp/src/main.ts index 724feda..27e63a7 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -45,7 +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 + array: true, }) .option('ds.tokens.propertyPrefix', { describe: 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, + }); }, });