diff --git a/README.md b/README.md index e518b55..8f00c11 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,11 @@ my-angular-workspace/ ### Component Analysis -- **`report-violations`**: Report deprecated CSS usage in a directory with configurable grouping format +- **`report-violations`**: Report deprecated CSS usage for a specific component in a directory. Supports optional file output with statistics. + +- **`report-all-violations`**: Report all deprecated CSS usage across all components in a directory. Supports optional file output with statistics. + +- **`group-violations`**: Creates balanced work distribution groups from violations reports using bin-packing algorithm. Maintains path exclusivity and directory boundaries for parallel development. - **`report-deprecated-css`**: Report deprecated CSS classes found in styling files diff --git a/docs/tools.md b/docs/tools.md index fa5dcfb..e6cbf16 100644 --- a/docs/tools.md +++ b/docs/tools.md @@ -1,29 +1,35 @@ -# Design System Tools for AI Agents +# Angular MCP Tools for AI Agents -This document provides comprehensive guidance for AI agents working with Angular Design System (DS) migration and analysis tools. Each tool is designed to support automated refactoring, validation, and analysis workflows. +This document provides comprehensive guidance for AI agents working with Angular migration and analysis tools. Each tool is designed to support automated refactoring, validation, and analysis workflows. ## Tool Categories ### 🔍 Project Analysis Tools #### `report-violations` -**Purpose**: Identifies deprecated DS CSS usage patterns in Angular projects -**AI Usage**: Use as the first step in migration workflows to identify all violations before planning refactoring +**Purpose**: Identifies deprecated CSS usage patterns for a specific component in Angular projects +**AI Usage**: Use when you need to analyze violations for a specific component before planning refactoring **Key Parameters**: - `directory`: Target analysis directory (use relative paths like `./src/app`) -- `componentName`: DS component class name (e.g., `DsButton`) +- `componentName`: Component class name (e.g., `DsButton`) - `groupBy`: `"file"` or `"folder"` for result organization -**Output**: Structured violation reports grouped by file or folder -**Best Practice**: Always run this before other migration tools to establish baseline +- `saveAsFile`: Optional boolean - if `true`, saves report to `tmp/.angular-toolkit-mcp/violations-report//-violations.json` +**Output**: +- Default: Structured violation reports grouped by file or folder +- With `saveAsFile: true`: File path and statistics (components, files, lines) +**Best Practice**: Use `saveAsFile: true` when you need to persist results for later processing or grouping workflows #### `report-all-violations` -**Purpose**: Reports all deprecated DS CSS usage for every DS component within a directory +**Purpose**: Reports all deprecated CSS usage for every component within a directory **AI Usage**: Use for a fast, global inventory of violations across the codebase before narrowing to specific components **Key Parameters**: - `directory`: Target analysis directory (use relative paths like `./src/app`) -- `groupBy`: `"file"` or `"folder"` for result organization (default: `"file"`) -**Output**: Structured violation reports grouped by file or folder covering all DS components -**Best Practice**: Use to discover all violations and establish the baseline for subsequent refactoring. +- `groupBy`: `"component"` or `"file"` for result organization (default: `"component"`) +- `saveAsFile`: Optional boolean - if `true`, saves report to `tmp/.angular-toolkit-mcp/violations-report/-violations.json` +**Output**: +- Default: Structured violation reports grouped by component or file covering all components +- 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. #### `get-project-dependencies` **Purpose**: Analyzes project structure, dependencies, and buildability @@ -34,6 +40,21 @@ This document provides comprehensive guidance for AI agents working with Angular **Output**: Dependency analysis, buildable/publishable status, peer dependencies **Best Practice**: Use to understand project constraints before recommending changes +#### `group-violations` +**Purpose**: Creates balanced work distribution groups from violations reports for parallel development +**AI Usage**: Use after `report-all-violations` to organize violations into balanced work groups for team distribution +**Key Parameters**: +- `fileName`: Name of violations JSON file in `tmp/.angular-toolkit-mcp/violations-report/` (e.g., `"packages-poker-core-lib-violations.json"`) +- `minGroups`: Minimum number of groups (default: 3) +- `maxGroups`: Maximum number of groups (default: 5) +- `variance`: Acceptable variance percentage for balance (default: 20) +**Output**: +- Work groups with balanced violation counts +- Individual JSON and Markdown files per group +- Metadata with validation results +- Saved to `tmp/.angular-toolkit-mcp/violation-groups//` +**Best Practice**: Use after saving violations with `saveAsFile: true`. The tool accepts both component-grouped and file-grouped reports. Groups maintain path exclusivity (each file in exactly one group) and preserve directory boundaries to enable parallel development without merge conflicts. + #### `report-deprecated-css` **Purpose**: Scans styling files for deprecated CSS classes **AI Usage**: Complement violation reports with style-specific analysis @@ -147,8 +168,9 @@ This document provides comprehensive guidance for AI agents working with Angular ### 1. Discovery & Analysis Workflow ``` 1. list-ds-components → Discover available DS components -2. report-violations → Identify all violations -3. get-project-dependencies → Analyze project structure +2. report-all-violations (saveAsFile: true) → Identify all violations and save to file +3. group-violations → Create balanced work distribution groups +4. get-project-dependencies → Analyze project structure ``` ### 2. Planning & Preparation Workflow diff --git a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/build-component-contract.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/build-component-contract.tool.ts index 4f9acb8..2130ae9 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/build-component-contract.tool.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/build-component-contract.tool.ts @@ -7,10 +7,16 @@ import { buildComponentContract } from './utils/build-contract.js'; import { generateContractSummary } from '../shared/utils/contract-file-ops.js'; import { ContractResult } from './models/types.js'; import { resolveCrossPlatformPath } from '../../shared/utils/cross-platform-path.js'; +import { + OUTPUT_SUBDIRS, + resolveDefaultSaveLocation, +} from '../../shared/constants.js'; import { createHash } from 'node:crypto'; +import { dirname } from 'node:path'; +import { mkdir, writeFile } from 'node:fs/promises'; interface BuildComponentContractOptions extends BaseHandlerOptions { - saveLocation: string; + saveLocation?: string; templateFile?: string; styleFile?: string; typescriptFile: string; @@ -36,6 +42,13 @@ export const buildComponentContractHandler = createHandler< typescriptFile, ); + const defaultSaveLocation = resolveDefaultSaveLocation( + saveLocation, + OUTPUT_SUBDIRS.CONTRACTS, + typescriptFile, + '-contract.json', + ); + // If templateFile or styleFile are not provided, use the TypeScript file path // This indicates inline template/styles const effectiveTemplatePath = templateFile @@ -55,10 +68,11 @@ export const buildComponentContractHandler = createHandler< const contractString = JSON.stringify(contract, null, 2); const hash = createHash('sha256').update(contractString).digest('hex'); - const effectiveSaveLocation = resolveCrossPlatformPath(cwd, saveLocation); + const effectiveSaveLocation = resolveCrossPlatformPath( + cwd, + defaultSaveLocation, + ); - const { mkdir, writeFile } = await import('node:fs/promises'); - const { dirname } = await import('node:path'); await mkdir(dirname(effectiveSaveLocation), { recursive: true }); const contractData = { diff --git a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/models/schema.ts b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/models/schema.ts index 4459d2c..8779d1e 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/models/schema.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/models/schema.ts @@ -1,5 +1,9 @@ import { ToolSchemaOptions } from '@push-based/models'; -import { COMMON_ANNOTATIONS } from '../../../shared/index.js'; +import { + COMMON_ANNOTATIONS, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS, +} from '../../../shared/index.js'; /** * Schema for building component contracts @@ -13,8 +17,7 @@ export const buildComponentContractSchema: ToolSchemaOptions = { properties: { saveLocation: { type: 'string', - description: - 'Path where to save the contract file. Supports both absolute and relative paths.', + description: `Path where to save the contract file. Supports both absolute and relative paths. If not provided, defaults to ${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.CONTRACTS}/-contract.json. When building contracts for comparison, use descriptive names like -before-contract.json or -after-contract.json to distinguish between refactoring phases.`, }, templateFile: { type: 'string', @@ -37,7 +40,7 @@ export const buildComponentContractSchema: ToolSchemaOptions = { default: '', }, }, - required: ['saveLocation', 'typescriptFile'], + required: ['typescriptFile'], }, annotations: { title: 'Build Component Contract', diff --git a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/diff-component-contract.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/diff-component-contract.tool.ts index 92d16db..d31b8ab 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/diff-component-contract.tool.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/diff-component-contract.tool.ts @@ -6,6 +6,10 @@ import { resolveCrossPlatformPath, normalizePathsInObject, } from '../../shared/utils/cross-platform-path.js'; +import { + OUTPUT_SUBDIRS, + resolveDefaultSaveLocation, +} from '../../shared/constants.js'; import { diffComponentContractSchema } from './models/schema.js'; import type { DomPathDictionary } from '../shared/models/types.js'; import { loadContract } from '../shared/utils/contract-file-ops.js'; @@ -15,10 +19,11 @@ import { generateDiffSummary, } from './utils/diff-utils.js'; import { writeFile, mkdir } from 'node:fs/promises'; +import { dirname } from 'node:path'; import diff from 'microdiff'; interface DiffComponentContractOptions extends BaseHandlerOptions { - saveLocation: string; + saveLocation?: string; contractBeforePath: string; contractAfterPath: string; dsComponentName?: string; @@ -46,6 +51,14 @@ export const diffComponentContractHandler = createHandler< ); const effectiveAfterPath = resolveCrossPlatformPath(cwd, contractAfterPath); + const defaultSaveLocation = resolveDefaultSaveLocation( + saveLocation, + OUTPUT_SUBDIRS.CONTRACT_DIFFS, + contractBeforePath, + '-diff.json', + '-contract.json', + ); + const contractBefore = await loadContract(effectiveBeforePath); const contractAfter = await loadContract(effectiveAfterPath); @@ -68,9 +81,11 @@ export const diffComponentContractHandler = createHandler< const normalizedDiffData = normalizePathsInObject(diffData, workspaceRoot); - const effectiveSaveLocation = resolveCrossPlatformPath(cwd, saveLocation); + const effectiveSaveLocation = resolveCrossPlatformPath( + cwd, + defaultSaveLocation, + ); - const { dirname } = await import('node:path'); await mkdir(dirname(effectiveSaveLocation), { recursive: true }); const diffFilePath = effectiveSaveLocation; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/models/schema.ts b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/models/schema.ts index 7d05a6e..f0f2e6a 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/models/schema.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/component-contract/diff/models/schema.ts @@ -1,5 +1,9 @@ import { ToolSchemaOptions } from '@push-based/models'; -import { COMMON_ANNOTATIONS } from '../../../shared/index.js'; +import { + COMMON_ANNOTATIONS, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS, +} from '../../../shared/index.js'; /** * Schema for diffing component contracts @@ -13,18 +17,15 @@ export const diffComponentContractSchema: ToolSchemaOptions = { properties: { saveLocation: { type: 'string', - description: - 'Path where to save the diff result file. Supports both absolute and relative paths.', + description: `Path where to save the diff result file. Supports both absolute and relative paths. If not provided, defaults to ${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.CONTRACT_DIFFS}/-diff.json`, }, contractBeforePath: { type: 'string', - description: - 'Path to the contract file before refactoring. Supports both absolute and relative paths.', + description: `Path to the contract file before refactoring. Supports both absolute and relative paths. Typically located at ${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.CONTRACTS}/-before-contract.json`, }, contractAfterPath: { type: 'string', - description: - 'Path to the contract file after refactoring. Supports both absolute and relative paths.', + description: `Path to the contract file after refactoring. Supports both absolute and relative paths. Typically located at ${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.CONTRACTS}/-after-contract.json`, }, dsComponentName: { type: 'string', @@ -32,7 +33,7 @@ export const diffComponentContractSchema: ToolSchemaOptions = { default: '', }, }, - required: ['saveLocation', 'contractBeforePath', 'contractAfterPath'], + required: ['contractBeforePath', 'contractAfterPath'], }, annotations: { title: 'Diff Component Contract', diff --git a/packages/angular-mcp-server/src/lib/tools/ds/ds.tools.ts b/packages/angular-mcp-server/src/lib/tools/ds/ds.tools.ts index 9304f6b..28d6b07 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/ds.tools.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/ds.tools.ts @@ -9,6 +9,7 @@ import { join } from 'node:path'; import { reportViolationsTools, reportAllViolationsTools, + groupViolationsTools, } from './report-violations/index.js'; export const componentCoverageToolsSchema: ToolSchemaOptions = { @@ -137,4 +138,5 @@ export const dsTools = [ ...componentCoverageTools, ...reportViolationsTools, ...reportAllViolationsTools, + ...groupViolationsTools, ]; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/group-violations.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/group-violations.tool.ts new file mode 100644 index 0000000..5f59030 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/group-violations.tool.ts @@ -0,0 +1,213 @@ +import { createHandler } from '../shared/utils/handler-helpers.js'; +import { normalizeAbsolutePathToRelative } from '../shared/utils/cross-platform-path.js'; +import { DEFAULT_OUTPUT_BASE, OUTPUT_SUBDIRS } from '../shared/constants.js'; +import { readFile, writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import type { + AllViolationsReportByFile, + AllViolationsReport, + GroupViolationsOptions, + GroupViolationsReport, + GroupViolationsResult, +} from './models/types.js'; +import { groupViolationsSchema } from './models/schema.js'; +import { + detectReportFormat, + convertComponentToFileFormat, + enrichFiles, + groupByDirectory, + determineOptimalGroups, + createWorkGroups, + generateGroupMarkdown, + mapWorkGroupToReportGroup, +} from './utils/index.js'; + +export { groupViolationsSchema }; + +export const groupViolationsHandler = createHandler< + GroupViolationsOptions, + GroupViolationsResult +>( + groupViolationsSchema.name, + async (params, { cwd, workspaceRoot }) => { + const minGroups = params.minGroups ?? 3; + const maxGroups = params.maxGroups ?? 5; + const variance = params.variance ?? 20; + + const inputPath = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATIONS_REPORT, + params.fileName, + ); + + let rawData: any; + try { + const fileContent = await readFile(inputPath, 'utf-8'); + rawData = JSON.parse(fileContent); + } catch (ctx) { + throw new Error( + `Failed to read violations file at ${inputPath}: ${ctx instanceof Error ? ctx.message : String(ctx)}`, + ); + } + + const format = detectReportFormat(rawData); + + if (format === 'unknown') { + throw new Error( + 'Invalid violations report format. Expected either { files: [...] } or { components: [...] }', + ); + } + + let violationsData: AllViolationsReportByFile; + + if (format === 'component') { + violationsData = convertComponentToFileFormat( + rawData as AllViolationsReport, + ); + } else { + violationsData = rawData as AllViolationsReportByFile; + } + + if (!violationsData.files || violationsData.files.length === 0) { + throw new Error('No violations found in the input file'); + } + + const rootPath = violationsData.rootPath || ''; + + const enrichedFiles = enrichFiles(violationsData.files); + const totalViolations = enrichedFiles.reduce( + (sum, f) => sum + f.violations, + 0, + ); + const totalFiles = enrichedFiles.length; + + const directorySummary = groupByDirectory(enrichedFiles); + + const optimalGroups = determineOptimalGroups( + totalViolations, + directorySummary, + minGroups, + maxGroups, + variance, + ); + + const targetPerGroup = totalViolations / optimalGroups; + const minAcceptable = Math.floor(targetPerGroup * (1 - variance / 100)); + const maxAcceptable = Math.ceil(targetPerGroup * (1 + variance / 100)); + + const groups = createWorkGroups( + directorySummary, + optimalGroups, + maxAcceptable, + ); + + const totalGroupViolations = groups.reduce( + (sum, g) => sum + g.violations, + 0, + ); + const allFilesInGroups = groups.flatMap((g) => g.files.map((f) => f.file)); + const uniqueFiles = new Set(allFilesInGroups); + const pathExclusivity = uniqueFiles.size === allFilesInGroups.length; + const balanced = groups.every( + (g) => g.violations >= minAcceptable && g.violations <= maxAcceptable, + ); + + const report: GroupViolationsReport = { + metadata: { + generatedAt: new Date().toISOString(), + inputFile: params.fileName, + rootPath, + totalFiles, + totalViolations, + groupCount: optimalGroups, + targetPerGroup, + acceptableRange: { min: minAcceptable, max: maxAcceptable }, + variance, + }, + groups: groups.map((g) => mapWorkGroupToReportGroup(g, rootPath)), + validation: { + totalViolations: totalGroupViolations, + totalFiles: allFilesInGroups.length, + uniqueFiles: uniqueFiles.size, + pathExclusivity, + balanced, + }, + }; + + const reportName = params.fileName.replace('.json', ''); + const outputDir = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATION_GROUPS, + reportName, + ); + + await mkdir(outputDir, { recursive: true }); + + const metadataPath = join(outputDir, 'metadata.json'); + await writeFile( + metadataPath, + JSON.stringify( + { + metadata: report.metadata, + validation: report.validation, + }, + null, + 2, + ), + 'utf-8', + ); + + for (const group of report.groups) { + const groupPath = join(outputDir, `group-${group.id}.json`); + await writeFile(groupPath, JSON.stringify(group, null, 2), 'utf-8'); + + const markdownPath = join(outputDir, `group-${group.id}.md`); + const markdown = generateGroupMarkdown(group); + await writeFile(markdownPath, markdown, 'utf-8'); + } + + return { + ...report, + outputDir: normalizeAbsolutePathToRelative(outputDir, workspaceRoot), + }; + }, + (result) => { + const { metadata, groups, validation, outputDir } = result; + + const message = [ + `✅ Created ${metadata.groupCount} work distribution groups`, + '', + `📊 Summary:`, + ` - Total files: ${metadata.totalFiles}`, + ` - Total violations: ${metadata.totalViolations}`, + ` - Target per group: ${metadata.targetPerGroup.toFixed(1)} violations`, + ` - Acceptable range: ${metadata.acceptableRange.min}-${metadata.acceptableRange.max}`, + '', + `📦 Groups:`, + ...groups.map( + (g) => + ` ${g.name} - ${g.statistics.violationCount} violations (${g.statistics.fileCount} files)`, + ), + '', + `✅ Validation:`, + ` - Total violations: ${validation.totalViolations} ${validation.totalViolations === metadata.totalViolations ? '✅' : '❌'}`, + ` - Path exclusivity: ${validation.pathExclusivity ? '✅' : '❌'}`, + ` - Balance: ${validation.balanced ? '✅' : '⚠️'}`, + '', + `📁 Output directory: ${outputDir}`, + ` - metadata.json (summary and validation)`, + ...groups.map((g) => ` - group-${g.id}.json + group-${g.id}.md`), + ]; + + return message; + }, +); + +export const groupViolationsTools = [ + { + schema: groupViolationsSchema, + handler: groupViolationsHandler, + }, +]; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/index.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/index.ts index 72b3734..1f9e9fb 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/index.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/index.ts @@ -1,5 +1,6 @@ export { reportViolationsTools } from './report-violations.tool.js'; export { reportAllViolationsTools } from './report-all-violations.tool.js'; +export { groupViolationsTools } from './group-violations.tool.js'; export type { ViolationEntry, diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/schema.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/schema.ts index 17ebb1b..abc2e24 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/schema.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/schema.ts @@ -1,34 +1,82 @@ -import { ToolSchemaOptions } from '@push-based/models'; +import { + createViolationReportingSchema, + createProjectAnalysisSchema, + COMMON_ANNOTATIONS, +} from '../../shared/models/schema-helpers.js'; +import { + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS, +} from '../../shared/constants.js'; -export const reportViolationsSchema: ToolSchemaOptions = { +export const reportViolationsSchema = { name: 'report-violations', - description: `Report deprecated DS CSS usage in a directory with configurable grouping format.`, + description: `Report deprecated CSS usage for a specific component in a directory. Returns violations grouped by file, showing which deprecated classes are used and where. Use this when you know which component you're checking for. Output includes: file paths, line numbers, and violation details (but not replacement suggestions since the component is already known).`, + inputSchema: createViolationReportingSchema({ + saveAsFile: { + type: 'boolean', + description: `If true, saves the violations report to /${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.VIOLATIONS_REPORT}// with filename pattern -violations.json (e.g., packages-poker-core-lib-violations.json). Overwrites if file exists.`, + }, + }), + annotations: { + title: 'Report Violations', + ...COMMON_ANNOTATIONS.readOnly, + }, +}; + +export const reportAllViolationsSchema = { + name: 'report-all-violations', + description: + 'Scan a directory for all deprecated CSS classes and output a comprehensive violation report. Use this to discover all violations across multiple components. Output can be grouped by component (default) or by file, and includes: file paths, line numbers, violation details, and replacement suggestions (which component should be used instead). This is ideal for getting an overview of all violations in a directory.', + inputSchema: createProjectAnalysisSchema({ + groupBy: { + type: 'string', + enum: ['component', 'file'], + description: + 'How to group the results: "component" (default) groups by component showing all files affected by each component, "file" groups by file path showing all components violated in each file', + default: 'component', + }, + saveAsFile: { + type: 'boolean', + description: `If true, saves the violations report to /${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.VIOLATIONS_REPORT}/ with filename pattern -violations.json (e.g., packages-poker-core-lib-violations.json). Overwrites if file exists.`, + }, + }), + annotations: { + title: 'Report All Violations', + ...COMMON_ANNOTATIONS.readOnly, + }, +}; + +export const groupViolationsSchema = { + name: 'group-violations', + description: `Creates work distribution groups from violations report. Reads a violations JSON file (e.g., packages-poker-violations.json) from ${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.VIOLATIONS_REPORT}/ and creates balanced work groups using bin-packing algorithm. Accepts both file-grouped and component-grouped violation reports. Groups are balanced by violation count, maintain path exclusivity (each file in one group), and preserve directory boundaries for parallel development.`, inputSchema: { - type: 'object', + type: 'object' as const, properties: { - directory: { + fileName: { type: 'string', - description: - 'The relative path the directory (starting with "./path/to/dir" avoid big folders.) to run the task in starting from CWD. Respect the OS specifics.', + description: `Name of the violations JSON file (e.g., "packages-poker-violations.json"). File must exist in ${DEFAULT_OUTPUT_BASE}/${OUTPUT_SUBDIRS.VIOLATIONS_REPORT}/`, }, - componentName: { - type: 'string', - description: - 'The class name of the component to search for (e.g., DsButton)', + minGroups: { + type: 'number', + description: 'Minimum number of groups to create (default: 3)', + default: 3, }, - groupBy: { - type: 'string', - enum: ['file', 'folder'], - description: 'How to group the violation results', - default: 'file', + maxGroups: { + type: 'number', + description: 'Maximum number of groups to create (default: 5)', + default: 5, + }, + variance: { + type: 'number', + description: + 'Acceptable variance percentage for group balance (default: 20). Groups will have violations within target ± variance%', + default: 20, }, }, - required: ['directory', 'componentName'], + required: ['fileName'], }, annotations: { - title: 'Report Violations', - readOnlyHint: true, - openWorldHint: false, - idempotentHint: false, + title: 'Group Violations', + ...COMMON_ANNOTATIONS.readOnly, }, }; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/types.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/types.ts index cd32494..d20bf21 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/types.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/models/types.ts @@ -1,4 +1,16 @@ -// Types for report-violations (single component, no replacement field needed) +import type { BaseHandlerOptions } from '../../shared/utils/handler-helpers.js'; + +// ============================================================================ +// report-violations types +// ============================================================================ + +export interface ReportViolationsOptions extends BaseHandlerOptions { + directory: string; + componentName: string; + groupBy?: 'file' | 'folder'; + saveAsFile?: boolean; +} + export interface ViolationEntry { file: string; lines: number[]; @@ -8,9 +20,33 @@ export interface ViolationEntry { export interface ComponentViolationReport { component: string; violations: ViolationEntry[]; + rootPath: string; +} + +export interface ViolationFileOutput { + message: string; + filePath: string; + stats?: { + components: number; + files: number; + lines: number; + }; +} + +export type ReportViolationsResult = + | ComponentViolationReport + | ViolationFileOutput; + +// ============================================================================ +// report-all-violations types +// ============================================================================ + +export interface ReportAllViolationsOptions extends BaseHandlerOptions { + directory: string; + groupBy?: 'component' | 'file'; + saveAsFile?: boolean; } -// Types for report-all-violations (multiple components, replacement field needed) export interface AllViolationsEntry { file: string; lines: number[]; @@ -25,9 +61,9 @@ export interface AllViolationsComponentReport { export interface AllViolationsReport { components: AllViolationsComponentReport[]; + rootPath: string; } -// File-grouped output types for report-all-violations export interface ComponentViolationInFile { component: string; lines: number[]; @@ -42,4 +78,70 @@ export interface FileViolationReport { export interface AllViolationsReportByFile { files: FileViolationReport[]; + rootPath: string; } + +export interface ProcessedViolation { + component: string; + fileName: string; + lines: number[]; + violation: string; + replacement: string; +} + +export type ReportAllViolationsResult = + | AllViolationsReport + | AllViolationsReportByFile + | ViolationFileOutput; + +// ============================================================================ +// group-violations types +// ============================================================================ + +export interface GroupViolationsOptions extends BaseHandlerOptions { + fileName: string; + minGroups?: number; + maxGroups?: number; + variance?: number; +} + +export interface GroupViolationsReport { + metadata: { + generatedAt: string; + inputFile: string; + rootPath: string; + totalFiles: number; + totalViolations: number; + groupCount: number; + targetPerGroup: number; + acceptableRange: { min: number; max: number }; + variance: number; + }; + groups: Array<{ + id: number; + name: string; + rootPath: string; + directories: string[]; + files: Array<{ + file: string; + violations: number; + components: FileViolationReport['components']; + }>; + statistics: { + fileCount: number; + violationCount: number; + }; + componentDistribution: Record; + }>; + validation: { + totalViolations: number; + totalFiles: number; + uniqueFiles: number; + pathExclusivity: boolean; + balanced: boolean; + }; +} + +export type GroupViolationsResult = GroupViolationsReport & { + outputDir: string; +}; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-all-violations.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-all-violations.tool.ts index dfb6a4a..ce168ca 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-all-violations.tool.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-all-violations.tool.ts @@ -1,11 +1,6 @@ -import { - BaseHandlerOptions, - createHandler, -} from '../shared/utils/handler-helpers.js'; -import { - COMMON_ANNOTATIONS, - createProjectAnalysisSchema, -} from '../shared/models/schema-helpers.js'; +import { createHandler } from '../shared/utils/handler-helpers.js'; +import { normalizeAbsolutePathToRelative } from '../shared/utils/cross-platform-path.js'; +import { DEFAULT_OUTPUT_BASE, OUTPUT_SUBDIRS } from '../shared/constants.js'; import { analyzeProjectCoverage, extractComponentName, @@ -17,85 +12,25 @@ import { import type { BaseViolationAudit } from '../shared/violation-analysis/types.js'; import { loadAndValidateDsComponentsFile } from '../../../validation/ds-components-file-loader.validation.js'; import { - AllViolationsReport, AllViolationsComponentReport, AllViolationsEntry, - AllViolationsReportByFile, FileViolationReport, ComponentViolationInFile, + ReportAllViolationsOptions, + ReportAllViolationsResult, + ProcessedViolation, } from './models/types.js'; +import { reportAllViolationsSchema } from './models/schema.js'; +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { + generateFilename, + parseViolationMessageWithReplacement, + calculateComponentGroupedStats, + calculateFileGroupedStats, +} from './utils/index.js'; -interface ReportAllViolationsOptions extends BaseHandlerOptions { - directory: string; - groupBy?: 'component' | 'file'; -} - -export const reportAllViolationsSchema = { - name: 'report-all-violations', - description: - 'Scan a directory for all deprecated design system CSS classes and output a comprehensive violation report. Use this to discover all violations across multiple components. Output can be grouped by component (default) or by file, and includes: file paths, line numbers, violation details, and replacement suggestions (which component should be used instead). This is ideal for getting an overview of all violations in a directory.', - inputSchema: createProjectAnalysisSchema({ - groupBy: { - type: 'string', - enum: ['component', 'file'], - description: - 'How to group the results: "component" (default) groups by design system component showing all files affected by each component, "file" groups by file path showing all components violated in each file', - default: 'component', - }, - }), - annotations: { - title: 'Report All Violations', - ...COMMON_ANNOTATIONS.readOnly, - }, -}; - -/** - * Extracts deprecated class and replacement from violation message - * Performance optimized with caching to avoid repeated regex operations - */ -const messageParsingCache = new Map< - string, - { violation: string; replacement: string } ->(); - -function parseViolationMessage(message: string): { - violation: string; - replacement: string; -} { - // Check cache first - const cached = messageParsingCache.get(message); - if (cached) { - return cached; - } - - // Clean up HTML tags - const cleanMessage = message - .replace(//g, '`') - .replace(/<\/code>/g, '`'); - - // Extract deprecated class - look for patterns like "class `offer-badge`" or "class `btn, btn-primary`" - const classMatch = cleanMessage.match(/class `([^`]+)`/); - const violation = classMatch ? classMatch[1] : 'unknown'; - - // Extract replacement component - look for "Use `ComponentName`" - const replacementMatch = cleanMessage.match(/Use `([^`]+)`/); - const replacement = replacementMatch ? replacementMatch[1] : 'unknown'; - - const result = { violation, replacement }; - messageParsingCache.set(message, result); - return result; -} - -/** - * Processed violation data structure used internally for both grouping modes - */ -interface ProcessedViolation { - component: string; - fileName: string; - lines: number[]; - violation: string; - replacement: string; -} +export { reportAllViolationsSchema }; /** * Processes all failed audits into a unified structure @@ -118,11 +53,12 @@ function processAudits( fileGroups, )) { // Lines are already sorted by groupIssuesByFile, so we can use them directly - const { violation, replacement } = parseViolationMessage(message); + const { violation, replacement } = + parseViolationMessageWithReplacement(message); processed.push({ component: componentName, - fileName, + fileName: fileName, lines: fileLines, // Already sorted violation, replacement, @@ -135,10 +71,10 @@ function processAudits( export const reportAllViolationsHandler = createHandler< ReportAllViolationsOptions, - AllViolationsReport | AllViolationsReportByFile + ReportAllViolationsResult >( reportAllViolationsSchema.name, - async (params, { cwd, deprecatedCssClassesPath }) => { + async (params, { cwd, workspaceRoot, deprecatedCssClassesPath }) => { if (!deprecatedCssClassesPath) { throw new Error( 'Missing ds.deprecatedCssClassesPath. Provide --ds.deprecatedCssClassesPath in mcp.json file.', @@ -162,7 +98,33 @@ export const reportAllViolationsHandler = createHandler< // Early exit for empty results if (failedAudits.length === 0) { - return params.groupBy === 'file' ? { files: [] } : { components: [] }; + const report = + params.groupBy === 'file' + ? { files: [], rootPath: params.directory } + : { components: [], rootPath: params.directory }; + + if (params.saveAsFile) { + const outputDir = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATIONS_REPORT, + ); + const filename = generateFilename(params.directory); + const filePath = join(outputDir, filename); + await mkdir(outputDir, { recursive: true }); + await writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8'); + return { + message: 'Violations report saved', + filePath: normalizeAbsolutePathToRelative(filePath, workspaceRoot), + stats: { + components: 0, + files: 0, + lines: 0, + }, + }; + } + + return report; } // Process all audits into unified structure (eliminates code duplication) @@ -193,7 +155,29 @@ export const reportAllViolationsHandler = createHandler< ([component, violations]) => ({ component, violations }), ); - return { components }; + const report = { components, rootPath: params.directory }; + + if (params.saveAsFile) { + const outputDir = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATIONS_REPORT, + ); + const filename = generateFilename(params.directory); + const filePath = join(outputDir, filename); + await mkdir(outputDir, { recursive: true }); + await writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8'); + + const stats = calculateComponentGroupedStats(components); + + return { + message: 'Violations report saved', + filePath: normalizeAbsolutePathToRelative(filePath, workspaceRoot), + stats, + }; + } + + return report; } // Group by file @@ -221,9 +205,40 @@ export const reportAllViolationsHandler = createHandler< ([file, components]) => ({ file, components }), ).sort((a, b) => a.file.localeCompare(b.file)); - return { files }; + const report = { files, rootPath: params.directory }; + + if (params.saveAsFile) { + const outputDir = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATIONS_REPORT, + ); + const filename = generateFilename(params.directory); + const filePath = join(outputDir, filename); + await mkdir(outputDir, { recursive: true }); + await writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8'); + + const stats = calculateFileGroupedStats(files); + + return { + message: 'Violations report saved', + filePath: normalizeAbsolutePathToRelative(filePath, workspaceRoot), + stats, + }; + } + + return report; }, (result) => { + // Check if this is a file output response + if ('message' in result && 'filePath' in result) { + const stats = 'stats' in result && result.stats ? result.stats : null; + const statsMessage = stats + ? ` (${stats.components} components, ${stats.files} files, ${stats.lines} lines)` + : ''; + return [`Violations report saved to ${result.filePath}${statsMessage}`]; + } + const isFileGrouping = 'files' in result; const isEmpty = isFileGrouping ? result.files.length === 0 diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-violations.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-violations.tool.ts index 1a6fdf6..bc456eb 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-violations.tool.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/report-violations.tool.ts @@ -1,63 +1,68 @@ -import { - createHandler, - BaseHandlerOptions, -} from '../shared/utils/handler-helpers.js'; -import { - createViolationReportingSchema, - COMMON_ANNOTATIONS, -} from '../shared/models/schema-helpers.js'; +import { createHandler } from '../shared/utils/handler-helpers.js'; +import { normalizeAbsolutePathToRelative } from '../shared/utils/cross-platform-path.js'; +import { DEFAULT_OUTPUT_BASE, OUTPUT_SUBDIRS } from '../shared/constants.js'; import { analyzeViolationsBase } from '../shared/violation-analysis/base-analyzer.js'; import { groupIssuesByFile, filterFailedAudits, } from '../shared/violation-analysis/formatters.js'; import { BaseViolationResult } from '../shared/violation-analysis/types.js'; -import { ComponentViolationReport, ViolationEntry } from './models/types.js'; - -interface ReportViolationsOptions extends BaseHandlerOptions { - directory: string; - componentName: string; - groupBy?: 'file' | 'folder'; -} - -export const reportViolationsSchema = { - name: 'report-violations', - description: `Report deprecated CSS usage for a specific design system component in a directory. Returns violations grouped by file, showing which deprecated classes are used and where. Use this when you know which component you're checking for. Output includes: file paths, line numbers, and violation details (but not replacement suggestions since the component is already known).`, - inputSchema: createViolationReportingSchema(), - annotations: { - title: 'Report Violations', - ...COMMON_ANNOTATIONS.readOnly, - }, -}; - -/** - * Extracts deprecated class from violation message - */ -function parseViolationMessage(message: string): string { - // Clean up HTML tags - const cleanMessage = message - .replace(//g, '`') - .replace(/<\/code>/g, '`'); - - // Extract deprecated class - look for patterns like "class `offer-badge`" or "class `btn, btn-primary`" - const classMatch = cleanMessage.match(/class `([^`]+)`/); - return classMatch ? classMatch[1] : 'unknown'; -} +import { + ComponentViolationReport, + ViolationEntry, + ReportViolationsOptions, + ReportViolationsResult, +} from './models/types.js'; +import { reportViolationsSchema } from './models/schema.js'; +import { writeFile, mkdir } from 'fs/promises'; +import { join } from 'path'; +import { + generateFilename, + parseViolationMessage, + calculateSingleComponentStats, +} from './utils/index.js'; + +export { reportViolationsSchema }; export const reportViolationsHandler = createHandler< ReportViolationsOptions, - ComponentViolationReport + ReportViolationsResult >( reportViolationsSchema.name, - async (params) => { + async (params, { cwd, workspaceRoot }) => { const result = await analyzeViolationsBase(params); const failedAudits = filterFailedAudits(result); if (failedAudits.length === 0) { - return { + const report = { component: params.componentName, violations: [], + rootPath: params.directory, }; + + if (params.saveAsFile) { + const outputDir = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATIONS_REPORT, + params.componentName, + ); + const filename = generateFilename(params.directory); + const filePath = join(outputDir, filename); + await mkdir(outputDir, { recursive: true }); + await writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8'); + return { + message: 'Violations report saved', + filePath: normalizeAbsolutePathToRelative(filePath, workspaceRoot), + stats: { + components: 1, + files: 0, + lines: 0, + }, + }; + } + + return report; } const violations: ViolationEntry[] = []; @@ -80,18 +85,52 @@ export const reportViolationsHandler = createHandler< } } - return { + const report = { component: params.componentName, violations, + rootPath: params.directory, }; + + if (params.saveAsFile) { + const outputDir = join( + cwd, + DEFAULT_OUTPUT_BASE, + OUTPUT_SUBDIRS.VIOLATIONS_REPORT, + params.componentName, + ); + const filename = generateFilename(params.directory); + const filePath = join(outputDir, filename); + await mkdir(outputDir, { recursive: true }); + await writeFile(filePath, JSON.stringify(report, null, 2), 'utf-8'); + + const stats = calculateSingleComponentStats(violations); + + return { + message: 'Violations report saved', + filePath: normalizeAbsolutePathToRelative(filePath, workspaceRoot), + stats, + }; + } + + return report; }, (result) => { - if (result.violations.length === 0) { - return [`No violations found for component: ${result.component}`]; + // Check if this is a file output response + if ('message' in result && 'filePath' in result) { + const stats = 'stats' in result ? result.stats : null; + const statsMessage = stats + ? ` (${stats.components} component, ${stats.files} files, ${stats.lines} lines)` + : ''; + return [`Violations report saved to ${result.filePath}${statsMessage}`]; + } + + const report = result as ComponentViolationReport; + if (report.violations.length === 0) { + return [`No violations found for component: ${report.component}`]; } const message = [ - `Found violations for component: ${result.component}`, + `Found violations for component: ${report.component}`, 'Use this output to identify:', ' - Which files contain violations', ' - The specific line numbers where violations occur', @@ -99,7 +138,7 @@ export const reportViolationsHandler = createHandler< '', 'Violation Report:', '', - JSON.stringify(result, null, 2), + JSON.stringify(report, null, 2), ]; return [message.join('\n')]; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/directory-grouping.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/directory-grouping.utils.ts new file mode 100644 index 0000000..88f6590 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/directory-grouping.utils.ts @@ -0,0 +1,49 @@ +import type { DirectorySummary, EnrichedFile } from './types.js'; + +/** + * Group files by directory hierarchy + */ +export function groupByDirectory(files: EnrichedFile[]): DirectorySummary[] { + const directoryMap = new Map(); + + files.forEach((file) => { + const dir = file.subdirectory || file.directory; + if (!directoryMap.has(dir)) { + directoryMap.set(dir, []); + } + directoryMap.get(dir)!.push(file); + }); + + return Array.from(directoryMap.entries()) + .map(([directory, dirFiles]) => ({ + directory, + files: dirFiles, + fileCount: dirFiles.length, + violations: dirFiles.reduce((sum, f) => sum + f.violations, 0), + })) + .sort((a, b) => b.violations - a.violations); +} + +/** + * Determine optimal number of groups + */ +export function determineOptimalGroups( + totalViolations: number, + directorySummary: DirectorySummary[], + minGroups: number, + maxGroups: number, + variance: number, +): number { + for (let g = minGroups; g <= maxGroups; g++) { + const target = totalViolations / g; + const maxAcceptable = target * (1 + variance / 100); + + const canBalance = directorySummary.every( + (dir) => dir.violations <= maxAcceptable, + ); + if (canBalance) { + return g; + } + } + return minGroups; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/file-enrichment.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/file-enrichment.utils.ts new file mode 100644 index 0000000..713f7a9 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/file-enrichment.utils.ts @@ -0,0 +1,21 @@ +import type { FileViolationReport } from '../models/types.js'; +import type { EnrichedFile } from './types.js'; + +/** + * Calculate total violations for a file + */ +export function calculateViolations(file: FileViolationReport): number { + return file.components.reduce((sum, comp) => sum + comp.lines.length, 0); +} + +/** + * Enrich files with metadata + */ +export function enrichFiles(files: FileViolationReport[]): EnrichedFile[] { + return files.map((file) => ({ + ...file, + violations: calculateViolations(file), + directory: file.file.split('/')[0], + subdirectory: file.file.split('/').slice(0, 2).join('/'), + })); +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/filename.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/filename.utils.ts new file mode 100644 index 0000000..fa59090 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/filename.utils.ts @@ -0,0 +1,11 @@ +/** + * Generates filename from directory path + * Example: "./packages/poker/core-lib" -> "packages-poker-core-lib-violations.json" + */ +export function generateFilename(directory: string): string { + const normalized = directory + .replace(/^\.\//, '') // Remove leading ./ + .replace(/\/+$/, '') // Remove trailing slashes + .replace(/\//g, '-'); // Replace slashes with dashes + return `${normalized}-violations.json`; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/format-converter.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/format-converter.utils.ts new file mode 100644 index 0000000..95167ce --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/format-converter.utils.ts @@ -0,0 +1,48 @@ +import type { + AllViolationsReport, + AllViolationsReportByFile, + FileViolationReport, +} from '../models/types.js'; + +/** + * Detect the format of the violations report + */ +export function detectReportFormat( + data: any, +): 'file' | 'component' | 'unknown' { + if (data.files && Array.isArray(data.files)) { + return 'file'; + } + if (data.components && Array.isArray(data.components)) { + return 'component'; + } + return 'unknown'; +} + +/** + * Convert component-grouped report to file-grouped format + */ +export function convertComponentToFileFormat( + report: AllViolationsReport, +): AllViolationsReportByFile { + const fileMap = report.components.reduce((map, componentReport) => { + return componentReport.violations.reduce((m, violation) => { + const existing = m.get(violation.file) ?? []; + existing.push({ + component: componentReport.component, + lines: violation.lines, + violation: violation.violation, + replacement: violation.replacement, + }); + m.set(violation.file, existing); + return m; + }, map); + }, new Map()); + + const files: FileViolationReport[] = Array.from( + fileMap.entries(), + ([file, components]) => ({ file, components }), + ).sort((a, b) => a.file.localeCompare(b.file)); + + return { files, rootPath: report.rootPath }; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/index.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/index.ts new file mode 100644 index 0000000..9bf6c82 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/index.ts @@ -0,0 +1,36 @@ +export { generateFilename } from './filename.utils.js'; +export { + parseViolationMessage, + parseViolationMessageWithReplacement, +} from './message-parser.utils.js'; +export { + calculateSingleComponentStats, + calculateComponentGroupedStats, + calculateFileGroupedStats, +} from './stats.utils.js'; +export { + detectReportFormat, + convertComponentToFileFormat, +} from './format-converter.utils.js'; +export { calculateViolations, enrichFiles } from './file-enrichment.utils.js'; +export { + groupByDirectory, + determineOptimalGroups, +} from './directory-grouping.utils.js'; +export { + assignGroupName, + calculateComponentDistribution, + createWorkGroups, + mapWorkGroupToReportGroup, +} from './work-group.utils.js'; +export { generateGroupMarkdown } from './markdown-generator.utils.js'; + +// Re-export all types from the centralized types file +export type { + ViolationStats, + EnrichedFile, + DirectorySummary, + WorkGroup, + ReportGroup, + GroupForMarkdown, +} from './types.js'; diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/markdown-generator.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/markdown-generator.utils.ts new file mode 100644 index 0000000..d186cbe --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/markdown-generator.utils.ts @@ -0,0 +1,50 @@ +import type { GroupForMarkdown } from './types.js'; + +/** + * Generate markdown report for a group + */ +export function generateGroupMarkdown(group: GroupForMarkdown): string { + // Component distribution summary + const componentSummary = Object.entries(group.componentDistribution) + .sort((a, b) => b[1] - a[1]) + .map(([comp, count]) => `${comp} (${count})`) + .join(', '); + + // Directory list with file counts + const directoryCounts = new Map(); + group.files.forEach((file) => { + const dir = file.file.split('/').slice(0, 2).join('/'); + directoryCounts.set(dir, (directoryCounts.get(dir) || 0) + 1); + }); + + const directoryList = Array.from(directoryCounts.entries()) + .map(([dir, count]) => `- ${dir} (${count} file${count > 1 ? 's' : ''})`) + .join('\n'); + + // File list with violations + const fileList = group.files + .map((file) => { + const componentLines = file.components + .map((comp) => { + const lines = comp.lines.map((l) => `L${l}`).join(','); + return `- ${comp.component}: ${lines}`; + }) + .join('\n'); + + return `\`${file.file}\` — ${file.violations} violation${file.violations > 1 ? 's' : ''}\n${componentLines}`; + }) + .join('\n\n'); + + return `# ${group.name} + +## Summary +${group.statistics.fileCount} files | ${group.statistics.violationCount} violations | ${componentSummary} + +## Directories +${directoryList} + +## Files to Update + +${fileList} +`; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/message-parser.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/message-parser.utils.ts new file mode 100644 index 0000000..c9581ff --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/message-parser.utils.ts @@ -0,0 +1,50 @@ +/** + * Extracts deprecated class from violation message + */ +export function parseViolationMessage(message: string): string { + // Clean up HTML tags + const cleanMessage = message + .replace(//g, '`') + .replace(/<\/code>/g, '`'); + + // Extract deprecated class - look for patterns like "class `offer-badge`" or "class `btn, btn-primary`" + const classMatch = cleanMessage.match(/class `([^`]+)`/); + return classMatch ? classMatch[1] : 'unknown'; +} + +/** + * Extracts deprecated class and replacement from violation message + * Performance optimized with caching to avoid repeated regex operations + */ +const messageParsingCache = new Map< + string, + { violation: string; replacement: string } +>(); + +export function parseViolationMessageWithReplacement(message: string): { + violation: string; + replacement: string; +} { + // Check cache first + const cached = messageParsingCache.get(message); + if (cached) { + return cached; + } + + // Clean up HTML tags + const cleanMessage = message + .replace(//g, '`') + .replace(/<\/code>/g, '`'); + + // Extract deprecated class - look for patterns like "class `offer-badge`" or "class `btn, btn-primary`" + const classMatch = cleanMessage.match(/class `([^`]+)`/); + const violation = classMatch ? classMatch[1] : 'unknown'; + + // Extract replacement component - look for "Use `ComponentName`" + const replacementMatch = cleanMessage.match(/Use `([^`]+)`/); + const replacement = replacementMatch ? replacementMatch[1] : 'unknown'; + + const result = { violation, replacement }; + messageParsingCache.set(message, result); + return result; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/stats.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/stats.utils.ts new file mode 100644 index 0000000..a4dfcf0 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/stats.utils.ts @@ -0,0 +1,66 @@ +import type { + ViolationEntry, + AllViolationsComponentReport, + FileViolationReport, +} from '../models/types.js'; +import type { ViolationStats } from './types.js'; + +/** + * Calculate statistics for single component violations + */ +export function calculateSingleComponentStats( + violations: ViolationEntry[], +): ViolationStats { + const uniqueFiles = new Set(violations.map((v) => v.file)).size; + const totalLines = violations.reduce((sum, v) => sum + v.lines.length, 0); + + return { + components: 1, + files: uniqueFiles, + lines: totalLines, + }; +} + +/** + * Calculate statistics for component-grouped violations + */ +export function calculateComponentGroupedStats( + components: AllViolationsComponentReport[], +): ViolationStats { + const uniqueComponents = components.length; + const uniqueFiles = new Set( + components.flatMap((c) => c.violations.map((v) => v.file)), + ).size; + const totalLines = components.reduce( + (sum, c) => sum + c.violations.reduce((s, v) => s + v.lines.length, 0), + 0, + ); + + return { + components: uniqueComponents, + files: uniqueFiles, + lines: totalLines, + }; +} + +/** + * Calculate statistics for file-grouped violations + */ +export function calculateFileGroupedStats( + files: FileViolationReport[], +): ViolationStats { + const uniqueComponents = new Set( + files.flatMap((f) => f.components.map((c) => c.component)), + ).size; + const uniqueFiles = files.length; + const totalLines = files.reduce( + (sum, f) => sum + f.components.reduce((s, c) => s + c.lines.length, 0), + 0, + ); + + return { + components: uniqueComponents, + files: uniqueFiles, + lines: totalLines, + }; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/types.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/types.ts new file mode 100644 index 0000000..255357a --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/types.ts @@ -0,0 +1,60 @@ +import type { FileViolationReport } from '../models/types.js'; + +export interface ViolationStats { + components: number; + files: number; + lines: number; +} + +export interface EnrichedFile extends FileViolationReport { + violations: number; + directory: string; + subdirectory: string; +} + +export interface DirectorySummary { + directory: string; + files: EnrichedFile[]; + fileCount: number; + violations: number; +} + +export interface WorkGroup { + id: number; + name: string; + directories: string[]; + files: EnrichedFile[]; + violations: number; + componentDistribution: Record; +} + +export interface ReportGroup { + id: number; + name: string; + rootPath: string; + directories: string[]; + files: Array<{ + file: string; + violations: number; + components: FileViolationReport['components']; + }>; + statistics: { + fileCount: number; + violationCount: number; + }; + componentDistribution: Record; +} + +export interface GroupForMarkdown { + name: string; + statistics: { + fileCount: number; + violationCount: number; + }; + componentDistribution: Record; + files: Array<{ + file: string; + violations: number; + components: FileViolationReport['components']; + }>; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/work-group.utils.ts b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/work-group.utils.ts new file mode 100644 index 0000000..15a8522 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/report-violations/utils/work-group.utils.ts @@ -0,0 +1,101 @@ +import type { + DirectorySummary, + EnrichedFile, + ReportGroup, + WorkGroup, +} from './types.js'; + +/** + * Map a WorkGroup to the report group format + */ +export function mapWorkGroupToReportGroup( + group: WorkGroup, + rootPath: string, +): ReportGroup { + return { + id: group.id, + name: group.name, + rootPath, + directories: group.directories, + files: group.files.map((f) => ({ + file: f.file, + violations: f.violations, + components: f.components, + })), + statistics: { + fileCount: group.files.length, + violationCount: group.violations, + }, + componentDistribution: group.componentDistribution, + }; +} + +/** + * Assign group name based on primary directory + */ +export function assignGroupName( + directories: string[], + groupId: number, +): string { + const primaryDir = directories[0] || 'misc'; + return `Group ${groupId} - ${primaryDir}`; +} + +/** + * Calculate component distribution for a group + */ +export function calculateComponentDistribution( + files: EnrichedFile[], +): Record { + return files.reduce>((distribution, file) => { + return file.components.reduce((dist, comp) => { + dist[comp.component] = (dist[comp.component] || 0) + comp.lines.length; + return dist; + }, distribution); + }, {}); +} + +/** + * Create work groups using bin-packing algorithm + */ +export function createWorkGroups( + directorySummary: DirectorySummary[], + optimalGroups: number, + maxAcceptable: number, +): WorkGroup[] { + const groups: WorkGroup[] = Array.from({ length: optimalGroups }, (_, i) => ({ + id: i + 1, + name: '', + directories: [], + files: [], + violations: 0, + componentDistribution: {}, + })); + + // Bin packing: first-fit decreasing + const sortedDirs = [...directorySummary].sort( + (a, b) => b.violations - a.violations, + ); + + sortedDirs.forEach((dir) => { + // Find group with least violations that can fit this directory + const targetGroup = groups + .filter((g) => g.violations + dir.violations <= maxAcceptable) + .sort((a, b) => a.violations - b.violations)[0]; + + const selectedGroup = + targetGroup || groups.sort((a, b) => a.violations - b.violations)[0]; + + selectedGroup.directories.push(dir.directory); + selectedGroup.files.push(...dir.files); + selectedGroup.violations += dir.violations; + }); + + // Assign names and component distribution + groups.forEach((group) => { + group.name = assignGroupName(group.directories, group.id); + group.componentDistribution = calculateComponentDistribution(group.files); + }); + + return groups; +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/constants.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/constants.ts new file mode 100644 index 0000000..d2f9ed7 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/constants.ts @@ -0,0 +1,35 @@ +import { join, basename } from 'node:path'; + +/** + * Default output directory for all generated files. + * Used as the base path for contracts, violations reports, and work groups. + */ +export const DEFAULT_OUTPUT_BASE = 'tmp/.angular-toolkit-mcp'; + +/** + * Subdirectory paths relative to DEFAULT_OUTPUT_BASE + */ +export const OUTPUT_SUBDIRS = { + CONTRACTS: 'contracts', + CONTRACT_DIFFS: 'contracts/diffs', + VIOLATIONS_REPORT: 'violations-report', + VIOLATION_GROUPS: 'violation-groups', +} as const; + + +export function resolveDefaultSaveLocation( + saveLocation: string | undefined, + subdir: string, + sourceFile: string, + suffix: string, + stripExtension = '.ts', +): string { + if (saveLocation) { + return saveLocation; + } + return join( + DEFAULT_OUTPUT_BASE, + subdir, + `${basename(sourceFile, stripExtension)}${suffix}`, + ); +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/index.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/index.ts index 0d8b0e1..1c42c56 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/index.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/index.ts @@ -5,3 +5,4 @@ export * from './utils/component-validation.js'; export * from './utils/output.utils.js'; export * from './utils/cross-platform-path.js'; export * from './models/schema-helpers.js'; +export * from './constants.js'; 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 5690766..eb61c4f 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/tools.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/tools.ts @@ -2,6 +2,7 @@ import { ToolsConfig } from '@push-based/models'; import { reportViolationsTools, reportAllViolationsTools, + groupViolationsTools, } from './report-violations/index.js'; import { getProjectDependenciesTools } from './project/get-project-dependencies.tool.js'; import { reportDeprecatedCssTools } from './project/report-deprecated-css.tool.js'; @@ -19,6 +20,7 @@ export const dsTools: ToolsConfig[] = [ // Project tools ...reportViolationsTools, ...reportAllViolationsTools, + ...groupViolationsTools, ...getProjectDependenciesTools, ...reportDeprecatedCssTools, ...buildComponentUsageGraphTools,