diff --git a/.cursor/flows/README.md b/.cursor/flows/README.md deleted file mode 100644 index a35a5d0..0000000 --- a/.cursor/flows/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# Cursor Flows - -This directory contains AI-assisted workflow templates (flows) for Angular component refactoring and design system migration tasks. These flows are designed to work with Cursor IDE's rule system to provide structured, step-by-step guidance for complex refactoring operations. - -## What are Flows? - -Flows are collections of rule files (.mdc) that guide the AI through multi-step processes. Each flow contains: -- **Rule files (.mdc)**: Step-by-step instructions for the AI -- **Documentation**: Supporting materials and best practices -- **Templates**: Reusable patterns and examples - -## Available Flows - -### 1. Component Refactoring Flow -**Location:** `component-refactoring/` -**Purpose:** Improve individual Angular components according to modern best practices - -**Files:** -- `01-review-component.mdc` - Analyze component and create improvement plan -- `02-refactor-component.mdc` - Execute refactoring checklist -- `03-validate-component.mdc` - Verify improvements through contract comparison -- `angular-20.md` - Angular best practices reference - -**Use Case:** When you need to modernize a single component's code quality, performance, or maintainability. - -### 2. Design System Refactoring Flow -**Location:** `ds-refactoring-flow/` -**Purpose:** Migrate components from deprecated design system patterns to modern alternatives - -**Flow Options:** - -**Option A: Targeted Approach** (recommended for focused, incremental migrations) -- `01-find-violations.mdc` - Identify specific deprecated component usage -- `02-plan-refactoring.mdc` - Create detailed migration strategy for specific cases - -**Option B: Comprehensive Approach** (recommended for large-scale migrations) -- `01b-find-all-violations.mdc` - Scan entire codebase, group by folders, select subfolder for detailed analysis -- `02b-plan-refactoring-for-all-violations.mdc` - Create comprehensive migration plan for all violations in scope - -**Continuation Steps** (used with both approaches): -- `03-non-viable-cases.mdc` - Handle non-migratable components by marking them for exclusion -- `03-fix-violations.mdc` - Execute code changes -- `04-validate-changes.mdc` - Verify improvements through contract comparison -- `05-prepare-report.mdc` - Generate testing checklists and documentation -- `clean-global-styles.mdc` - Independent analysis of deprecated CSS usage - -**Choosing Your Approach:** -- **Targeted (01 → 02)**: Use when working on specific components or small sets of violations. Provides focused analysis and incremental progress. -- **Comprehensive (01b → 02b)**: Use when planning large-scale migrations across multiple folders. Provides broad overview first, then detailed planning for selected scope. - -**Special Handling:** -- **Non-Viable Cases**: When components are identified as non-viable during the planning step, use `03-non-viable-cases.mdc` instead of proceeding with the normal fix violations step. This marks components with special prefixes (`after-migration-[ORIGINAL_CLASS]`) to exclude them from future violation reports. - -**Use Cases:** -- **Targeted Flow**: Incremental migration of specific components or small violation sets -- **Comprehensive Flow**: Large-scale migration planning across multiple directories -- **Non-Viable Handling**: Alternative handling within either flow for legacy components that cannot be migrated - -## How to Use Flows - -1. Copy the desired flow's `.mdc` files to your `.cursor/rules/` directory -2. The rules will be automatically available in Cursor -3. Follow the flow documentation for step-by-step guidance - -## Prerequisites - -Before using any flow, ensure you have: -- **Cursor IDE** with MCP (Model Context Protocol) server connected -- **Git branch** for your refactoring work -- **Component files** accessible in your workspace -- **Angular project** with proper TypeScript configuration - -## Flow Process Overview - -Most flows follow a similar pattern: -1. **Analysis** - Review current state and identify issues -2. **Planning** - Create actionable improvement checklist -3. **Execution** - Implement changes systematically -4. **Validation** - Verify improvements and quality gates -5. **Reporting** - Document changes and results - -## Quality Gates - -Flows include human review checkpoints to ensure: -- ✅ Analysis accuracy -- ✅ Refactoring plan approval -- ✅ Code quality validation -- ✅ Final acceptance - -## Documentation - -For detailed information about each flow, see: -- [Component Refactoring Flow](../../docs/component-refactoring-flow.md) -- [Architecture & Design](../../docs/architecture-internal-design.md) -- [Contracts Documentation](../../docs/contracts.md) \ No newline at end of file diff --git a/.cursor/flows/component-refactoring/01-review-component.mdc b/.cursor/flows/component-refactoring/01-review-component.mdc deleted file mode 100644 index 04a2285..0000000 --- a/.cursor/flows/component-refactoring/01-review-component.mdc +++ /dev/null @@ -1,66 +0,0 @@ -You are an AI assistant tasked with reviewing an Angular component and proposing a refactoring plan. Your goal is to analyze the component implementation, evaluate it against specific categories, provide scores, summarize strengths and weaknesses, and propose a refactoring checklist. - -You will be provided with the following inputs: - -1. Component path: The path to the component's primary .ts file -2. Styleguide: A short description or URL of guidelines to follow -3. Component files: The content of the component's TypeScript, template, and style files - -Here's how to proceed: - -1. File Gathering: - Review the provided {{COMPONENT_FILES}}. This should include: - -- The primary TypeScript file (.ts) -- The template file (either inline in the .ts file or as a separate .html file) -- The primary style sheet (.scss, .css, .sass, or .less) if present - -If any essential file is missing, respond with: -❌ Component files missing: [list missing files] -Then stop the process. - -2. Review Process: - Analyze the code against the provided {{STYLEGUIDE}} and general Angular/Design System best practices. Focus on these five categories: - -- Accessibility -- Performance -- Scalability -- Maintainability -- Best Practices - -For each category, identify 3-5 concrete observations. - -3. Output Format: - Provide your analysis in the following format: - - -[Write a short narrative (150-250 words) describing the overall state of the component] - - - -Accessibility: [Score 1-10] -Performance: [Score 1-10] -Scalability: [Score 1-10] -Maintainability: [Score 1-10] -Best Practices: [Score 1-10] - - - - -- [ ] [First actionable item] -- [ ] [Second actionable item] - [Continue with more actionable items] - - -After the checklist, ask once: -🛠️ Approve this checklist or request changes? - -4. Rules and Guidelines: - -- Do not include any text outside the specified tags or the single approval question. -- If anything is unclear, ask for clarification inside a block and stop the process. -- Assume paths are workspace-relative unless they are absolute. -- Do not use any external tools or services not explicitly provided in these instructions. - -5. Final Output: - Based on your analysis of the {{COMPONENT_FILES}} located at {{COMPONENT_PATH}}, and following the {{STYLEGUIDE}}, provide your complete review and refactoring plan using the format specified above. diff --git a/.cursor/flows/component-refactoring/02-refactor-component.mdc b/.cursor/flows/component-refactoring/02-refactor-component.mdc deleted file mode 100644 index e7799fb..0000000 --- a/.cursor/flows/component-refactoring/02-refactor-component.mdc +++ /dev/null @@ -1,55 +0,0 @@ -You are an AI assistant tasked with refactoring an Angular component according to an approved checklist. Your goal is to follow a specific workflow and provide a summary of the refactoring process. Here are your instructions: - -First, you will be given the path to the component file: - -{{COMPONENT_PATH}} - - -Next, you will receive the content of the refactoring checklist: - -{{CHECKLIST_CONTENT}} - - -Follow this workflow: - -1. Build Pre-Refactor Contract - - Call the tool using this exact format: - build_component_contract componentFile="{{COMPONENT_PATH}}" dsComponentName="AUTO" - - The tool will detect the template and style automatically. - - Store the result in a variable called baselineContract. - - If the tool returns an error, respond with: - 🚨 Contract build failed - [include the error message here] - Then stop the process. - -2. Iterate Through Checklist - For each unchecked item in the checklist: - a. Make the necessary code edits (use Cursor edit instructions as you normally would). - b. Mark the item as done with a short note explaining what was changed. - If any item is ambiguous or unclear, ask the user for clarification using: - [Your question about the ambiguous item] - Then stop the process and wait for a response. - -3. Update Checklist File - Save the updated checklist to a file named: - .cursor/tmp/component-refactor-checklist-{{COMPONENT_PATH}}.md - -4. Summary Output - Provide a summary of the refactoring process using these exact tags: - - [List each completed item with a brief note about what was changed] - - - - [Include the full updated checklist markdown here] - - -After providing the summary output, ask the following question: -✅ Refactor complete. Proceed to validation? - -Important rules to follow: - -- Do NOT build a post-refactor contract in this step. -- Do not provide any extra commentary outside of the specified blocks and questions unless there is an error or you need clarification. -- Always use the exact tag names and formats specified in these instructions. - -Remember, your role is to follow these instructions precisely and provide clear, concise output as specified. diff --git a/.cursor/flows/component-refactoring/03-validate-component.mdc b/.cursor/flows/component-refactoring/03-validate-component.mdc deleted file mode 100644 index 3404329..0000000 --- a/.cursor/flows/component-refactoring/03-validate-component.mdc +++ /dev/null @@ -1,69 +0,0 @@ -You are an AI assistant tasked with validating a refactored Angular component. Your job is to analyze the changes made to the component and provide an assessment of its quality across various dimensions. Follow these instructions carefully to complete the task. - -You will be provided with two input variables: - -{{COMPONENT_PATH}} - -This is the path to the refactored Angular component file. - - -{{BASELINE_CONTRACT_PATH}} - -This is the path to the baseline contract file captured before refactoring. - -Follow this workflow to complete the validation: - -1. Build Post-Refactor Contract - Call the following tool: - build_component_contract componentFile="{{COMPONENT_PATH}}" dsComponentName="AUTO" - Save the returned path as updatedContract. - If the tool returns an error, output: - 🚨 Contract build failed – [include the error message] - Then stop the process. - -2. Diff Contracts - Call the following tool: - diff_component_contract contractBeforePath="{{BASELINE_CONTRACT_PATH}}" contractAfterPath="[updatedContract]" dsComponentName="AUTO" - Store the result as diffAnalysis. - -3. Analyse Diff & Re-Score - Based on the diffAnalysis, re-evaluate the following five categories: - - Accessibility - - Performance - - Scalability - - Maintainability - - Best Practices - - For each category: - a. Analyze the changes and their impact - b. Determine a new score on a scale of 1-10 - c. Calculate the change (delta) from the original score - d. Identify specific improvements or regressions - -4. Output Results - Provide your analysis in the following format: - - - [Write a high-level summary of the changes detected in the component] - - - - Accessibility: [Score 1-10] (Δ [change]) - Performance: [Score 1-10] (Δ [change]) - Scalability: [Score 1-10] (Δ [change]) - Maintainability: [Score 1-10] (Δ [change]) - Best Practices: [Score 1-10] (Δ [change]) - - - - [Provide an overall judgment: either "✅ Success" or "⚠️ Issues found"] - [List any remaining risks or necessary follow-ups] - - -Rules and reminders: - -- Strictly confine your output to the three tagged blocks (diff_summary, new_scoring, and validation_assessment). -- Do not include any internal thoughts or additional commentary outside these blocks. -- Ensure your analysis is objective and based solely on the information provided by the diff analysis. -- When calculating score changes, use "+" for improvements and "-" for regressions. -- After providing the three required blocks, do not add any additional text or explanations. diff --git a/.cursor/flows/component-refactoring/angular-20.md b/.cursor/flows/component-refactoring/angular-20.md deleted file mode 100644 index a54a532..0000000 --- a/.cursor/flows/component-refactoring/angular-20.md +++ /dev/null @@ -1,131 +0,0 @@ -# Angular Best Practices - -This project adheres to modern Angular best practices, emphasizing maintainability, performance, accessibility, and scalability. - -## TypeScript Best Practices - -- **Strict Type Checking:** Always enable and adhere to strict type checking. This helps catch errors early and improves code quality. -- **Prefer Type Inference:** Allow TypeScript to infer types when they are obvious from the context. This reduces verbosity while maintaining type safety. - - **Bad:** - ```typescript - let name: string = 'Angular'; - ``` - - **Good:** - ```typescript - let name = 'Angular'; - ``` -- **Avoid `any`:** Do not use the `any` type unless absolutely necessary as it bypasses type checking. Prefer `unknown` when a type is uncertain and you need to handle it safely. - -## Angular Best Practices - -- **Standalone Components:** Always use standalone components, directives, and pipes. Avoid using `NgModules` for new features or refactoring existing ones. -- **Implicit Standalone:** When creating standalone components, you do not need to explicitly set `standalone: true` as it is implied by default when generating a standalone component. - - **Bad:** - ```typescript - @Component({ - standalone: true, - // ... - }) - export class MyComponent {} - ``` - - **Good:** - ```typescript - @Component({ - // `standalone: true` is implied - // ... - }) - export class MyComponent {} - ``` -- **Signals for State Management:** Utilize Angular Signals for reactive state management within components and services. -- **Lazy Loading:** Implement lazy loading for feature routes to improve initial load times of your application. -- **NgOptimizedImage:** Use `NgOptimizedImage` for all static images to automatically optimize image loading and performance. - -## Components - -- **Single Responsibility:** Keep components small, focused, and responsible for a single piece of functionality. -- **`input()` and `output()` Functions:** Prefer `input()` and `output()` functions over the `@Input()` and `@Output()` decorators for defining component inputs and outputs. - - **Old Decorator Syntax:** - ```typescript - @Input() userId!: string; - @Output() userSelected = new EventEmitter(); - ``` - - **New Function Syntax:** - - ```typescript - import { input, output } from '@angular/core'; - - // ... - userId = input(''); - userSelected = output(); - ``` - -- **`computed()` for Derived State:** Use the `computed()` function from `@angular/core` for derived state based on signals. -- **`ChangeDetectionStrategy.OnPush`:** Always set `changeDetection: ChangeDetectionStrategy.OnPush` in the `@Component` decorator for performance benefits by reducing unnecessary change detection cycles. -- **Inline Templates:** Prefer inline templates (template: `...`) for small components to keep related code together. For larger templates, use external HTML files. -- **Reactive Forms:** Prefer Reactive forms over Template-driven forms for complex forms, validation, and dynamic controls due to their explicit, immutable, and synchronous nature. -- **No `ngClass` / `NgClass`:** Do not use the `ngClass` directive. Instead, use native `class` bindings for conditional styling. - - **Bad:** - ```html -
- ``` - - **Good:** - ```html -
-
-
- ``` -- **No `ngStyle` / `NgStyle`:** Do not use the `ngStyle` directive. Instead, use native `style` bindings for conditional inline styles. - - **Bad:** - ```html -
- ``` - - **Good:** - ```html -
-
- ``` - -## State Management - -- **Signals for Local State:** Use signals for managing local component state. -- **`computed()` for Derived State:** Leverage `computed()` for any state that can be derived from other signals. -- **Pure and Predictable Transformations:** Ensure state transformations are pure functions (no side effects) and predictable. - -## Templates - -- **Simple Templates:** Keep templates as simple as possible, avoiding complex logic directly in the template. Delegate complex logic to the component's TypeScript code. -- **Native Control Flow:** Use the new built-in control flow syntax (`@if`, `@for`, `@switch`) instead of the older structural directives (`*ngIf`, `*ngFor`, `*ngSwitch`). - - **Old Syntax:** - ```html -
Content
-
{{ item }}
- ``` - - **New Syntax:** - ```html - @if (isVisible) { -
Content
- } @for (item of items; track item.id) { -
{{ item }}
- } - ``` -- **Async Pipe:** Use the `async` pipe to handle observables in templates. This automatically subscribes and unsubscribes, preventing memory leaks. - -## Services - -- **Single Responsibility:** Design services around a single, well-defined responsibility. -- **`providedIn: 'root'`:** Use the `providedIn: 'root'` option when declaring injectable services to ensure they are singletons and tree-shakable. -- **`inject()` Function:** Prefer the `inject()` function over constructor injection when injecting dependencies, especially within `provide` functions, `computed` properties, or outside of constructor context. - - **Old Constructor Injection:** - ```typescript - constructor(private myService: MyService) {} - ``` - - **New `inject()` Function:** - - ```typescript - import { inject } from '@angular/core'; - - export class MyComponent { - private myService = inject(MyService); - // ... - } - ``` diff --git a/.cursor/flows/ds-refactoring-flow/01-find-violations.mdc b/.cursor/flows/ds-refactoring-flow/01-find-violations.mdc deleted file mode 100644 index 255899c..0000000 --- a/.cursor/flows/ds-refactoring-flow/01-find-violations.mdc +++ /dev/null @@ -1,90 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are an AI assistant tasked with helping a developer identify and plan refactoring for legacy component usage. Follow these instructions carefully to complete the task in two main steps. - -First, I will provide you with the following information: -{{COMPONENT_NAME}} -{{DIRECTORY}} - -Step 1: Find violations - -1. Run a scan using the report-violations function with the following parameters: - - component: {{COMPONENT_NAME}} - - directory: {{DIRECTORY}} - - groupBy: "folder" - - Store the result in a variable called scanResult. - -2. Perform first-level error handling: - - If the function call returns an error or result containing "Missing ds.deprecatedCssClassesPath", respond with: - ⚠️ *Cannot proceed: Missing required configuration parameter* – The `ds.deprecatedCssClassesPath` parameter must be provided when starting the MCP server to use violation detection tools. Please restart the server with this parameter configured. - Then stop execution. - - If the function call returns any other error, respond with: - 🚨 *Tool execution failed* – [error message] - Then stop execution. - - If scanResult.totalViolations is 0, respond with: - "✅ No legacy usage of {{COMPONENT_NAME}} found." - Then stop execution. - - Otherwise, continue to the next step. - -3. Output the results for the user: - - Print the ranked list of folders inside tags, like this: - - 1. [path/to/folder-A] – [X] violations in [Y] files - 2. [path/to/folder-B] – [X] violations in [Y] files - ... - - - After the tag, ask exactly once: - *Which sub-folder should I scan?* - (Accept either full path or list index.) - -Do not output anything else outside the tags and the follow-up question, unless you need to show a block for error or clarification. - -Step 2: Target sub-folder scan - -Once the user provides a subfolder choice, proceed as follows: - -1. Validate the user input: - - If the chosen subfolder is not in rankedFolders, respond with: - ❌ *Selected sub-folder not found in previous list. Please choose a valid entry.* - Then stop execution. - -2. Run a file-level scan: - - Use the report-violations function with these parameters: - - component: {{COMPONENT_NAME}} - - directory: {{SUBFOLDER}} - - groupBy: "file" - - Store the result in a variable called fileScan. - -3. Perform error handling and validation: - - If the function call returns an error containing "Missing ds.deprecatedCssClassesPath", respond with: - ⚠️ *Cannot proceed: Missing required configuration parameter* – The `ds.deprecatedCssClassesPath` parameter must be provided when starting the MCP server to use violation detection tools. Please restart the server with this parameter configured. - Then stop execution. - - If the function call returns any other error, respond with: - 🚨 *Tool execution failed* – [error message] - Then stop execution. - - If fileScan.rows.length is 0, respond with: - ⚠️ No violations found in {{SUBFOLDER}}. - Then stop execution. - - Sort the files by number of violations (descending) and then alphabetically. - -4. Output the results for the plan phase: - - Print the sorted list of files inside tags, like this: - - 1. [path/to/file-A.tsx] – [X] violations - 2. [path/to/file-B.tsx] – [X] violations - ... - - - After the tag, prompt the user with: - ❓ **Please attach the "Plan Phase" rules now so I can start refactoring planning.** - -As in Step 1, any side remarks should go in an optional ... tag. - -Final instructions: -- Always use the exact format and wording provided for outputs and prompts. -- Do not add any explanations or additional text unless explicitly instructed. -- If you encounter any situations not covered by these instructions, respond with: - ⚠️ Unexpected situation encountered. Please provide further guidance. \ No newline at end of file diff --git a/.cursor/flows/ds-refactoring-flow/01b-find-all-violations.mdc b/.cursor/flows/ds-refactoring-flow/01b-find-all-violations.mdc deleted file mode 100644 index 0ee4864..0000000 --- a/.cursor/flows/ds-refactoring-flow/01b-find-all-violations.mdc +++ /dev/null @@ -1,79 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are an AI assistant tasked with helping a developer identify and plan refactoring for legacy component usage. Follow these instructions carefully to complete the task in two main steps. - -First, I will provide you with the following information: -{{DIRECTORY}} - -Step 1: Find violations - -1. Run a scan using the report-all-violations function with the following parameters: - - directory: {{DIRECTORY}} - - groupBy: "folder" - - Store the result in a variable called scanResult. - -2. Perform first-level error handling: - - If the function call returns an error or result containing "Missing ds.deprecatedCssClassesPath", respond with: - ⚠️ *Cannot proceed: Missing required configuration parameter* – The `ds.deprecatedCssClassesPath` parameter must be provided when starting the MCP server to use violation detection tools. Please restart the server with this parameter configured. - Then stop execution. - - If the function call returns any other error, respond with: - 🚨 *Tool execution failed* – [error message] - Then stop execution. - - If no violations are found, respond with: - "✅ No legacy usage found." - Then stop execution. - - Otherwise, continue to the next step. - -3. Output the results for the user: - - Print the ranked list of folders inside tags, like this: - - 1. [path/to/folder-A] – [X] violations in [Y] files - 2. [path/to/folder-B] – [X] violations in [Y] files - ... - - - After the tag, ask exactly once: - *Which sub-folder should I scan?* - (Accept either full path or list index.) - -Do not output anything else outside the tags and the follow-up question, unless you need to show a block for error or clarification. - -Step 2: Target sub-folder scan - -Once the user provides a subfolder choice, proceed as follows: - -1. Validate the user input: - - If the chosen subfolder is not in rankedFolders, respond with: - ❌ *Selected sub-folder not found in previous list. Please choose a valid entry.* - Then stop execution. - -2. Run a file-level scan: - - Use the report-all-violations function with these parameters: - - directory: {{SUBFOLDER}} - - groupBy: "file" - - Store the result in a variable called fileScan. - -3. Perform error handling and validation: - - If the function call returns an error containing "Missing ds.deprecatedCssClassesPath", respond with: - ⚠️ *Cannot proceed: Missing required configuration parameter* – The `ds.deprecatedCssClassesPath` parameter must be provided when starting the MCP server to use violation detection tools. Please restart the server with this parameter configured. - Then stop execution. - - If the function call returns any other error, respond with: - 🚨 *Tool execution failed* – [error message] - Then stop execution. - - If fileScan.rows.length is 0, respond with: - ⚠️ No violations found in {{SUBFOLDER}}. - Then stop execution. - - Sort the files by number of violations (descending) and then alphabetically. - -4. Output the results for the plan phase: - - Print the sorted list of files inside tags, like this: - - 1. [path/to/file-A.tsx] – [X] violations - 2. [path/to/file-B.tsx] – [X] violations - ... - - - After the tag, prompt the user with: - ❓ **Please attach the "Plan Phase" rules now so I can start refactoring planning.** \ No newline at end of file diff --git a/.cursor/flows/ds-refactoring-flow/02-plan-refactoring.mdc b/.cursor/flows/ds-refactoring-flow/02-plan-refactoring.mdc deleted file mode 100644 index d756b8c..0000000 --- a/.cursor/flows/ds-refactoring-flow/02-plan-refactoring.mdc +++ /dev/null @@ -1,81 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are an AI assistant tasked with helping a development team migrate legacy components to a new design system. Your goal is to analyze the current codebase, identify areas that need updating, and provide a detailed plan for the migration process. This task will be completed in three phases: a comprehensive analysis, a detailed plan creation, and a checklist creation. - -You will be working with the following inputs: -{{COMPONENT_NAME}}: The name of the target design-system component -{{FOLDER_PATH}}: The path to the folder containing the legacy components -{{COMPONENT_DOCS}}: The official documentation for the target design-system component -{{COMPONENT_CODE}}: The source files of the target design-system component -{{USAGE_GRAPH}}: A graph showing the usage of the legacy component in the specified folder -{{LIBRARY_DATA}}: Information about library type - -# Phase 1: Comprehensive Analysis - -1. Review all provided inputs: COMPONENT_DOCS, COMPONENT_CODE, USAGE_GRAPH, and LIBRARY_DATA. - -2. Analyze the current codebase, focusing on: - a. The approved markup and API for the target component - b. The actual implementation of the design-system component - c. All files (templates, TS, styles, specs, NgModules) that reference the legacy component - d. Dependencies and library information - -3. Create a comprehensive summary of the analysis, including: - a. Total number of files affected - b. Assessment of migration complexity (Low, Medium, High) - c. Any potential non-viable migrations that may require manual rethinking - d. Key decisions or assumptions made during the analysis - e. Insights gained from examining the component files - f. Implications of the LIBRARY_DATA on the migration process - -Write your comprehensive analysis in tags. - -# Phase 2: Detailed Plan Creation - -Please think about this problem thoroughly and in great detail. Consider multiple approaches and show your complete reasoning. Please perform a thourough and Based on your comprehensive analysis, create a detailed migration plan: - -1. For each affected file: - a. Compare the old markup against the design-system exemplar from the COMPONENT_DOCS. - b. Classify the migration effort as: - - Simple swap (straight replacement with no loss of behavior, styling, responsive rules, animation, click/test-ID, or accessibility attributes) - - Requires restructure (minor code or CSS tweaks needed to preserve behaviors or visuals that the design-system component lacks) - - Non-viable (needs manual rethink) - c. Assign a complexity score on a scale of 1-10, adding: - - +1 per removed animation or breakpoint - - +2 per business variant that needs to be rebuilt - -2. Create an actionable plan ordered by effort, including: - a. File path & type - b. Refactor classification - c. Concrete edits needed (template, TS, styles, NgModule, spec) - d. Verification notes (2-3 static checks that can be performed by reading files only) - e. Complexity score - -3. If any items are classified as non-viable, explicitly highlight these in a separate section of your plan. - -4. Review your detailed plan against the COMPONENT_DOCS to ensure all recommendations align with the official documentation. - -5. Identify any ambiguities in your plan that could be interpreted multiple ways and list these in a separate section. - -Write your detailed migration plan in tags. - -# Phase 3: Checklist Creation - -After the user approves the plan and clarifies any ambiguities: - -1. Create a checklist that lists only actual changes as checkboxes. -2. Create a "check" phase where all verifications (2-3 static checks that can be performed by reading files only) are listed as checkboxes. -3. Ensure the checklist is comprehensive and follows directly from the approved migration plan. - -Write your checklist in tags. - -Your final output should include only the following: -1. The block -2. The block -3. The following approval request: "🛠️ Approve this plan or specify adjustments?" -4. If applicable, an ambiguity safeguard: "❓ The plan contains ambiguities: [short description]. Please clarify." - -After the user approves the plan and clarifies any ambiguities, provide only the block in your response. Also, remember to save the checklist in a file at .cursor/tmp/refactoring-checklis-{{FOLDER_PATH}}.md. \ No newline at end of file diff --git a/.cursor/flows/ds-refactoring-flow/02b-plan-refactoring-for-all-violations.mdc b/.cursor/flows/ds-refactoring-flow/02b-plan-refactoring-for-all-violations.mdc deleted file mode 100644 index 0da85fb..0000000 --- a/.cursor/flows/ds-refactoring-flow/02b-plan-refactoring-for-all-violations.mdc +++ /dev/null @@ -1,37 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are an AI assistant tasked with helping a development team migrate legacy components to a new design system. Your goal is to analyze the current codebase, identify areas that need updating, and provide a detailed plan for the migration process. This task will be completed in three phases: a comprehensive analysis, a detailed plan creation, and a checklist creation. - -You will be working with the following inputs: -{{FOLDER_PATH}}: The path to the folder containing the legacy components -{{COMPONENT_DOCS}}: The official documentation for the target design-system components -{{COMPONENT_CODE}}: The source files of the target design-system components -{{USAGE_GRAPH}}: A graph showing the usage of the legacy components in the specified folder -{{LIBRARY_DATA}}: Information about library type - -# Phase 1: Comprehensive Analysis - -1. Review all provided inputs: COMPONENT_DOCS, COMPONENT_CODE, USAGE_GRAPH, and LIBRARY_DATA. - -2. Analyze the current codebase, focusing on: - a. The approved markup and API for the target components - b. The actual implementation of the design-system components - c. All files (templates, TS, styles, specs, NgModules) that reference the legacy components - d. Dependencies and library information - -3. Create a comprehensive summary of the analysis, including: - a. Total number of files affected - b. Assessment of migration complexity (Low, Medium, High) - c. Any potential non-viable migrations that may require manual rethinking - d. Key decisions or assumptions made during the analysis - e. Insights gained from examining the component files - f. Implications of the LIBRARY_DATA on the migration process - -Write your comprehensive analysis in tags. - -# Phase 2: Detailed Plan Creation - -Please think about this problem thoroughly \ No newline at end of file diff --git a/.cursor/flows/ds-refactoring-flow/03-fix-violations.mdc b/.cursor/flows/ds-refactoring-flow/03-fix-violations.mdc deleted file mode 100644 index 6b921a4..0000000 --- a/.cursor/flows/ds-refactoring-flow/03-fix-violations.mdc +++ /dev/null @@ -1,49 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- - -You are an AI assistant tasked with refactoring code based on a checklist and updating the checklist accordingly. Follow these steps carefully: - -1. Read the refactoring checklist from the file located at .cursor/tmp/refactoring-checklist-{{FOLDER_PATH}}. The content of this checklist is provided here: - - -{{CHECKLIST_CONTENT}} - - -2. For each component mentioned in the checklist, use the `build_component_contract` tool to create contracts. The syntax for using this tool is: - -build_component_contract(component_file, dsComponentName) - -Replace "component_files" with the actual files of the component. - -3. Execute the checklist items one by one. For each item: - a. Analyze the component using the contract built in step 2. - b. Determine if any changes are needed based on the checklist item. - c. If changes are needed, describe the changes you would make. - d. DO NOT BUILD CONTRACTS FOR THE UPDATED COMPONENT STATES - -4. Update the checklist file with the changes made. For each item, add a note describing what was changed or why no change was needed. - -5. Reflect on the changes you've made. If anything is unclear or you have additional suggestions: - a. Explicitly ask the user for confirmation. - b. Provide a clear explanation of your uncertainty or suggestion. - -6. Prepare your final output in the following format: - - - -[List the updated checklist items here, including notes on changes made or why no changes were needed] - - - -[Include any reflections, uncertainties, or additional suggestions here] - - - -[List any specific points where user confirmation is needed] - - - -Remember, your final output should only include the content within the tags. Do not include any of your thought process or the steps you took to arrive at this output. diff --git a/.cursor/flows/ds-refactoring-flow/03-non-viable-cases.mdc b/.cursor/flows/ds-refactoring-flow/03-non-viable-cases.mdc deleted file mode 100644 index 0d8f22d..0000000 --- a/.cursor/flows/ds-refactoring-flow/03-non-viable-cases.mdc +++ /dev/null @@ -1,100 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are a design system migration specialist executing a non-migratable component workflow. Follow this systematic process: - -## PHASE 1: IDENTIFICATION & DISCOVERY -**Step 1:** Identify and output the target component class name from previous conversation context -- Output: [CLASS_NAME] - -**Step 2:** Run CSS discovery in parallel using report-deprecated-css tool: -- **Global Styles Directory:** [USER_PROVIDED_INPUT] (if not provided, request from user) -- **Styles Overrides Directory:** [USER_PROVIDED_INPUT] (if not provided, request from user) -- **Fallback behavior:** If only one directory provided, run tool for that directory only -- **Error handling:** If neither directory provided, ask user for at least one input -- Component: [IDENTIFIED_CLASS_NAME] - -**Step 3:** Create implementation checklist -- Count total violations found across both directories -- Generate checklist item for each violation location -- **Validation Check:** Verify checklist item count = total violation count -- **Save checklist to:** `.cursor/tmp/css-cleanup/[class-name]-[scope]-non-viable-migration-checklist.md` -- **DO NOT output checklist content in chat** - only reference checklist file location -- Output format: - -[NUMBER] -[NUMBER] -Items match violations: [TRUE/FALSE] -`.cursor/tmp/css-cleanup/[class-name]-[scope]-non-viable-migration-checklist.md` - - -## PHASE 2: IMPLEMENTATION -**Work from checklist file** - reference saved checklist and update it as you progress through each item. - -Execute each checklist item systematically in this exact order: - -**Step 1: HTML Template Updates (FIRST PRIORITY)** -- Replace original component classes with "after-migration-[ORIGINAL_CLASS]" in HTML files/templates -- This must be done BEFORE any CSS changes -- Update all instances found in the violation reports -- **Update checklist:** Mark HTML items as complete - -**Step 2: CSS Selector Duplication (NOT REPLACEMENT)** -- DUPLICATE CSS selectors, do NOT replace them -- Transform: `.custom-radio {}` → `.custom-radio, .after-migration-custom-radio {}` -- Keep original selector intact alongside new prefixed selector -- This ensures both old and new classes work with identical styling -- Maintain visual parity between original and prefixed versions -- **Update checklist:** Mark CSS items as complete - -## PHASE 3: VALIDATION (Success Criteria) - MANDATORY EXECUTION -**CRITICAL:** Execute validation steps from checklist using actual tools, not just manual verification. - -**Validation 1 - CSS Count Consistency:** -- **TOOL REQUIRED:** Re-run report-deprecated-css tool on both original directories -- Compare counts with original baseline -- **Update checklist:** Mark validation item as complete -- Output: - [NUMBER] - [NUMBER] - PASS/FAIL - Deprecated class count remains identical to original - - -**Validation 2 - Violation Reduction:** -- **TOOL REQUIRED:** Run report-violations tool against modified component scope -- Compare with original violation count -- **Update checklist:** Mark validation item as complete -- Output: - [NUMBER] - [NUMBER] - [NUMBER] - [NUMBER] - PASS/FAIL - 0 violations OR exactly X fewer violations (where X = number of replacements made) - - -**Final Step:** Update saved checklist file with validation results and mark all items complete. - -## OUTPUT REQUIREMENTS -- Start with identified class name in tags -- Show violation counts and checklist summary in tags (NO detailed checklist content) -- Reference checklist file location only -- Provide step-by-step implementation status with checklist updates -- Report validation results in and tags with clear pass/fail status -- **Throughout process:** Update checklist file, don't repeat content in chat - -Execute each phase completely before proceeding to the next. Request confirmation if validation criteria are not met. - -## USAGE -To invoke this workflow, user can say: -- "Execute non-viable handling for [component]" -- "Run the non-migratable component workflow" -- "Handle non-viable component migration" -- Or simply reference this rule: @non-viable-handling -description: -globs: -alwaysApply: false ---- diff --git a/.cursor/flows/ds-refactoring-flow/04-validate-changes.mdc b/.cursor/flows/ds-refactoring-flow/04-validate-changes.mdc deleted file mode 100644 index 900b156..0000000 --- a/.cursor/flows/ds-refactoring-flow/04-validate-changes.mdc +++ /dev/null @@ -1,67 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are an AI assistant tasked with analyzing refactored code and component contracts. Your goal is to identify potential issues, breaking changes, and risky points that require attention from the development team. Follow these steps carefully: - -First, you will be provided with two inputs: - - -{{REFACTORED_FILES}} - - -This contains the list of files that have been refactored. - - -{{COMPONENT_CONTRACTS}} - - -This contains the list of available component contracts. - -Now, follow these steps: - -1. Fix eslint issues in {{REFACTORED_FILES}} using `lint-files`. DO NOT go to next step until lint errors are fixed. If there are unfixable errors ask user what to do. - -2. Use the `build_component_contract` tool to capture the refactored state of the components: - build_component_contract {{REFACTORED_FILES}} - -3. Use the `list_component_contracts` tool to get the list of available contracts: - list_component_contracts - -4. Use the `diff_component_contract` tool to get a diff of before and after contracts. You'll need to do this for each component contract. For example: - diff_component_contract old_contract new_contract - Replace "old_contract" and "new_contract" with the actual contract names from step 3. - -5. Analyze the diff to identify any potential breaking or questionable changes. Look for: - - Changes in function signatures - - Modifications to data structures - - Alterations in component interfaces - - Any other changes that might affect the behavior or usage of the components - -6. Reflect on your analysis. In your reflection, consider: - - The severity of each change - - Potential impacts on other parts of the system - - Backwards compatibility issues - - Performance implications - -7. Create a final validation report. This report should: - - Summarize the changes found - - Highlight any risky points that require elevated attention - - Provide recommendations for the developer, QA, or UAT team - -Your final output should be structured as follows: - - -[Your detailed analysis of the changes, including all potential issues and their implications] - - - -[List any questions or issues that require further clarification from the user] - - - -[Your final validation report, highlighting risky points and providing recommendations] - - -Remember, your goal is to provide a thorough and accurate analysis that will help the development team understand the implications of their refactoring. Be specific in your observations and clear in your recommendations. \ No newline at end of file diff --git a/.cursor/flows/ds-refactoring-flow/05-prepare-report.mdc b/.cursor/flows/ds-refactoring-flow/05-prepare-report.mdc deleted file mode 100644 index 765ba5e..0000000 --- a/.cursor/flows/ds-refactoring-flow/05-prepare-report.mdc +++ /dev/null @@ -1,64 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- -You are an AI assistant tasked with analyzing chat history, creating testing checklists, and generating documentation for code changes. Follow these instructions carefully: - -1. First, review the entire chat history provided: - -{{CHAT_HISTORY}} - - -2. Analyze the chat history, focusing on: - - Refactoring changes discussed - - Any analysis or insights provided about the code - - Specific areas of the code that were modified - - Any potential risks or concerns mentioned - -3. Reflect on this information, considering: - - The overall impact of the changes - - Potential edge cases or scenarios that might be affected - - Any areas that might require special attention during testing - -4. Create detailed testing checklists for three roles: Developer, Manual QA Engineer, and UAT Professional. For each role, provide a list of specific items to test or verify. Include the following in your checklists: - - Highlight any uncertainties that need clarification - - Specify verification points that need to be made - - Ensure coverage of both functional and non-functional aspects affected by the changes - -Format your checklists using markdown, with each role as a second-level heading (##) and checklist items as bullet points (-). - -5. Save the testing checklists in a verification document. Use the following path: - .cursor/tmp/verification-checklist-{{FOLDER}}.md - -6. Generate a semantic commit message for the changes discussed in the chat. The commit message should: - - Start with the [AI] mark - - Follow the conventional commit format (type: description) - - Briefly summarize the main changes or purpose of the commit - -7. Create a short PR (Pull Request) description based on the changes discussed in the chat. The description should: - - Summarize the main changes and their purpose - - Mention any significant refactoring or improvements made - - Highlight any areas that require special attention during review - -Provide your output in the following format: - - -Your analysis of the chat history and reflection on the information - - - -Your detailed testing checklists for Developer, Manual QA Engineer, and UAT Professional - - - -The path where the verification document is saved - - - -Your generated semantic commit message - - - -Your short PR description - \ No newline at end of file diff --git a/.cursor/flows/ds-refactoring-flow/clean-global-styles.mdc b/.cursor/flows/ds-refactoring-flow/clean-global-styles.mdc deleted file mode 100644 index 518fa86..0000000 --- a/.cursor/flows/ds-refactoring-flow/clean-global-styles.mdc +++ /dev/null @@ -1,43 +0,0 @@ ---- -description: -globs: -alwaysApply: false ---- - -You are an AI assistant tasked with analyzing a project for deprecated CSS classes and component violations. You will be provided with three inputs: - -{{SOURCE_PATH}} - - -{{GLOBAL_STYLES_PATH}} - - -{{COMPONENT_NAME}} - -Follow these steps to complete the task: - -1. Use the `report-deprecated-css` tool to find occurrences of deprecated CSS classes in the global styles: - report-deprecated-css {{GLOBAL_STYLES_PATH}} -2. Use the `report-deprecated-css` tool to find occurrences of deprecated CSS classes in the source folder: - report-deprecated-css {{SOURCE_PATH}} -3. Use the `report-violations` tool to find usages of deprecated component classes in the source folder: - report-violations {{SOURCE_PATH}} -4. Analyze the results from the tool calls: - a. If violations are found in the source folder, state the number of violations and recommend fixing them first. - b. If no violations are found, list the deprecated CSS (if any) found in the global styles and source path. -5. Format your final output using the following structure: - - [Include your analysis of the results here] - - - [If violations were found: Recommend fixing them. - If only deprecated CSS is found: State that deprecated CSS was found in the project.] - - - [Leave this section empty if violations were found. - If no violations were found but deprecated CSS exists, ask whether to: - -- Remove the deprecated CSS -- Save it in .cursor/tmp/{{COMPONENT_NAME}}-deprecated-css.md -- Do nothing] - diff --git a/.cursor/mcp.json.example b/.cursor/mcp.json.example deleted file mode 100644 index dba92db..0000000 --- a/.cursor/mcp.json.example +++ /dev/null @@ -1,22 +0,0 @@ -{ - "mcpServers": { - "nx-mcp": { - "url": "http://localhost:9665/sse" - }, - "angular-toolkit-mcp": { - "command": "node", - "args": [ - "/absolute/path/to/angular-mcp-server/packages/angular-mcp-server/dist/index.js", - "--workspaceRoot=/absolute/path/to/your/angular/workspace", - "--ds.storybookDocsRoot=relative/path/to/storybook/docs", - "--ds.deprecatedCssClassesPath=relative/path/to/component-options.js", - "--ds.uiRoot=relative/path/to/ui/components" - ] - }, - "ESLint": { - "type": "stdio", - "command": "npx", - "args": ["@eslint/mcp@latest"] - } - } -} diff --git a/.gitignore b/.gitignore index 653600c..653efcd 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,7 @@ vitest.config.*.timestamp* .cursor/rules/nx-rules.mdc .cursor/mcp.json .github/instructions/nx.instructions.md + +# Kiro +.kiro/settings/mcp.json +.kiro/tmp \ No newline at end of file diff --git a/README.md b/README.md index 8f00c11..6513e2d 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,8 @@ Add the server to your MCP client configuration (e.g., Claude Desktop, Cursor, C "--workspaceRoot=/absolute/path/to/your/angular/workspace", "--ds.uiRoot=relative/path/to/ui/components", "--ds.storybookDocsRoot=relative/path/to/storybook/docs", - "--ds.deprecatedCssClassesPath=relative/path/to/component-options.mjs" + "--ds.deprecatedCssClassesPath=relative/path/to/component-options.mjs", + "--ds.generatedStylesRoot=relative/path/to/generated/styles" ] } } @@ -107,14 +108,15 @@ When developing locally, point to the built server: "--workspaceRoot=/absolute/path/to/your/angular/workspace", "--ds.uiRoot=relative/path/to/ui/components", "--ds.storybookDocsRoot=relative/path/to/storybook/docs", - "--ds.deprecatedCssClassesPath=relative/path/to/component-options.mjs" + "--ds.deprecatedCssClassesPath=relative/path/to/component-options.mjs", + "--ds.generatedStylesRoot=relative/path/to/generated/styles" ] } } } ``` -> Note: `ds.storybookDocsRoot` and `ds.deprecatedCssClassesPath` are optional. The server will start without them. Tools that require these paths will return a clear error prompting you to provide the missing parameter. +> Note: `ds.storybookDocsRoot`, `ds.deprecatedCssClassesPath`, and `ds.generatedStylesRoot` are optional. The server will start without them. Tools that require these paths will return a clear error prompting you to provide the missing parameter. > **Note**: The example file contains configuration for `ESLint` official MCP which is required for the toolkit to work properly. @@ -133,11 +135,26 @@ When developing locally, point to the built server: |-----------|------|-------------|---------| | `ds.storybookDocsRoot` | Relative path | Root directory containing Storybook documentation used by documentation-related tools | `storybook/docs` | | `ds.deprecatedCssClassesPath` | Relative path | JavaScript file mapping deprecated CSS classes used by violation and deprecated CSS tools | `design-system/component-options.mjs` | +| `ds.generatedStylesRoot` | Relative path | Directory containing generated design token CSS files. Required for token-aware tools. | `dist/generated/styles` | When optional parameters are omitted: - `ds.storybookDocsRoot`: Tools will skip Storybook documentation lookups (e.g., `get-ds-component-data` will still return implementation/import data but may have no docs files). - `ds.deprecatedCssClassesPath`: Tools that require the mapping will fail fast with a clear error. Affected tools include: `get-deprecated-css-classes`, `report-deprecated-css`, `report-all-violations`, and `report-violations`. +- `ds.generatedStylesRoot`: Token features are disabled. Token-aware tools return a clear message explaining that `--ds.generatedStylesRoot` is required. All other tools work normally. + +#### Token Configuration Parameters + +These parameters control how design tokens are discovered, organised, and categorised. All are optional and have sensible defaults. They are only relevant when `ds.generatedStylesRoot` is configured. + +| Parameter | Type | Default | Description | +|-----------|------|---------|-------------| +| `ds.tokens.filePattern` | `string` | `**/semantic.css` | Glob pattern to discover token files inside `generatedStylesRoot`. Supports `**` (recursive) and `*` (single-segment) wildcards. Change if your token files have a different name (e.g. `**/variables.css`). | +| `ds.tokens.propertyPrefix` | `string \| null` | `null` | When set, only CSS custom properties whose name starts with this prefix are loaded. When `null`, all `--*` properties are included. Useful to filter out non-token properties from generated files. | +| `ds.tokens.scopeStrategy` | `flat \| brand-theme` | `flat` | How directory structure under `generatedStylesRoot` maps to token scope metadata. `flat`: all files are treated as a single set with no scope (scope: {}). Use when tokens are not organised by brand or theme. `brand-theme`: path segments map to scope keys — first segment → `brand`, second → `theme` (e.g. `acme/dark/semantic.css` → scope: `{ brand: 'acme', theme: 'dark' }`). Use when tokens are organised in a `{brand}/{theme}/` directory layout. | +| `ds.tokens.categoryInference` | `by-prefix \| by-value \| none` | `by-prefix` | How tokens are assigned to categories (color, spacing, etc.). `by-prefix`: matches token names against `categoryPrefixMap` (longest prefix wins). `by-value`: infers from resolved values (hex/rgb/hsl → color, px/rem/em → spacing, % → opacity). `none`: leaves all tokens uncategorised. | + +> **Note:** `ds.tokens.categoryPrefixMap` (a `Record` mapping category names to token name prefixes) defaults to `{ color: '--semantic-color', spacing: '--semantic-spacing', radius: '--semantic-radius', typography: '--semantic-typography', size: '--semantic-size', opacity: '--semantic-opacity' }`. It is not exposed as a CLI argument but can be set via config file. Only relevant when `categoryInference` is `by-prefix`. #### Deprecated CSS Classes File Format @@ -169,6 +186,15 @@ my-angular-workspace/ │ │ └── ... │ └── design-system/ │ └── component-options.mjs # ds.deprecatedCssClassesPath +├── dist/ +│ └── generated/ +│ └── styles/ # ds.generatedStylesRoot +│ ├── semantic.css # flat layout +│ └── acme/ # brand-theme layout +│ ├── dark/ +│ │ └── semantic.css +│ └── light/ +│ └── semantic.css ├── storybook/ │ └── docs/ # ds.storybookDocsRoot └── apps/ diff --git a/docs/architecture-internal-design.md b/docs/architecture-internal-design.md index 2547097..0864f9c 100644 --- a/docs/architecture-internal-design.md +++ b/docs/architecture-internal-design.md @@ -93,15 +93,43 @@ The MCP SDK auto-validates every call against the schema – no manual parsing r ## 6. Configuration Options +### Core Options + | Option | Type | Description | |--------|------|-------------| | `workspaceRoot` | absolute path | Root of the Nx/Angular workspace. | | `ds.storybookDocsRoot` | relative path | Path (from root) to Storybook MDX/Docs for DS components. | | `ds.deprecatedCssClassesPath` | relative path | JS file mapping components → deprecated CSS classes. | | `ds.uiRoot` | relative path | Folder containing raw design-system component source. | +| `ds.generatedStylesRoot` | relative path | Directory containing generated design token CSS files. Enables token-aware features when provided. | + +### Token Configuration (`ds.tokens.*`) + +These options control how design tokens are discovered, organised, and categorised. All have defaults and are only relevant when `ds.generatedStylesRoot` is configured. + +| 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.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`). | Validation is handled via **Zod** in `angular-mcp-server-options.schema.ts`. +### Token Dataset Storage Model + +The token dataset stores one flat array of all loaded `TokenEntry` objects. At construction time, four index maps are built from that array for efficient lookups: + +| Index | Type | Behaviour | +|-------|------|-----------| +| `byName` | `Map` | Last-write-wins — only one entry per token name. When the same token appears in multiple brand files, only the last processed entry is kept. | +| `byValue` | `Map` | All entries with that resolved value are kept. Enables reverse-lookup ("which tokens resolve to `#86b521`?"). | +| `byCategory` | `Map` | All entries in that category are kept. | +| `byScopeKey` | `Map>` | All entries matching a scope dimension are kept. Enables scoped queries like "all tokens where brand = acme". | + +For example, if `--semantic-color-primary` appears in 30 brand files with different values, the `tokens` array has 30 entries. `byValue` and `byScopeKey` keep all 30. `byName` only keeps the last one processed. + --- ## 7. Shared Libraries in Play @@ -110,11 +138,22 @@ Validation is handled via **Zod** in `angular-mcp-server-options.schema.ts`. models (types & schemas) ├─ utils ├─ styles-ast-utils -└─ angular-ast-utils - └─ ds-component-coverage (top-level plugin) +│ └─ scss-value-parser (extracts property-value pairs per selector from SCSS) +├─ angular-ast-utils +└─ ds-component-coverage (top-level plugin) +``` + +The `angular-mcp-server` package also contains shared token infrastructure: + +``` +tools/ds/shared/utils/ +├─ css-custom-property-parser.ts (regex-based CSS --* extraction) +├─ token-dataset.ts (queryable token data structure) +├─ token-dataset-loader.ts (file discovery, scope, categorisation) +└─ regex-helpers.ts (shared regex patterns) ``` -These libraries provide AST parsing, file operations, and DS analysis. Tools import them directly; they are **framework-agnostic** and can be unit-tested in isolation. +These libraries provide AST parsing, file operations, token loading, and DS analysis. Tools import them directly; they are **framework-agnostic** and can be unit-tested in isolation. --- diff --git a/docs/getting-started.md b/docs/getting-started.md index 8881476..a6fe108 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -45,7 +45,8 @@ Instead of the palette-based flow, copy the manual configuration from your works "--workspaceRoot=/absolute/path/to/angular-toolkit-mcp", "--ds.storybookDocsRoot=packages/minimal-repo/packages/design-system/storybook-host-app/src/components", "--ds.deprecatedCssClassesPath=packages/minimal-repo/packages/design-system/component-options.mjs", - "--ds.uiRoot=packages/minimal-repo/packages/design-system/ui" + "--ds.uiRoot=packages/minimal-repo/packages/design-system/ui", + "--ds.generatedStylesRoot=dist/generated/styles" ] } } @@ -54,6 +55,8 @@ Instead of the palette-based flow, copy the manual configuration from your works Add or edit this JSON in **Cursor → Settings → MCP Servers** (or the equivalent dialog in your editor). +> **Note:** `ds.generatedStylesRoot` is optional. When provided, it enables token-aware features (token discovery, categorisation, and querying). When omitted, all existing tools work normally and token features are simply disabled. + --- ## 4. Next Steps diff --git a/package-lock.json b/package-lock.json index f9590ac..ab364ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -129,7 +129,6 @@ "integrity": "sha512-mqudAcyrSp/E7ZQdQoHfys0/nvQuwyJDaAzj3qL3HUStuUzb5ULNOj2f6sFBo+xYo+/WT8IzmzDN9DCqDgvFaA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.1902.15", @@ -256,7 +255,6 @@ "integrity": "sha512-iE4fp4d5ALu702uoL6/YkjM2JlGEXZ5G+RVzq3W2jg/Ft6ISAQnRKB6mymtetDD6oD7i87e8uSu9kFVNBauX2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "2.3.0", "@angular-devkit/architect": "0.1902.15", @@ -375,7 +373,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -386,7 +383,6 @@ "integrity": "sha512-rRHT8siFIXQrAYOYqZQVsAr8vJ+cBNqcVAY6m5V8/4QqzaPl+zDBe6cLEPRDuNOUf3ww8RfJVlOyQMoSI+5Ang==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.26.10", "@babel/types": "^7.26.10", @@ -404,7 +400,6 @@ "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.25.9" }, @@ -418,7 +413,6 @@ "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.25.9" }, @@ -435,7 +429,6 @@ "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.26.5", "@babel/helper-remap-async-to-generator": "^7.25.9", @@ -454,7 +447,6 @@ "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.25.9", @@ -473,7 +465,6 @@ "integrity": "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.25.9", "@babel/helper-plugin-utils": "^7.26.5", @@ -495,7 +486,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -506,7 +496,6 @@ "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/compat-data": "^7.26.8", "@babel/helper-compilation-targets": "^7.26.5", @@ -591,7 +580,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -602,7 +590,6 @@ "integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -623,7 +610,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -641,7 +627,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -659,7 +644,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -677,7 +661,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -695,7 +678,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -713,7 +695,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -731,7 +712,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -749,7 +729,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -767,7 +746,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -785,7 +763,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -803,7 +780,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -821,7 +797,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -839,7 +814,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -857,7 +831,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -875,7 +848,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -893,7 +865,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -911,7 +882,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -929,7 +899,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -947,7 +916,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -965,7 +933,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -983,7 +950,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -1001,7 +967,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -1019,7 +984,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1037,7 +1001,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1055,7 +1018,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -1066,7 +1028,6 @@ "integrity": "sha512-6ZXYK3M1XmaVBZX6FCfChgtponnL0R6I7k8Nu+kaoNkT828FVZTcca1MqmWQipaW2oNREQl5AaPCUOOCVNdRMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@inquirer/core": "^10.1.7", "@inquirer/type": "^3.0.4" @@ -1095,8 +1056,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-android-arm64": { "version": "4.34.8", @@ -1110,8 +1070,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-darwin-arm64": { "version": "4.34.8", @@ -1125,8 +1084,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-darwin-x64": { "version": "4.34.8", @@ -1140,8 +1098,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.34.8", @@ -1155,8 +1112,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-freebsd-x64": { "version": "4.34.8", @@ -1170,8 +1126,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.34.8", @@ -1185,8 +1140,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.34.8", @@ -1200,8 +1154,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.34.8", @@ -1215,8 +1168,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.34.8", @@ -1230,8 +1182,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-loongarch64-gnu": { "version": "4.34.8", @@ -1245,8 +1196,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-powerpc64le-gnu": { "version": "4.34.8", @@ -1260,8 +1210,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.34.8", @@ -1275,8 +1224,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.34.8", @@ -1290,8 +1238,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.34.8", @@ -1305,8 +1252,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.34.8", @@ -1320,8 +1266,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.34.8", @@ -1335,8 +1280,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.34.8", @@ -1350,8 +1294,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.34.8", @@ -1365,16 +1308,14 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@angular-devkit/build-angular/node_modules/@types/estree": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@angular-devkit/build-angular/node_modules/agent-base": { "version": "7.1.4", @@ -1382,7 +1323,6 @@ "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 14" } @@ -1407,7 +1347,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "browserslist": "^4.23.3", "caniuse-lite": "^1.0.30001646", @@ -1431,8 +1370,7 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@angular-devkit/build-angular/node_modules/copy-webpack-plugin": { "version": "12.0.2", @@ -1440,7 +1378,6 @@ "integrity": "sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-glob": "^3.3.2", "glob-parent": "^6.0.1", @@ -1466,7 +1403,6 @@ "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", @@ -1504,7 +1440,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1545,7 +1480,6 @@ "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -1560,7 +1494,6 @@ "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=4.0" } @@ -1571,7 +1504,6 @@ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", "dev": true, "license": "ISC", - "peer": true, "dependencies": { "is-glob": "^4.0.3" }, @@ -1585,7 +1517,6 @@ "integrity": "sha512-0Ia46fDOaT7k4og1PDW4YbodWWr3scS2vAr2lTbsplOt2WkKp0vQbkI9wKis/T5LV/dqPjO3bpS/z6GTJB82LA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@sindresorhus/merge-streams": "^2.1.0", "fast-glob": "^3.3.3", @@ -1607,7 +1538,6 @@ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "agent-base": "^7.1.2", "debug": "4" @@ -1622,7 +1552,6 @@ "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 4" } @@ -1632,8 +1561,7 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@angular-devkit/build-angular/node_modules/less": { "version": "4.2.2", @@ -1669,7 +1597,6 @@ "integrity": "sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 18.12.0" }, @@ -1697,7 +1624,6 @@ "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 12.13.0" } @@ -1709,7 +1635,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "pify": "^4.0.1", "semver": "^5.6.0" @@ -1725,7 +1650,6 @@ "dev": true, "license": "ISC", "optional": true, - "peer": true, "bin": { "semver": "bin/semver" } @@ -1736,7 +1660,6 @@ "integrity": "sha512-GJuACcS//jtq4kCtd5ii/M0SZf7OZRH+BxdqXZHaJfb8TJiVl+NgQRPwiYt2EuqeSkNydn/7vP+bcE27C5mb9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "schema-utils": "^4.0.0", "tapable": "^2.2.1" @@ -1758,7 +1681,6 @@ "integrity": "sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", @@ -1778,7 +1700,6 @@ "integrity": "sha512-Vj7sf++t5pBD637NSfkxpHSMfWaeig5+DKWLhcqIYx6mWQz5hdJTGDVMQiJcw1ZYkhs7AazKDGpRVji1LJCZUQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1793,7 +1714,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "engines": { "node": ">=6" } @@ -1804,7 +1724,6 @@ "integrity": "sha512-EZJb+ZxDrQf3dihsUL7p42pjNyrNIFJCrRHPMgxu/svsj+P3xS3fuEWp7k2+rfsavfl1N0G29b1HGs7J0m8rZA==", "dev": true, "license": "MIT", - "peer": true, "optionalDependencies": { "@napi-rs/nice": "^1.0.1" } @@ -1845,7 +1764,6 @@ "integrity": "sha512-489gTVMzAYdiZHFVA/ig/iYFllCcWFHMvUHI1rpFmkoUtRlQxqh6/yiNqnYibjMZ2b/+FUQwldG+aLsEt6bglQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.6" }, @@ -1907,7 +1825,6 @@ "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.16" }, @@ -1921,7 +1838,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -1932,7 +1848,6 @@ "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -1964,7 +1879,6 @@ "integrity": "sha512-qg3LkeuinTrZoJHHF94coSaTfIPyBYoywp+ys4qu20oSJFbKMYoIJo0FWJT9q6Vp49l6z9IsJRbHdcGtiKbGoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "postcss": "^8.5.3", @@ -2051,7 +1965,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2067,7 +1980,6 @@ "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -2130,7 +2042,6 @@ "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "clone-deep": "^4.0.1", "flat": "^5.0.2", @@ -2146,7 +2057,6 @@ "integrity": "sha512-pIfZeizWsViXx8bsMoBLZw7Tl7uFf7bM7hAfmNwk0bb0QGzx5k1BiW6IKWyaG+Dg6U4UCrlNpIiut2b78HwQZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/architect": "0.1902.15", "rxjs": "7.8.1" @@ -2166,6 +2076,7 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.15.tgz", "integrity": "sha512-pU2RZYX6vhd7uLSdLwPnuBcr0mXJSjp3EgOXKsrlQFQZevc+Qs+2JdXgIElnOT/aDqtRtriDmLlSbtdE8n3ZbA==", "license": "MIT", + "peer": true, "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", @@ -2193,6 +2104,7 @@ "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.15.tgz", "integrity": "sha512-kNOJ+3vekJJCQKWihNmxBkarJzNW09kP5a9E1SRNiQVNOUEeSwcRR0qYotM65nx821gNzjjhJXnAZ8OazWldrg==", "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/core": "19.2.15", "jsonc-parser": "3.3.1", @@ -2244,6 +2156,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-19.2.14.tgz", "integrity": "sha512-ZqJDYOdhgKpVGNq3+n/Gbxma8DVYElDsoRe0tvNtjkWBVdaOxdZZUqmJ3kdCBsqD/aqTRvRBu0KGo9s2fCChkA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -2287,7 +2200,6 @@ "integrity": "sha512-lWBYIrF7qK5+GjY5Uy+/hEgp8OJWOD/rpy74GplYRhEauvbHDeFB8t5hPOZxCZ0Oxf4Cc36tK51/l3ymJysrKw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -2318,8 +2230,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@angular/compiler-cli/node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", @@ -2327,7 +2238,6 @@ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "dev": true, "license": "ISC", - "peer": true, "bin": { "semver": "bin/semver.js" } @@ -2337,8 +2247,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@babel/code-frame": { "version": "7.27.1", @@ -2371,6 +2280,7 @@ "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2665,7 +2575,6 @@ "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.24.7" }, @@ -5454,6 +5363,7 @@ "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^4.1.2", "@inquirer/confirm": "^5.1.6", @@ -6234,8 +6144,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@lmdb/lmdb-darwin-x64": { "version": "3.2.6", @@ -6249,8 +6158,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@lmdb/lmdb-linux-arm": { "version": "3.2.6", @@ -6264,8 +6172,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@lmdb/lmdb-linux-arm64": { "version": "3.2.6", @@ -6279,8 +6186,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@lmdb/lmdb-linux-x64": { "version": "3.2.6", @@ -6294,8 +6200,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@lmdb/lmdb-win32-x64": { "version": "3.2.6", @@ -6309,8 +6214,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@microsoft/api-extractor": { "version": "7.52.8", @@ -7924,6 +7828,7 @@ "integrity": "sha512-c9siKVjcgT2gtDdOTqEr+GaP2X/PWAS0OV424ljKLstFL1lcS/BIsxWFDmxPPl5hDByAH+1q4YhC1LWY4LNDQw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@module-federation/bridge-react-webpack-plugin": "0.9.1", "@module-federation/data-prefetch": "0.9.1", @@ -8262,6 +8167,7 @@ "integrity": "sha512-kzFn3ObUeBp5vaEtN1WMxhTYBuYEErxugu1RzFUERD21X3BZ+b4cWwdFJuBDlsmVjctIg/QSOoZoPXRKAO0foA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.15.0", "@module-federation/webpack-bundler-runtime": "0.15.0" @@ -8517,6 +8423,7 @@ "integrity": "sha512-JQZ//ab+lEXoU2DHAH+JtYASGzxEjXB0s4rU+6VJXc8c+oUPxH3kWIwzjdncg2mcWBmC1140DCk+K+kDfOZ5CQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime": "0.9.1", "@module-federation/webpack-bundler-runtime": "0.9.1" @@ -8582,8 +8489,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { "version": "3.0.3", @@ -8597,8 +8503,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { "version": "3.0.3", @@ -8612,8 +8517,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { "version": "3.0.3", @@ -8627,8 +8531,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { "version": "3.0.3", @@ -8642,8 +8545,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { "version": "3.0.3", @@ -8657,8 +8559,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@napi-rs/nice": { "version": "1.0.1", @@ -8984,7 +8885,6 @@ "integrity": "sha512-H37nop/wWMkSgoU2VvrMzanHePdLRRrX52nC5tT2ZhH3qP25+PrnMyw11PoLDLv3iWXC68uB1AiKNIT+jiQbuQ==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^18.19.1 || ^20.11.1 || >=22.0.0", "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", @@ -9353,6 +9253,7 @@ "integrity": "sha512-RzDbBhOE47XU3YHYJKHION8CfQ7MoWM4vjQUUKIrzTjt/QzhnhkyypP7z6aukynpGRyIXARMLMJekFiY1kBJjA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "ejs": "^3.1.7", "enquirer": "~2.3.6", @@ -10033,6 +9934,7 @@ "integrity": "sha512-UFynvx+gM44Gv9qFgj0acCQK2VE1CtdfwFdimkapco3hlPCJ/zeq73n2yVKimVbtm+TnApIugGhLJnkU6gjYXA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -12050,6 +11952,7 @@ "integrity": "sha512-eIzbMYdrpJLjfkelKFLpxUObuv2gAmAuebUJmXeyf2OlFT/DGgoWRDGOVX4MpIHgcE1XCi27sqvOdRU4HA7Zgw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@module-federation/runtime-tools": "0.15.0", "@rspack/binding": "1.4.0", @@ -12427,6 +12330,7 @@ "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-19.2.15.tgz", "integrity": "sha512-dz/eoFQKG09POSygpEDdlCehFIMo35HUM2rVV8lx9PfQEibpbGwl1NNQYEbqwVjTyCyD/ILyIXCWPE+EfTnG4g==", "license": "MIT", + "peer": true, "dependencies": { "@angular-devkit/core": "19.2.15", "@angular-devkit/schematics": "19.2.15", @@ -12545,7 +12449,6 @@ "integrity": "sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -12742,6 +12645,7 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -12868,6 +12772,7 @@ "integrity": "sha512-jYWaI2WNEKz8KZL3sExd2KVL1JMma1/J7z+9iTpv0+fRN7LGMF8VTGGuHI2bug/ztpdZU1G44FG/Kk6ElXL9CQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@swc-node/core": "^1.13.3", "@swc-node/sourcemap-support": "^0.5.1", @@ -12970,6 +12875,7 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.21" @@ -13213,6 +13119,7 @@ "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@swc/counter": "^0.1.3" } @@ -13502,6 +13409,7 @@ "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", @@ -13612,7 +13520,8 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.20.tgz", "integrity": "sha512-nL54VfDjThdP2UXJXZao5wp76CDiDw4zSRO8d4Tk7UgDqNKGKVEQB0/t3ti63NS+YNNkIQDvwEAF04BO+WYu7Q==", "devOptional": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/node-forge": { "version": "1.3.11", @@ -13782,6 +13691,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -14002,7 +13912,6 @@ "integrity": "sha512-mkQnxTkcldAzIsomk1UuLfAu9n+kpQ3JbHcpCp7d2Oo6ITtji8pHS3QToOWjhPFvNQSnhlkAjmGbhv2QvwO/7Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=14.21.3" }, @@ -14185,6 +14094,7 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -14711,6 +14621,7 @@ "integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "argparse": "^2.0.1" }, @@ -14753,6 +14664,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -14798,7 +14710,6 @@ "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loader-utils": "^2.0.0", "regex-parser": "^2.2.11" @@ -14835,6 +14746,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -15564,7 +15476,6 @@ "integrity": "sha512-p4AF8uYzm9Fwu8m/hSVTCPXrRBPmB34hQpHsec2KOaR9CZmgoU8IOv4Cvwq4hgz2p4hLMNbsdNl5XeA6XbAQwA==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "css-select": "^5.1.0", "css-what": "^6.1.0", @@ -15773,6 +15684,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -18200,6 +18112,7 @@ "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -18240,7 +18153,6 @@ "integrity": "sha512-2HlCS6rNvKWaSKhWaG/YIyRsTsL3gUrMP2ToZMBIjw9LM7vVcIs+rz8kE2vExvTJgvM8OKPqNpcHawY/BQc/qQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -18280,6 +18192,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -18341,6 +18254,7 @@ "integrity": "sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -18847,6 +18761,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -19485,6 +19400,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -20324,7 +20240,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", @@ -20338,7 +20253,6 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -21357,6 +21271,7 @@ "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -21993,6 +21908,7 @@ "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -22239,7 +22155,6 @@ "integrity": "sha512-RsBECncGO17KAoJCYXjv+ckIz+Ii9NCi+9enk+rq6XC81ezYkb4/RHE6CTXdA7IOJqoF3wcaLfVG0CPmE5ca6A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "source-map-support": "^0.5.5" } @@ -22447,6 +22362,7 @@ "integrity": "sha512-w16Xk/Ta9Hhyei0Gpz9m7VS8F28nieJaL/VyShID7cYvP6IL5oHeL6p4TXSDJqZE/lNv0oJ2pGVjJsRkfwm5FA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "copy-anything": "^2.0.1", "parse-node-version": "^1.0.1", @@ -22684,7 +22600,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "msgpackr": "^1.11.2", "node-addon-api": "^6.1.0", @@ -22710,8 +22625,7 @@ "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/load-tsconfig": { "version": "0.2.5", @@ -23583,7 +23497,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "optionalDependencies": { "msgpackr-extract": "^3.0.2" } @@ -23596,7 +23509,6 @@ "hasInstallScript": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, @@ -23981,7 +23893,6 @@ "dev": true, "license": "MIT", "optional": true, - "peer": true, "dependencies": { "detect-libc": "^2.0.1" }, @@ -23998,7 +23909,6 @@ "dev": true, "license": "Apache-2.0", "optional": true, - "peer": true, "engines": { "node": ">=8" } @@ -24304,6 +24214,7 @@ "devOptional": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@napi-rs/wasm-runtime": "0.2.4", "@yarnpkg/lockfile": "^1.1.0", @@ -24782,8 +24693,7 @@ "integrity": "sha512-IQh2aMfMIDbPjI/8a3Edr+PiOpcsB7yo8NdW7aHWVaoR/pcDldunMvnnwbk/auPGqmKeAdxtZl7MHX/QmPwhvQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/os-locale": { "version": "1.4.0", @@ -25040,7 +24950,6 @@ "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "entities": "^4.3.0", "parse5": "^7.0.0", @@ -25056,7 +24965,6 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -25070,7 +24978,6 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -25084,7 +24991,6 @@ "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "parse5": "^7.0.0" }, @@ -25098,7 +25004,6 @@ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "engines": { "node": ">=0.12" }, @@ -25112,7 +25017,6 @@ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "entities": "^6.0.0" }, @@ -25467,6 +25371,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -25686,8 +25591,7 @@ "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/postcss-merge-longhand": { "version": "6.0.5", @@ -26474,6 +26378,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -26487,6 +26392,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -26508,6 +26414,7 @@ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -26651,8 +26558,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regenerate": { "version": "1.4.2", @@ -26679,16 +26585,14 @@ "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/regex-parser": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/regexp-tree": { "version": "0.1.27", @@ -26846,7 +26750,6 @@ "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "adjust-sourcemap-loader": "^4.0.0", "convert-source-map": "^1.7.0", @@ -26863,8 +26766,7 @@ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/resolve-url-loader/node_modules/source-map": { "version": "0.6.1", @@ -26872,7 +26774,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -26966,6 +26867,7 @@ "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -27079,6 +26981,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -27154,6 +27057,7 @@ "integrity": "sha512-Ack2K8rc57kCFcYlf3HXpZEJFNUX8xd8DILldksREmYXQkRHI879yy8q4mRDJgrojkySMZqmmmW1NxrFxMsYaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@bufbuild/protobuf": "^2.5.0", "buffer-builder": "^0.2.0", @@ -28869,6 +28773,7 @@ "integrity": "sha512-ZIdT8eUv8tegmqy1tTIdJv9We2DumkNZFdCF5mz/Kpq3OcTaxSuCAYZge6HKK2CmNC02G1eJig2RV7XTw5hQrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@adobe/css-tools": "~4.3.3", "debug": "^4.3.2", @@ -29237,6 +29142,7 @@ "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.14.0", @@ -29952,7 +29858,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsscmp": { "version": "1.0.6", @@ -30005,6 +29912,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "license": "(MIT OR CC0-1.0)", + "peer": true, "engines": { "node": ">=10" }, @@ -30037,6 +29945,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -30148,7 +30057,6 @@ "integrity": "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -30412,6 +30320,7 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -30566,6 +30475,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -30719,8 +30629,7 @@ "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "optional": true }, "node_modules/webidl-conversions": { "version": "7.0.0", @@ -30738,6 +30647,7 @@ "integrity": "sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.6", @@ -30786,6 +30696,7 @@ "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -30884,6 +30795,7 @@ "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/bonjour": "^3.5.13", "@types/connect-history-api-fallback": "^1.5.4", @@ -31416,6 +31328,7 @@ "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -31471,6 +31384,7 @@ "integrity": "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ==", "devOptional": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -31618,6 +31532,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz", "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/packages/angular-mcp-server/src/lib/angular-mcp-server.ts b/packages/angular-mcp-server/src/lib/angular-mcp-server.ts index c75c19f..387855d 100644 --- a/packages/angular-mcp-server/src/lib/angular-mcp-server.ts +++ b/packages/angular-mcp-server/src/lib/angular-mcp-server.ts @@ -19,9 +19,15 @@ import { fileURLToPath } from 'node:url'; import { AngularMcpServerOptionsSchema, AngularMcpServerOptions, + TokensConfig, } from './validation/angular-mcp-server-options.schema.js'; import { validateAngularMcpServerFilesExist } from './validation/file-existence.js'; import { validateDeprecatedCssClassesFile } from './validation/ds-components-file.validation.js'; +import { + loadTokenDataset, + createEmptyTokenDataset, +} from './tools/ds/shared/utils/token-dataset-loader.js'; +import type { TokenDataset } from './tools/ds/shared/utils/token-dataset.js'; export class AngularMcpServerWrapper { private readonly mcpServer: McpServer; @@ -29,6 +35,9 @@ export class AngularMcpServerWrapper { private readonly storybookDocsRoot?: string; private readonly deprecatedCssClassesPath?: string; private readonly uiRoot: string; + private readonly generatedStylesRoot?: string; + private readonly tokensConfig: TokensConfig; + private tokenDataset?: TokenDataset; /** * Private constructor - use AngularMcpServerWrapper.create() instead. @@ -42,6 +51,8 @@ export class AngularMcpServerWrapper { this.storybookDocsRoot = ds.storybookDocsRoot; this.deprecatedCssClassesPath = ds.deprecatedCssClassesPath; this.uiRoot = ds.uiRoot; + this.generatedStylesRoot = ds.generatedStylesRoot; + this.tokensConfig = ds.tokens; this.mcpServer = new McpServer({ name: 'Angular MCP', @@ -73,20 +84,37 @@ export class AngularMcpServerWrapper { const validatedConfig = AngularMcpServerOptionsSchema.parse(config); // Validate file existence (optional keys are checked only when provided) - validateAngularMcpServerFilesExist(validatedConfig); + // Uses returned config which may have generatedStylesRoot cleared if path is invalid + const finalConfig = validateAngularMcpServerFilesExist(validatedConfig); // Load and validate deprecatedCssClassesPath content only if provided - if (validatedConfig.ds.deprecatedCssClassesPath) { - await validateDeprecatedCssClassesFile(validatedConfig); + if (finalConfig.ds.deprecatedCssClassesPath) { + await validateDeprecatedCssClassesFile(finalConfig); } - return new AngularMcpServerWrapper(validatedConfig); + return new AngularMcpServerWrapper(finalConfig); } getMcpServer(): McpServer { return this.mcpServer; } + async getTokenDataset(): Promise { + if (!this.generatedStylesRoot) { + return createEmptyTokenDataset( + '--ds.generatedStylesRoot is required for token functionality', + ); + } + if (!this.tokenDataset) { + this.tokenDataset = await loadTokenDataset({ + generatedStylesRoot: this.generatedStylesRoot, + workspaceRoot: this.workspaceRoot, + tokens: this.tokensConfig, + }); + } + return this.tokenDataset; + } + private registerResources() { this.mcpServer.server.setRequestHandler( ListResourcesRequestSchema, @@ -258,6 +286,8 @@ export class AngularMcpServerWrapper { uiRoot: this.uiRoot, cwd: this.workspaceRoot, workspaceRoot: this.workspaceRoot, + generatedStylesRoot: this.generatedStylesRoot, + tokensConfig: this.tokensConfig, }, }, }); diff --git a/packages/angular-mcp-server/src/lib/spec/server-token-integration.spec.ts b/packages/angular-mcp-server/src/lib/spec/server-token-integration.spec.ts new file mode 100644 index 0000000..354daec --- /dev/null +++ b/packages/angular-mcp-server/src/lib/spec/server-token-integration.spec.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { AngularMcpServerWrapper } from '../angular-mcp-server.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let tmpDir: string; + +function createTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-server-test-')); +} + +function setupWorkspace(options?: { + generatedStylesRoot?: string; + cssContent?: string; +}) { + tmpDir = createTmpDir(); + + // Create the required uiRoot directory + const uiRoot = path.join(tmpDir, 'packages', 'ui'); + fs.mkdirSync(uiRoot, { recursive: true }); + + // Optionally create generatedStylesRoot with a CSS file + if (options?.generatedStylesRoot) { + const stylesDir = path.join(tmpDir, options.generatedStylesRoot); + fs.mkdirSync(stylesDir, { recursive: true }); + + if (options.cssContent) { + fs.writeFileSync( + path.join(stylesDir, 'semantic.css'), + options.cssContent, + 'utf-8', + ); + } + } + + return { + workspaceRoot: tmpDir, + uiRoot: 'packages/ui', + generatedStylesRoot: options?.generatedStylesRoot, + }; +} + +function cleanupTmpDir() { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// --------------------------------------------------------------------------- +// Integration Tests — Server Bootstrap with Token Config +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 1.4–1.8, 10.1–10.3, 11.2–11.3** + */ +describe('Server bootstrap with token config (integration)', () => { + afterEach(() => { + cleanupTmpDir(); + vi.restoreAllMocks(); + }); + + // ---- Req 1.7: Server starts without errors when generatedStylesRoot is not provided ---- + it('starts without errors when generatedStylesRoot is not provided', async () => { + const { workspaceRoot, uiRoot } = setupWorkspace(); + + const server = await AngularMcpServerWrapper.create({ + workspaceRoot, + ds: { uiRoot }, + } as Parameters[0]); + + expect(server).toBeDefined(); + expect(server.getMcpServer()).toBeDefined(); + }); + + // ---- Req 1.4: Server starts without errors when generatedStylesRoot is provided and valid ---- + it('starts without errors when generatedStylesRoot is provided and valid', async () => { + const cssContent = ':root { --semantic-color-primary: #86b521; }'; + const { workspaceRoot, uiRoot, generatedStylesRoot } = setupWorkspace({ + generatedStylesRoot: 'dist/styles', + cssContent, + }); + + const server = await AngularMcpServerWrapper.create({ + workspaceRoot, + ds: { uiRoot, generatedStylesRoot }, + } as Parameters[0]); + + expect(server).toBeDefined(); + expect(server.getMcpServer()).toBeDefined(); + }); + + // ---- Req 1.5, 1.6: Server starts with warning when generatedStylesRoot points to non-existent path ---- + it('starts with warning when generatedStylesRoot points to non-existent path', async () => { + const { workspaceRoot, uiRoot } = setupWorkspace(); + // eslint-disable-next-line @typescript-eslint/no-empty-function + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const server = await AngularMcpServerWrapper.create({ + workspaceRoot, + ds: { uiRoot, generatedStylesRoot: 'non-existent/path' }, + } as Parameters[0]); + + expect(server).toBeDefined(); + expect(server.getMcpServer()).toBeDefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('does not exist or is not a directory'), + ); + }); + + // ---- Req 11.2, 11.3: Existing tool invocations are unaffected by new config fields ---- + it('existing tool invocations are unaffected by new config fields', async () => { + const { workspaceRoot, uiRoot } = setupWorkspace(); + + // Create server with new token config fields (partial — Zod fills defaults) + const server = await AngularMcpServerWrapper.create({ + workspaceRoot, + ds: { + uiRoot, + tokens: { + filePattern: '**/custom.css', + scopeStrategy: 'brand-theme', + categoryInference: 'by-value', + }, + }, + } as Parameters[0]); + + expect(server).toBeDefined(); + + // The MCP server should still have tools registered + const mcpServer = server.getMcpServer(); + expect(mcpServer).toBeDefined(); + }); + + // ---- Req 10.1: getTokenDataset() returns empty dataset with actionable message when generatedStylesRoot is absent ---- + it('getTokenDataset() returns empty dataset with actionable message when generatedStylesRoot is absent', async () => { + const { workspaceRoot, uiRoot } = setupWorkspace(); + + const server = await AngularMcpServerWrapper.create({ + workspaceRoot, + ds: { uiRoot }, + } as Parameters[0]); + + const dataset = await server.getTokenDataset(); + + expect(dataset.isEmpty).toBe(true); + expect(dataset.diagnostics.length).toBeGreaterThan(0); + expect(dataset.diagnostics[0]).toContain('generatedStylesRoot'); + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/component/get-ds-component-data.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/component/get-ds-component-data.tool.ts index 4ae1c44..21063b6 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/component/get-ds-component-data.tool.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/component/get-ds-component-data.tool.ts @@ -11,6 +11,7 @@ import { componentNameToKebabCase, } from '../shared/utils/component-validation.js'; import { resolveCrossPlatformPath } from '../shared/utils/cross-platform-path.js'; +import { walkDirectorySync } from '@push-based/utils'; import * as fs from 'fs'; import * as path from 'path'; @@ -57,35 +58,6 @@ export const getDsComponentDataToolSchema: ToolSchemaOptions = { }, }; -function getAllFilesInDirectory(dirPath: string): string[] { - const files: string[] = []; - - function walkDirectory(currentPath: string) { - try { - const items = fs.readdirSync(currentPath); - - for (const item of items) { - const fullPath = path.join(currentPath, item); - const stat = fs.statSync(fullPath); - - if (stat.isDirectory()) { - walkDirectory(fullPath); - } else { - files.push(fullPath); - } - } - } catch { - return; - } - } - - if (fs.existsSync(dirPath)) { - walkDirectory(dirPath); - } - - return files; -} - function findStoriesFiles(componentPath: string): string[] { const storiesFiles: string[] = []; @@ -132,7 +104,7 @@ export const getDsComponentDataHandler = createHandler< let implementationFiles: string[] = []; if (includeImplementation) { - const srcFiles = getAllFilesInDirectory(pathsInfo.srcPath); + const srcFiles = walkDirectorySync(pathsInfo.srcPath); implementationFiles = srcFiles.map((file) => `file://${file}`); } diff --git a/packages/angular-mcp-server/src/lib/tools/ds/component/list-ds-components.tool.ts b/packages/angular-mcp-server/src/lib/tools/ds/component/list-ds-components.tool.ts index 0169511..cf210ca 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/component/list-ds-components.tool.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/component/list-ds-components.tool.ts @@ -7,6 +7,7 @@ import { COMMON_ANNOTATIONS } from '../shared/models/schema-helpers.js'; import { getComponentPathsInfo } from './utils/paths-helpers.js'; import { getComponentDocPathsForName } from './utils/doc-helpers.js'; import { resolveCrossPlatformPath } from '../shared/utils/cross-platform-path.js'; +import { walkDirectorySync } from '@push-based/utils'; import * as fs from 'fs'; import * as path from 'path'; @@ -48,35 +49,6 @@ export const listDsComponentsToolSchema: ToolSchemaOptions = { }, }; -function getAllFilesInDirectory(dirPath: string): string[] { - const files: string[] = []; - - function walkDirectory(currentPath: string) { - try { - const items = fs.readdirSync(currentPath); - - for (const item of items) { - const fullPath = path.join(currentPath, item); - const stat = fs.statSync(fullPath); - - if (stat.isDirectory()) { - walkDirectory(fullPath); - } else { - files.push(fullPath); - } - } - } catch { - return; - } - } - - if (fs.existsSync(dirPath)) { - walkDirectory(dirPath); - } - - return files; -} - function kebabCaseToPascalCase(kebabCase: string): string { return ( 'Ds' + @@ -166,7 +138,7 @@ export const listDsComponentsHandler = createHandler< let implementationFiles: string[] = []; if (includeImplementation) { - const srcFiles = getAllFilesInDirectory(pathsInfo.srcPath); + const srcFiles = walkDirectorySync(pathsInfo.srcPath); implementationFiles = srcFiles.map((file) => `file://${file}`); } diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/handler-helpers.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/handler-helpers.ts index 930a617..94c70fc 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/handler-helpers.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/handler-helpers.ts @@ -2,6 +2,7 @@ import { CallToolRequest, CallToolResult, } from '@modelcontextprotocol/sdk/types.js'; +import type { TokensConfig } from '../../../../validation/angular-mcp-server-options.schema.js'; import { validateComponentName } from './component-validation.js'; import { buildTextResponse, throwError } from './output.utils.js'; import * as process from 'node:process'; @@ -18,6 +19,8 @@ export interface BaseHandlerOptions { storybookDocsRoot?: string; deprecatedCssClassesPath?: string; uiRoot?: string; + generatedStylesRoot?: string; + tokensConfig?: TokensConfig; } /** @@ -29,6 +32,8 @@ export interface HandlerContext { storybookDocsRoot?: string; deprecatedCssClassesPath?: string; uiRoot: string; + generatedStylesRoot?: string; + tokensConfig?: TokensConfig; } /** @@ -63,6 +68,8 @@ export function setupHandlerEnvironment( storybookDocsRoot: params.storybookDocsRoot, deprecatedCssClassesPath: params.deprecatedCssClassesPath, uiRoot: params.uiRoot || '', + generatedStylesRoot: params.generatedStylesRoot, + tokensConfig: params.tokensConfig, }; } diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/css-custom-property-parser.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/css-custom-property-parser.spec.ts new file mode 100644 index 0000000..4e3bc48 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/css-custom-property-parser.spec.ts @@ -0,0 +1,379 @@ +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; + +import { + extractCustomPropertiesFromContent, + parseCssCustomProperties, +} from '@push-based/styles-ast-utils'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Wraps declarations in a :root block for realistic CSS content. */ +function cssRoot(declarations: string): string { + return `:root {\n${declarations}\n}`; +} + +// --------------------------------------------------------------------------- +// Unit Tests — extractCustomPropertiesFromContent +// --------------------------------------------------------------------------- + +describe('extractCustomPropertiesFromContent', () => { + it('extracts basic --name: value; pairs', async () => { + const css = cssRoot(` + --color-primary: #ff0000; + --spacing-sm: 4px; + `); + const result = await extractCustomPropertiesFromContent(css); + + expect(result.get('--color-primary')).toBe('#ff0000'); + expect(result.get('--spacing-sm')).toBe('4px'); + expect(result.size).toBe(2); + }); + + it('extracts multi-line declarations', async () => { + const css = cssRoot(` + --gradient-bg: linear-gradient( + to right, + #ff0000, + #00ff00 + ); + --simple: blue; + `); + const result = await extractCustomPropertiesFromContent(css); + + expect(result.has('--gradient-bg')).toBe(true); + expect(result.get('--simple')).toBe('blue'); + }); + + it('strips comments and does not extract properties inside comments', async () => { + const css = cssRoot(` + /* --commented-out: should-not-appear; */ + --real-prop: visible; + /* + --multi-line-comment: also-hidden; + --another-hidden: nope; + */ + --another-real: yes; + `); + const result = await extractCustomPropertiesFromContent(css); + + expect(result.has('--commented-out')).toBe(false); + expect(result.has('--multi-line-comment')).toBe(false); + expect(result.has('--another-hidden')).toBe(false); + expect(result.get('--real-prop')).toBe('visible'); + expect(result.get('--another-real')).toBe('yes'); + expect(result.size).toBe(2); + }); + + it('preserves var() references in values', async () => { + const css = cssRoot(` + --color-primary: #86b521; + --button-bg: var(--color-primary); + --button-border: var(--color-primary, #000); + `); + const result = await extractCustomPropertiesFromContent(css); + + expect(result.get('--button-bg')).toBe('var(--color-primary)'); + expect(result.get('--button-border')).toBe('var(--color-primary, #000)'); + }); + + it('returns empty Map for empty content', async () => { + const result = await extractCustomPropertiesFromContent(''); + expect(result.size).toBe(0); + }); + + it('returns empty Map for content with no custom properties', async () => { + const css = `body { color: red; font-size: 16px; }`; + const result = await extractCustomPropertiesFromContent(css); + expect(result.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — parseCssCustomProperties (file-based) +// --------------------------------------------------------------------------- + +describe('parseCssCustomProperties', () => { + it('returns empty Map for non-existent file', async () => { + const result = await parseCssCustomProperties( + path.join(__dirname, 'this-file-does-not-exist.css'), + ); + expect(result).toBeInstanceOf(Map); + expect(result.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — propertyPrefix filtering +// --------------------------------------------------------------------------- + +describe('extractCustomPropertiesFromContent — propertyPrefix filtering', () => { + const css = cssRoot(` + --semantic-color-primary: #ff0000; + --semantic-color-secondary: #00ff00; + --semantic-spacing-sm: 4px; + --ds-button-bg: var(--semantic-color-primary); + --other-prop: 10px; + `); + + it('returns all properties when propertyPrefix is null', async () => { + const result = await extractCustomPropertiesFromContent(css, { + propertyPrefix: null, + }); + expect(result.size).toBe(5); + }); + + it('returns all properties when propertyPrefix is undefined', async () => { + const result = await extractCustomPropertiesFromContent(css); + expect(result.size).toBe(5); + }); + + it('filters by prefix --semantic-color', async () => { + const result = await extractCustomPropertiesFromContent(css, { + propertyPrefix: '--semantic-color', + }); + expect(result.size).toBe(2); + expect(result.has('--semantic-color-primary')).toBe(true); + expect(result.has('--semantic-color-secondary')).toBe(true); + }); + + it('filters by prefix --ds-', async () => { + const result = await extractCustomPropertiesFromContent(css, { + propertyPrefix: '--ds-', + }); + expect(result.size).toBe(1); + expect(result.has('--ds-button-bg')).toBe(true); + }); + + it('returns empty Map when no properties match prefix', async () => { + const result = await extractCustomPropertiesFromContent(css, { + propertyPrefix: '--nonexistent-', + }); + expect(result.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Property-Based Tests (parameterised) +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 3.1, 3.5** + * Property 4: CSS custom property parsing round-trip + */ +describe('Property 4: CSS custom property parsing round-trip', () => { + const testCases = [ + { + label: 'simple color tokens', + properties: [ + ['--color-red', '#ff0000'], + ['--color-green', '#00ff00'], + ['--color-blue', '#0000ff'], + ] as [string, string][], + }, + { + label: 'spacing tokens with various units', + properties: [ + ['--spacing-xs', '2px'], + ['--spacing-sm', '0.25rem'], + ['--spacing-md', '1em'], + ['--spacing-lg', '24px'], + ] as [string, string][], + }, + { + label: 'tokens with var() references', + properties: [ + ['--base-color', '#86b521'], + ['--button-bg', 'var(--base-color)'], + ['--button-border', 'var(--base-color, #000)'], + ] as [string, string][], + }, + { + label: 'tokens with complex values', + properties: [ + ['--font-family', 'Arial, Helvetica, sans-serif'], + ['--shadow', '0 2px 4px rgba(0, 0, 0, 0.1)'], + ['--transition', 'all 0.3s ease-in-out'], + ] as [string, string][], + }, + { + label: 'single token', + properties: [['--only-one', '42px']] as [string, string][], + }, + { + label: 'tokens with hyphens and numbers in names', + properties: [ + ['--z-index-100', '100'], + ['--line-height-1-5', '1.5'], + ['--border-radius-2xl', '16px'], + ] as [string, string][], + }, + ]; + + it.each(testCases)( + 'round-trips all properties: $label', + async ({ properties }) => { + const declarations = properties + .map(([name, value]) => ` ${name}: ${value};`) + .join('\n'); + const css = cssRoot(declarations); + + const result = await extractCustomPropertiesFromContent(css); + + for (const [name, value] of properties) { + expect(result.get(name)).toBe(value); + } + expect(result.size).toBe(properties.length); + }, + ); +}); + +/** + * **Validates: Requirements 3.4** + * Property 5: CSS parser ignores comments + */ +describe('Property 5: CSS parser ignores comments', () => { + const commentOnlyCases = [ + { + label: 'single-line comment with property', + css: `/* --hidden: value; */`, + }, + { + label: 'multi-line comment with multiple properties', + css: `/*\n --hidden-a: red;\n --hidden-b: blue;\n*/`, + }, + { + label: 'multiple separate comments', + css: `/* --a: 1; */\n/* --b: 2; */\n/* --c: 3; */`, + }, + { + label: 'comment inside :root block', + css: `:root {\n /* --inside-root: hidden; */\n}`, + }, + { + label: 'nested-looking comment', + css: `/* :root { --nested: val; } */`, + }, + { + label: 'comment with var() reference', + css: `/* --ref: var(--other); */`, + }, + { + label: 'empty comment', + css: `/* */`, + }, + ]; + + it.each(commentOnlyCases)('returns empty Map: $label', async ({ css }) => { + const result = await extractCustomPropertiesFromContent(css); + expect(result.size).toBe(0); + }); + + it('extracts real properties while ignoring commented ones', async () => { + const css = ` + /* --hidden: nope; */ + :root { + --visible: yes; + /* --also-hidden: nope; */ + --also-visible: yes; + } + `; + const result = await extractCustomPropertiesFromContent(css); + expect(result.size).toBe(2); + expect(result.has('--visible')).toBe(true); + expect(result.has('--also-visible')).toBe(true); + expect(result.has('--hidden')).toBe(false); + expect(result.has('--also-hidden')).toBe(false); + }); +}); + +/** + * **Validates: Requirements 4.5, 4.6** + * Property 6: Property prefix filtering + */ +describe('Property 6: Property prefix filtering', () => { + const allProperties: [string, string][] = [ + ['--semantic-color-primary', '#ff0000'], + ['--semantic-color-secondary', '#00ff00'], + ['--semantic-spacing-sm', '4px'], + ['--semantic-spacing-md', '8px'], + ['--semantic-radius-sm', '2px'], + ['--ds-button-bg', 'var(--semantic-color-primary)'], + ['--ds-card-padding', '16px'], + ['--other-misc', '1'], + ]; + + const declarations = allProperties + .map(([name, value]) => ` ${name}: ${value};`) + .join('\n'); + const css = cssRoot(declarations); + + const prefixCases = [ + { + label: 'prefix --semantic-color matches 2 tokens', + prefix: '--semantic-color', + expectedCount: 2, + expectedNames: ['--semantic-color-primary', '--semantic-color-secondary'], + }, + { + label: 'prefix --semantic-spacing matches 2 tokens', + prefix: '--semantic-spacing', + expectedCount: 2, + expectedNames: ['--semantic-spacing-sm', '--semantic-spacing-md'], + }, + { + label: 'prefix --semantic- matches 5 tokens', + prefix: '--semantic-', + expectedCount: 5, + expectedNames: [ + '--semantic-color-primary', + '--semantic-color-secondary', + '--semantic-spacing-sm', + '--semantic-spacing-md', + '--semantic-radius-sm', + ], + }, + { + label: 'prefix --ds- matches 2 tokens', + prefix: '--ds-', + expectedCount: 2, + expectedNames: ['--ds-button-bg', '--ds-card-padding'], + }, + { + label: 'prefix --nonexistent- matches 0 tokens', + prefix: '--nonexistent-', + expectedCount: 0, + expectedNames: [], + }, + ]; + + it.each(prefixCases)( + '$label', + async ({ prefix, expectedCount, expectedNames }) => { + const result = await extractCustomPropertiesFromContent(css, { + propertyPrefix: prefix, + }); + expect(result.size).toBe(expectedCount); + for (const name of expectedNames) { + expect(result.has(name)).toBe(true); + } + }, + ); + + it('null prefix includes all properties', async () => { + const result = await extractCustomPropertiesFromContent(css, { + propertyPrefix: null, + }); + expect(result.size).toBe(allProperties.length); + for (const [name] of allProperties) { + expect(result.has(name)).toBe(true); + } + }); + + it('undefined options includes all properties', async () => { + const result = await extractCustomPropertiesFromContent(css); + expect(result.size).toBe(allProperties.length); + }); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset-loader.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset-loader.spec.ts new file mode 100644 index 0000000..39495ca --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset-loader.spec.ts @@ -0,0 +1,685 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +import { loadTokenDataset } from '../token-dataset-loader.js'; +import { TokensConfigSchema } from '../../../../../validation/angular-mcp-server-options.schema.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Returns default TokensConfig from the Zod schema. */ +function defaultTokensConfig() { + return TokensConfigSchema.parse({}); +} + +/** Creates a CSS file with custom property declarations inside :root. */ +function writeCssFile(filePath: string, declarations: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, `:root {\n${declarations}\n}\n`, 'utf-8'); +} + +// --------------------------------------------------------------------------- +// Temp directory management +// --------------------------------------------------------------------------- + +let tmpRoot: string; + +beforeAll(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'token-loader-test-')); +}); + +afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); +}); + +/** Creates a unique sub-directory under tmpRoot for each test scenario. */ +function makeTempDir(name: string): string { + const dir = path.join(tmpRoot, name); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +// --------------------------------------------------------------------------- +// Unit Tests — empty / absent scenarios +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — empty dataset when generatedStylesRoot is absent', () => { + it('returns empty dataset when generatedStylesRoot does not exist', async () => { + const ds = await loadTokenDataset({ + generatedStylesRoot: 'nonexistent-path-that-does-not-exist', + workspaceRoot: tmpRoot, + tokens: defaultTokensConfig(), + }); + + expect(ds.isEmpty).toBe(true); + expect(ds.diagnostics.length).toBeGreaterThan(0); + expect(ds.diagnostics[0]).toContain('does not exist'); + }); +}); + +describe('loadTokenDataset — empty dataset with diagnostic when glob matches zero files', () => { + it('returns empty dataset with diagnostic when no files match the pattern', async () => { + const stylesDir = makeTempDir('empty-glob'); + // Create a directory but no matching files + fs.writeFileSync(path.join(stylesDir, 'unrelated.txt'), 'not css'); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), filePattern: '**/semantic.css' }, + }); + + expect(ds.isEmpty).toBe(true); + expect(ds.diagnostics.length).toBeGreaterThan(0); + expect(ds.diagnostics[0]).toContain('No files matched'); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — flat directory strategy +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — flat scope strategy', () => { + it('produces empty scope for all tokens', async () => { + const stylesDir = makeTempDir('flat-strategy'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + ` --semantic-color-primary: #ff0000;\n --semantic-spacing-sm: 4px;`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'flat' }, + }); + + expect(ds.isEmpty).toBe(false); + expect(ds.tokens.length).toBe(2); + for (const token of ds.tokens) { + expect(token.scope).toEqual({}); + } + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — brand-theme directory strategy +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — brand-theme scope strategy', () => { + it('assigns correct scope from path segments', async () => { + const stylesDir = makeTempDir('brand-theme-strategy'); + writeCssFile( + path.join(stylesDir, 'acme', 'dark', 'semantic.css'), + ` --semantic-color-primary: #111111;`, + ); + writeCssFile( + path.join(stylesDir, 'acme', 'light', 'semantic.css'), + ` --semantic-color-primary: #ffffff;`, + ); + writeCssFile( + path.join(stylesDir, 'beta', 'dark', 'semantic.css'), + ` --semantic-color-primary: #222222;`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'brand-theme' }, + }); + + expect(ds.isEmpty).toBe(false); + expect(ds.tokens.length).toBe(3); + + const acmeDark = ds.tokens.find((t) => t.value === '#111111'); + expect(acmeDark).toBeDefined(); + expect(acmeDark!.scope).toEqual({ brand: 'acme', theme: 'dark' }); + + const acmeLight = ds.tokens.find((t) => t.value === '#ffffff'); + expect(acmeLight).toBeDefined(); + expect(acmeLight!.scope).toEqual({ brand: 'acme', theme: 'light' }); + + const betaDark = ds.tokens.find((t) => t.value === '#222222'); + expect(betaDark).toBeDefined(); + expect(betaDark!.scope).toEqual({ brand: 'beta', theme: 'dark' }); + }); + + it('assigns only brand when file is one level deep', async () => { + const stylesDir = makeTempDir('brand-only'); + writeCssFile( + path.join(stylesDir, 'acme', 'semantic.css'), + ` --semantic-color-primary: #aaa;`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'brand-theme' }, + }); + + expect(ds.tokens.length).toBe(1); + expect(ds.tokens[0].scope).toEqual({ brand: 'acme' }); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — by-prefix categorisation +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — by-prefix categorisation', () => { + it('assigns categories using default categoryPrefixMap', async () => { + const stylesDir = makeTempDir('by-prefix-default'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + [ + ' --semantic-color-primary: #ff0000;', + ' --semantic-spacing-sm: 4px;', + ' --semantic-radius-md: 8px;', + ' --semantic-typography-body: 16px;', + ' --semantic-size-lg: 24px;', + ' --semantic-opacity-half: 0.5;', + ' --unknown-token: 42;', + ].join('\n'), + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), categoryInference: 'by-prefix' }, + }); + + expect( + ds.tokens.find((t) => t.name === '--semantic-color-primary')?.category, + ).toBe('color'); + expect( + ds.tokens.find((t) => t.name === '--semantic-spacing-sm')?.category, + ).toBe('spacing'); + expect( + ds.tokens.find((t) => t.name === '--semantic-radius-md')?.category, + ).toBe('radius'); + expect( + ds.tokens.find((t) => t.name === '--semantic-typography-body')?.category, + ).toBe('typography'); + expect( + ds.tokens.find((t) => t.name === '--semantic-size-lg')?.category, + ).toBe('size'); + expect( + ds.tokens.find((t) => t.name === '--semantic-opacity-half')?.category, + ).toBe('opacity'); + expect( + ds.tokens.find((t) => t.name === '--unknown-token')?.category, + ).toBeUndefined(); + }); + + it('assigns categories using custom categoryPrefixMap', async () => { + const stylesDir = makeTempDir('by-prefix-custom'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + [ + ' --brand-color-primary: #ff0000;', + ' --brand-space-sm: 4px;', + ' --semantic-color-primary: #00ff00;', + ].join('\n'), + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { + ...defaultTokensConfig(), + categoryInference: 'by-prefix', + categoryPrefixMap: { + color: '--brand-color', + spacing: '--brand-space', + }, + }, + }); + + expect( + ds.tokens.find((t) => t.name === '--brand-color-primary')?.category, + ).toBe('color'); + expect(ds.tokens.find((t) => t.name === '--brand-space-sm')?.category).toBe( + 'spacing', + ); + // --semantic-color-primary doesn't match custom map + expect( + ds.tokens.find((t) => t.name === '--semantic-color-primary')?.category, + ).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — by-value categorisation +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — by-value categorisation', () => { + it('infers categories from resolved values', async () => { + const stylesDir = makeTempDir('by-value'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + [ + ' --token-hex: #ff0000;', + ' --token-hex-short: #f00;', + ' --token-rgb: rgb(255, 0, 0);', + ' --token-rgba: rgba(255, 0, 0, 0.5);', + ' --token-hsl: hsl(120, 100%, 50%);', + ' --token-hsla: hsla(120, 100%, 50%, 0.5);', + ' --token-px: 16px;', + ' --token-rem: 1.5rem;', + ' --token-em: 2em;', + ' --token-percent: 50%;', + ' --token-plain: some-value;', + ].join('\n'), + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), categoryInference: 'by-value' }, + }); + + expect(ds.tokens.find((t) => t.name === '--token-hex')?.category).toBe( + 'color', + ); + expect( + ds.tokens.find((t) => t.name === '--token-hex-short')?.category, + ).toBe('color'); + expect(ds.tokens.find((t) => t.name === '--token-rgb')?.category).toBe( + 'color', + ); + expect(ds.tokens.find((t) => t.name === '--token-rgba')?.category).toBe( + 'color', + ); + expect(ds.tokens.find((t) => t.name === '--token-hsl')?.category).toBe( + 'color', + ); + expect(ds.tokens.find((t) => t.name === '--token-hsla')?.category).toBe( + 'color', + ); + expect(ds.tokens.find((t) => t.name === '--token-px')?.category).toBe( + 'spacing', + ); + expect(ds.tokens.find((t) => t.name === '--token-rem')?.category).toBe( + 'spacing', + ); + expect(ds.tokens.find((t) => t.name === '--token-em')?.category).toBe( + 'spacing', + ); + expect(ds.tokens.find((t) => t.name === '--token-percent')?.category).toBe( + 'opacity', + ); + expect( + ds.tokens.find((t) => t.name === '--token-plain')?.category, + ).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — none categorisation +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — none categorisation', () => { + it('leaves all tokens uncategorised', async () => { + const stylesDir = makeTempDir('none-cat'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + [ + ' --semantic-color-primary: #ff0000;', + ' --semantic-spacing-sm: 4px;', + ].join('\n'), + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), categoryInference: 'none' }, + }); + + expect(ds.isEmpty).toBe(false); + for (const token of ds.tokens) { + expect(token.category).toBeUndefined(); + } + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — propertyPrefix filtering +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — propertyPrefix filtering', () => { + it('includes only properties matching the prefix', async () => { + const stylesDir = makeTempDir('prefix-filter'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + [ + ' --semantic-color-primary: #ff0000;', + ' --semantic-spacing-sm: 4px;', + ' --ds-button-bg: var(--semantic-color-primary);', + ' --other-prop: 10px;', + ].join('\n'), + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), propertyPrefix: '--semantic-' }, + }); + + expect(ds.tokens.length).toBe(2); + for (const token of ds.tokens) { + expect(token.name.startsWith('--semantic-')).toBe(true); + } + }); + + it('includes all properties when propertyPrefix is null', async () => { + const stylesDir = makeTempDir('prefix-null'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + [ + ' --semantic-color-primary: #ff0000;', + ' --ds-button-bg: var(--semantic-color-primary);', + ' --other-prop: 10px;', + ].join('\n'), + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), propertyPrefix: null }, + }); + + expect(ds.tokens.length).toBe(3); + }); +}); + +// =========================================================================== +// Property-Based Tests (parameterised) +// =========================================================================== + +/** + * **Validates: Requirements 5.1** + * Property 7: Flat scope strategy produces scopeless tokens + * + * For any set of token files discovered under generatedStylesRoot with + * scopeStrategy set to 'flat', all tokens in the resulting dataset + * SHALL have an empty scope (no brand, no theme). + */ +describe('Property 7: Flat scope strategy produces scopeless tokens', () => { + const cases = [ + { + label: 'single file at root', + setup: (dir: string) => { + writeCssFile(path.join(dir, 'semantic.css'), ' --a: #f00;'); + }, + }, + { + label: 'multiple files at root', + setup: (dir: string) => { + writeCssFile( + path.join(dir, 'semantic.css'), + ' --a: #f00;\n --b: 4px;', + ); + writeCssFile(path.join(dir, 'other.css'), ' --c: 8px;'); + }, + filePattern: '**/*.css', + }, + { + label: 'files in nested directories (still flat strategy)', + setup: (dir: string) => { + writeCssFile( + path.join(dir, 'brand', 'theme', 'semantic.css'), + ' --a: #f00;', + ); + writeCssFile(path.join(dir, 'semantic.css'), ' --b: 4px;'); + }, + }, + { + label: 'file with many tokens', + setup: (dir: string) => { + const declarations = Array.from( + { length: 10 }, + (_, i) => ` --token-${i}: value-${i};`, + ).join('\n'); + writeCssFile(path.join(dir, 'semantic.css'), declarations); + }, + }, + ]; + + it.each(cases)( + 'all tokens have empty scope: $label', + async ({ setup, filePattern }) => { + const stylesDir = makeTempDir( + `p7-${cases.indexOf(cases.find((c) => c.setup === setup)!)}`, + ); + setup(stylesDir); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { + ...defaultTokensConfig(), + scopeStrategy: 'flat', + filePattern: filePattern ?? '**/semantic.css', + }, + }); + + expect(ds.isEmpty).toBe(false); + for (const token of ds.tokens) { + expect(token.scope).toEqual({}); + } + }, + ); +}); + +/** + * **Validates: Requirements 5.2, 5.4** + * Property 8: Brand-theme scope strategy assigns correct scope + * + * For any token file at path {generatedStylesRoot}/{segment1}/{segment2}/... + * with scopeStrategy set to 'brand-theme', the resulting tokens SHALL have + * scope keys mapped from the path segments (first → brand, second → theme). + */ +describe('Property 8: Brand-theme scope strategy assigns correct scope', () => { + const cases = [ + { + label: 'brand/theme/file → { brand, theme }', + pathSegments: ['acme', 'dark'], + expectedScope: { brand: 'acme', theme: 'dark' }, + }, + { + label: 'brand-only/file → { brand }', + pathSegments: ['beta'], + expectedScope: { brand: 'beta' }, + }, + { + label: 'different brand/theme combo', + pathSegments: ['gamma', 'light'], + expectedScope: { brand: 'gamma', theme: 'light' }, + }, + { + label: 'root-level file → empty scope', + pathSegments: [], + expectedScope: {}, + }, + ]; + + it.each(cases)('$label', async ({ pathSegments, expectedScope }) => { + const stylesDir = makeTempDir(`p8-${pathSegments.join('-') || 'root'}`); + const filePath = path.join(stylesDir, ...pathSegments, 'semantic.css'); + writeCssFile(filePath, ' --token-a: #ff0000;'); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'brand-theme' }, + }); + + expect(ds.isEmpty).toBe(false); + expect(ds.tokens[0].scope).toEqual(expectedScope); + }); +}); + +/** + * **Validates: Requirements 6.1, 6.4** + * Property 9: Category assignment by prefix + * + * For any token name and any categoryPrefixMap, when categoryInference is + * 'by-prefix', the token SHALL be assigned the category whose prefix is the + * longest matching prefix of the token name. If no prefix matches, the token + * SHALL be uncategorised. + */ +describe('Property 9: Category assignment by prefix', () => { + const prefixMap = { + color: '--semantic-color', + spacing: '--semantic-spacing', + 'color-primary': '--semantic-color-primary', + }; + + const cases = [ + { + label: 'exact prefix match → color', + tokenName: '--semantic-color-secondary', + expectedCategory: 'color', + }, + { + label: 'longest prefix wins → color-primary', + tokenName: '--semantic-color-primary-dark', + expectedCategory: 'color-primary', + }, + { + label: 'spacing prefix match', + tokenName: '--semantic-spacing-sm', + expectedCategory: 'spacing', + }, + { + label: 'no prefix match → uncategorised', + tokenName: '--unknown-token', + expectedCategory: undefined, + }, + { + label: 'partial match not enough → uncategorised', + tokenName: '--semantic-radius-sm', + expectedCategory: undefined, + }, + ]; + + it.each(cases)( + '$label: $tokenName → $expectedCategory', + async ({ tokenName, expectedCategory }) => { + const stylesDir = makeTempDir(`p9-${tokenName.replace(/--/g, '')}`); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + ` ${tokenName}: some-value;`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { + ...defaultTokensConfig(), + categoryInference: 'by-prefix', + categoryPrefixMap: prefixMap, + }, + }); + + expect(ds.tokens.length).toBe(1); + expect(ds.tokens[0].category).toBe(expectedCategory); + }, + ); +}); + +/** + * **Validates: Requirements 6.2** + * Property 10: Category inference by value + * + * For any resolved token value, when categoryInference is 'by-value', the token + * SHALL be assigned a category matching the value pattern (hex/rgb/hsl → color, + * px/rem/em → spacing, percentage → opacity). Values not matching any pattern + * SHALL be uncategorised. + */ +describe('Property 10: Category inference by value', () => { + const cases = [ + { + label: 'hex 6-digit → color', + value: '#ff0000', + expectedCategory: 'color', + }, + { label: 'hex 3-digit → color', value: '#f00', expectedCategory: 'color' }, + { + label: 'hex 8-digit → color', + value: '#ff000080', + expectedCategory: 'color', + }, + { + label: 'rgb() → color', + value: 'rgb(255, 0, 0)', + expectedCategory: 'color', + }, + { + label: 'rgba() → color', + value: 'rgba(255, 0, 0, 0.5)', + expectedCategory: 'color', + }, + { + label: 'hsl() → color', + value: 'hsl(120, 100%, 50%)', + expectedCategory: 'color', + }, + { + label: 'hsla() → color', + value: 'hsla(120, 100%, 50%, 0.5)', + expectedCategory: 'color', + }, + { label: 'px → spacing', value: '16px', expectedCategory: 'spacing' }, + { + label: 'negative px → spacing', + value: '-4px', + expectedCategory: 'spacing', + }, + { label: 'rem → spacing', value: '1.5rem', expectedCategory: 'spacing' }, + { label: 'em → spacing', value: '2em', expectedCategory: 'spacing' }, + { + label: 'percentage → opacity', + value: '50%', + expectedCategory: 'opacity', + }, + { + label: 'plain string → uncategorised', + value: 'some-value', + expectedCategory: undefined, + }, + { + label: 'number without unit → uncategorised', + value: '42', + expectedCategory: undefined, + }, + { + label: 'var() reference → uncategorised', + value: 'var(--other)', + expectedCategory: undefined, + }, + ]; + + it.each(cases)( + '$label: "$value" → $expectedCategory', + async ({ value, expectedCategory }) => { + const safeName = value.replace(/[^a-zA-Z0-9]/g, '-').slice(0, 30); + const stylesDir = makeTempDir(`p10-${safeName}`); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + ` --test-token: ${value};`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), categoryInference: 'by-value' }, + }); + + expect(ds.tokens.length).toBe(1); + expect(ds.tokens[0].category).toBe(expectedCategory); + }, + ); +}); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset.spec.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset.spec.ts new file mode 100644 index 0000000..eccc560 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset.spec.ts @@ -0,0 +1,876 @@ +import { describe, it, expect } from 'vitest'; + +import { + TokenDatasetImpl, + createEmptyTokenDataset, + type TokenEntry, +} from '../token-dataset.js'; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** Builds a TokenEntry with sensible defaults. */ +function entry( + overrides: Partial & Pick, +): TokenEntry { + return { + scope: {}, + sourceFile: 'tokens.css', + ...overrides, + }; +} + +/** Rich fixture set covering various categories, scopes, and values. */ +const FIXTURES: TokenEntry[] = [ + // Color tokens — flat scope + entry({ + name: '--semantic-color-primary', + value: '#86b521', + category: 'color', + sourceFile: 'semantic.css', + }), + entry({ + name: '--semantic-color-secondary', + value: '#3366cc', + category: 'color', + sourceFile: 'semantic.css', + }), + entry({ + name: '--semantic-color-error', + value: '#ff0000', + category: 'color', + sourceFile: 'semantic.css', + }), + + // Spacing tokens — flat scope + entry({ + name: '--semantic-spacing-sm', + value: '4px', + category: 'spacing', + sourceFile: 'semantic.css', + }), + entry({ + name: '--semantic-spacing-md', + value: '8px', + category: 'spacing', + sourceFile: 'semantic.css', + }), + entry({ + name: '--semantic-spacing-lg', + value: '16px', + category: 'spacing', + sourceFile: 'semantic.css', + }), + + // Radius tokens — flat scope + entry({ + name: '--semantic-radius-sm', + value: '2px', + category: 'radius', + sourceFile: 'semantic.css', + }), + + // Uncategorised token + entry({ name: '--misc-token', value: '42', sourceFile: 'semantic.css' }), + + // Tokens with var() references + entry({ + name: '--ds-button-bg', + value: 'var(--semantic-color-primary)', + category: 'color', + sourceFile: 'components.css', + }), + + // Tokens with brand scope + entry({ + name: '--semantic-color-primary', + value: '#ff9900', + category: 'color', + scope: { brand: 'acme' }, + sourceFile: 'acme/semantic.css', + }), + entry({ + name: '--semantic-color-secondary', + value: '#009900', + category: 'color', + scope: { brand: 'acme' }, + sourceFile: 'acme/semantic.css', + }), + + // Tokens with brand + theme scope + entry({ + name: '--semantic-color-primary', + value: '#111111', + category: 'color', + scope: { brand: 'acme', theme: 'dark' }, + sourceFile: 'acme/dark/semantic.css', + }), + entry({ + name: '--semantic-spacing-sm', + value: '6px', + category: 'spacing', + scope: { brand: 'acme', theme: 'dark' }, + sourceFile: 'acme/dark/semantic.css', + }), + + // Duplicate value across scopes (for reverse lookup testing) + entry({ + name: '--semantic-opacity-low', + value: '0.5', + category: 'opacity', + sourceFile: 'semantic.css', + }), + entry({ + name: '--semantic-opacity-low', + value: '0.5', + category: 'opacity', + scope: { brand: 'acme' }, + sourceFile: 'acme/semantic.css', + }), +]; + +function buildDataset(tokens: TokenEntry[] = FIXTURES): TokenDatasetImpl { + return new TokenDatasetImpl(tokens); +} + +// --------------------------------------------------------------------------- +// Unit Tests — getByName +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByName', () => { + const ds = buildDataset(); + + it('returns the token for an exact name match (last-write wins for duplicates)', () => { + const result = ds.getByName('--semantic-color-primary'); + expect(result).toBeDefined(); + expect(result!.name).toBe('--semantic-color-primary'); + }); + + it('returns undefined for a name not in the dataset', () => { + expect(ds.getByName('--nonexistent-token')).toBeUndefined(); + }); + + it('returns the unique token when name is unique', () => { + const result = ds.getByName('--semantic-radius-sm'); + expect(result).toBeDefined(); + expect(result!.value).toBe('2px'); + expect(result!.category).toBe('radius'); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — getByPrefix +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByPrefix', () => { + const ds = buildDataset(); + + it('returns all tokens whose name starts with the prefix', () => { + const result = ds.getByPrefix('--semantic-color'); + // 3 flat + 2 acme + 1 acme/dark = 6 + expect(result.length).toBe(6); + for (const t of result) { + expect(t.name.startsWith('--semantic-color')).toBe(true); + } + }); + + it('returns empty array when no tokens match the prefix', () => { + expect(ds.getByPrefix('--nonexistent-')).toEqual([]); + }); + + it('returns all tokens when prefix is --', () => { + const result = ds.getByPrefix('--'); + expect(result.length).toBe(FIXTURES.length); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — getByValue +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByValue', () => { + const ds = buildDataset(); + + it('returns all tokens with the given value', () => { + const result = ds.getByValue('0.5'); + expect(result.length).toBe(2); + for (const t of result) { + expect(t.value).toBe('0.5'); + } + }); + + it('returns empty array for a value not in the dataset', () => { + expect(ds.getByValue('nonexistent-value')).toEqual([]); + }); + + it('finds tokens with var() reference values', () => { + const result = ds.getByValue('var(--semantic-color-primary)'); + expect(result.length).toBe(1); + expect(result[0].name).toBe('--ds-button-bg'); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — getByCategory +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByCategory', () => { + const ds = buildDataset(); + + it('returns all tokens in the given category', () => { + const result = ds.getByCategory('color'); + // 3 flat + 1 ds-button-bg + 2 acme + 1 acme/dark = 7 + expect(result.length).toBe(7); + for (const t of result) { + expect(t.category).toBe('color'); + } + }); + + it('returns empty array for a category not in the dataset', () => { + expect(ds.getByCategory('nonexistent')).toEqual([]); + }); + + it('does not include uncategorised tokens', () => { + const result = ds.getByCategory('color'); + expect(result.find((t) => t.name === '--misc-token')).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — getByScope +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByScope', () => { + const ds = buildDataset(); + + it('returns tokens matching a single scope key-value pair', () => { + const result = ds.getByScope({ brand: 'acme' }); + // 2 acme color + 1 acme opacity + 2 acme/dark = 5 + expect(result.length).toBe(5); + for (const t of result) { + expect(t.scope.brand).toBe('acme'); + } + }); + + it('returns tokens matching multiple scope key-value pairs (intersection)', () => { + const result = ds.getByScope({ brand: 'acme', theme: 'dark' }); + expect(result.length).toBe(2); + for (const t of result) { + expect(t.scope.brand).toBe('acme'); + expect(t.scope.theme).toBe('dark'); + } + }); + + it('returns all tokens when scope is empty', () => { + const result = ds.getByScope({}); + expect(result.length).toBe(FIXTURES.length); + }); + + it('returns empty array when scope key does not exist', () => { + expect(ds.getByScope({ variant: 'unknown' })).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — getByValueInScope +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByValueInScope', () => { + const ds = buildDataset(); + + it('returns only tokens matching both value and scope', () => { + const result = ds.getByValueInScope('0.5', { brand: 'acme' }); + expect(result.length).toBe(1); + expect(result[0].scope.brand).toBe('acme'); + expect(result[0].value).toBe('0.5'); + }); + + it('returns empty when value matches but scope does not', () => { + expect(ds.getByValueInScope('0.5', { brand: 'nonexistent' })).toEqual([]); + }); + + it('returns empty when scope matches but value does not', () => { + expect(ds.getByValueInScope('nonexistent', { brand: 'acme' })).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — getByCategoryInScope +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — getByCategoryInScope', () => { + const ds = buildDataset(); + + it('returns only tokens matching both category and scope', () => { + const result = ds.getByCategoryInScope('color', { + brand: 'acme', + theme: 'dark', + }); + expect(result.length).toBe(1); + expect(result[0].name).toBe('--semantic-color-primary'); + expect(result[0].scope.brand).toBe('acme'); + expect(result[0].scope.theme).toBe('dark'); + }); + + it('returns empty when category matches but scope does not', () => { + expect(ds.getByCategoryInScope('color', { brand: 'nonexistent' })).toEqual( + [], + ); + }); + + it('returns empty when scope matches but category does not', () => { + expect(ds.getByCategoryInScope('nonexistent', { brand: 'acme' })).toEqual( + [], + ); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — isEmpty +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — isEmpty', () => { + it('is true for an empty dataset', () => { + const ds = new TokenDatasetImpl([]); + expect(ds.isEmpty).toBe(true); + }); + + it('is false for a dataset with tokens', () => { + const ds = buildDataset(); + expect(ds.isEmpty).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — result shape +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — result shape', () => { + const ds = buildDataset(); + + it('getByName result contains all required fields', () => { + const result = ds.getByName('--semantic-color-error'); + expect(result).toBeDefined(); + expect(result).toHaveProperty('name'); + expect(result).toHaveProperty('value'); + expect(result).toHaveProperty('scope'); + expect(result).toHaveProperty('sourceFile'); + // category is either a string or undefined + expect( + result!.category === undefined || typeof result!.category === 'string', + ).toBe(true); + }); + + it('getByPrefix results contain all required fields', () => { + const results = ds.getByPrefix('--semantic-spacing'); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + expect(r).toHaveProperty('name'); + expect(r).toHaveProperty('value'); + expect(r).toHaveProperty('scope'); + expect(r).toHaveProperty('sourceFile'); + } + }); + + it('uncategorised token has category as undefined', () => { + const result = ds.getByName('--misc-token'); + expect(result).toBeDefined(); + expect(result!.category).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — createEmptyTokenDataset +// --------------------------------------------------------------------------- + +describe('createEmptyTokenDataset', () => { + it('returns an empty dataset', () => { + const ds = createEmptyTokenDataset(); + expect(ds.isEmpty).toBe(true); + expect(ds.tokens).toHaveLength(0); + expect(ds.diagnostics).toHaveLength(0); + }); + + it('includes diagnostic message when provided', () => { + const ds = createEmptyTokenDataset('No files found'); + expect(ds.isEmpty).toBe(true); + expect(ds.diagnostics).toEqual(['No files found']); + }); + + it('query methods return empty results', () => { + const ds = createEmptyTokenDataset(); + expect(ds.getByName('--anything')).toBeUndefined(); + expect(ds.getByPrefix('--')).toEqual([]); + expect(ds.getByValue('any')).toEqual([]); + expect(ds.getByCategory('color')).toEqual([]); + expect(ds.getByScope({})).toEqual([]); + expect(ds.getByValueInScope('v', {})).toEqual([]); + expect(ds.getByCategoryInScope('c', {})).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — diagnostics +// --------------------------------------------------------------------------- + +describe('TokenDatasetImpl — diagnostics', () => { + it('stores diagnostics passed at construction', () => { + const ds = new TokenDatasetImpl([], ['warning 1', 'warning 2']); + expect(ds.diagnostics).toEqual(['warning 1', 'warning 2']); + }); + + it('defaults to empty diagnostics', () => { + const ds = new TokenDatasetImpl([]); + expect(ds.diagnostics).toEqual([]); + }); +}); + +// =========================================================================== +// Property-Based Tests (parameterised) +// =========================================================================== + +/** + * **Validates: Requirements 7.1** + * Property 11: Token dataset exact name lookup + * + * For any token present in a TokenDataset, calling getByName with that token's + * exact name SHALL return that token. Calling getByName with a name not in the + * dataset SHALL return undefined. + */ +describe('Property 11: Token dataset exact name lookup', () => { + const uniqueTokens: TokenEntry[] = [ + entry({ name: '--color-red', value: '#f00', category: 'color' }), + entry({ name: '--color-blue', value: '#00f', category: 'color' }), + entry({ name: '--spacing-xs', value: '2px', category: 'spacing' }), + entry({ name: '--radius-lg', value: '16px', category: 'radius' }), + entry({ name: '--opacity-half', value: '0.5', category: 'opacity' }), + entry({ name: '--z-index-100', value: '100' }), + entry({ name: '--font-size-base', value: '16px', category: 'typography' }), + entry({ + name: '--ds-button-bg', + value: 'var(--color-red)', + category: 'color', + }), + ]; + + const ds = new TokenDatasetImpl(uniqueTokens); + + const presentCases = uniqueTokens.map((t) => ({ + label: t.name, + name: t.name, + expectedValue: t.value, + })); + + it.each(presentCases)( + 'getByName($name) returns the token', + ({ name, expectedValue }) => { + const result = ds.getByName(name); + expect(result).toBeDefined(); + expect(result!.name).toBe(name); + expect(result!.value).toBe(expectedValue); + }, + ); + + const absentCases = [ + { label: 'completely unknown', name: '--unknown-token' }, + { label: 'partial match', name: '--color' }, + { label: 'empty string', name: '' }, + { label: 'similar but different', name: '--color-red-dark' }, + { label: 'prefix only', name: '--ds-' }, + ]; + + it.each(absentCases)( + 'getByName($name) returns undefined for absent name: $label', + ({ name }) => { + expect(ds.getByName(name)).toBeUndefined(); + }, + ); +}); + +/** + * **Validates: Requirements 7.2** + * Property 12: Token dataset prefix lookup completeness + * + * For any prefix string and any TokenDataset, getByPrefix(prefix) SHALL return + * exactly the set of tokens whose name starts with that prefix — no more, no less. + */ +describe('Property 12: Token dataset prefix lookup completeness', () => { + const tokens: TokenEntry[] = [ + entry({ name: '--semantic-color-primary', value: '#f00' }), + entry({ name: '--semantic-color-secondary', value: '#0f0' }), + entry({ name: '--semantic-spacing-sm', value: '4px' }), + entry({ name: '--semantic-spacing-md', value: '8px' }), + entry({ name: '--ds-button-bg', value: 'var(--x)' }), + entry({ name: '--ds-card-padding', value: '16px' }), + entry({ name: '--other', value: '1' }), + ]; + + const ds = new TokenDatasetImpl(tokens); + + const prefixCases = [ + { prefix: '--semantic-color', expectedCount: 2 }, + { prefix: '--semantic-spacing', expectedCount: 2 }, + { prefix: '--semantic-', expectedCount: 4 }, + { prefix: '--ds-', expectedCount: 2 }, + { prefix: '--', expectedCount: 7 }, + { prefix: '--other', expectedCount: 1 }, + { prefix: '--nonexistent', expectedCount: 0 }, + { prefix: '', expectedCount: 7 }, + ]; + + it.each(prefixCases)( + 'getByPrefix("$prefix") returns exactly $expectedCount tokens', + ({ prefix, expectedCount }) => { + const result = ds.getByPrefix(prefix); + expect(result.length).toBe(expectedCount); + // Every result must start with the prefix + for (const t of result) { + expect(t.name.startsWith(prefix)).toBe(true); + } + // Every token in the dataset that starts with the prefix must be in the result + const expected = tokens.filter((t) => t.name.startsWith(prefix)); + expect(result.length).toBe(expected.length); + }, + ); +}); + +/** + * **Validates: Requirements 7.3** + * Property 13: Token dataset reverse value lookup + * + * For any value string and any TokenDataset, getByValue(value) SHALL return + * exactly the set of tokens whose resolved value equals that string. + */ +describe('Property 13: Token dataset reverse value lookup', () => { + const tokens: TokenEntry[] = [ + entry({ name: '--a', value: '#ff0000' }), + entry({ name: '--b', value: '#ff0000' }), + entry({ name: '--c', value: '#00ff00' }), + entry({ name: '--d', value: '4px' }), + entry({ name: '--e', value: 'var(--a)' }), + entry({ name: '--f', value: 'var(--a)' }), + entry({ name: '--g', value: 'unique-value' }), + ]; + + const ds = new TokenDatasetImpl(tokens); + + const valueCases = [ + { value: '#ff0000', expectedNames: ['--a', '--b'] }, + { value: '#00ff00', expectedNames: ['--c'] }, + { value: '4px', expectedNames: ['--d'] }, + { value: 'var(--a)', expectedNames: ['--e', '--f'] }, + { value: 'unique-value', expectedNames: ['--g'] }, + { value: 'not-in-dataset', expectedNames: [] }, + ]; + + it.each(valueCases)( + 'getByValue("$value") returns tokens: $expectedNames', + ({ value, expectedNames }) => { + const result = ds.getByValue(value); + expect(result.length).toBe(expectedNames.length); + const resultNames = result.map((t) => t.name).sort(); + expect(resultNames).toEqual([...expectedNames].sort()); + // Every result must have the queried value + for (const t of result) { + expect(t.value).toBe(value); + } + }, + ); +}); + +/** + * **Validates: Requirements 7.4** + * Property 14: Token dataset category lookup + * + * For any category string and any TokenDataset, getByCategory(category) SHALL + * return exactly the set of tokens assigned to that category. + */ +describe('Property 14: Token dataset category lookup', () => { + const tokens: TokenEntry[] = [ + entry({ name: '--c1', value: '#f00', category: 'color' }), + entry({ name: '--c2', value: '#0f0', category: 'color' }), + entry({ name: '--c3', value: '#00f', category: 'color' }), + entry({ name: '--s1', value: '4px', category: 'spacing' }), + entry({ name: '--s2', value: '8px', category: 'spacing' }), + entry({ name: '--r1', value: '2px', category: 'radius' }), + entry({ name: '--u1', value: '42' }), // uncategorised + entry({ name: '--u2', value: '99' }), // uncategorised + ]; + + const ds = new TokenDatasetImpl(tokens); + + const categoryCases = [ + { category: 'color', expectedCount: 3 }, + { category: 'spacing', expectedCount: 2 }, + { category: 'radius', expectedCount: 1 }, + { category: 'nonexistent', expectedCount: 0 }, + ]; + + it.each(categoryCases)( + 'getByCategory("$category") returns $expectedCount tokens', + ({ category, expectedCount }) => { + const result = ds.getByCategory(category); + expect(result.length).toBe(expectedCount); + for (const t of result) { + expect(t.category).toBe(category); + } + }, + ); + + it('uncategorised tokens are not returned by any category query', () => { + const allCategorised = [ + ...ds.getByCategory('color'), + ...ds.getByCategory('spacing'), + ...ds.getByCategory('radius'), + ]; + const uncategorised = tokens.filter((t) => t.category == null); + for (const u of uncategorised) { + expect(allCategorised.find((t) => t.name === u.name)).toBeUndefined(); + } + }); +}); + +/** + * **Validates: Requirements 7.5** + * Property 15: Token dataset query results contain all required fields + * + * For any query method on TokenDataset that returns results, each result SHALL + * contain name, value, category (or undefined), and scope fields. + */ +describe('Property 15: Token dataset query results contain all required fields', () => { + const tokens: TokenEntry[] = [ + entry({ + name: '--a', + value: '#f00', + category: 'color', + scope: { brand: 'x' }, + sourceFile: 'a.css', + }), + entry({ + name: '--b', + value: '4px', + category: 'spacing', + scope: {}, + sourceFile: 'b.css', + }), + entry({ name: '--c', value: '1', scope: {}, sourceFile: 'c.css' }), // no category + ]; + + const ds = new TokenDatasetImpl(tokens); + + function assertShape(t: Record): void { + expect(t).toHaveProperty('name'); + expect(t).toHaveProperty('value'); + expect(t).toHaveProperty('scope'); + expect(t).toHaveProperty('sourceFile'); + // category is either a string or undefined (key may or may not be present) + expect(t.category === undefined || typeof t.category === 'string').toBe( + true, + ); + expect(typeof t.name).toBe('string'); + expect(typeof t.value).toBe('string'); + expect(typeof t.scope).toBe('object'); + } + + const queryCases = [ + { label: 'getByName', fn: () => [ds.getByName('--a')].filter(Boolean) }, + { label: 'getByPrefix', fn: () => ds.getByPrefix('--') }, + { label: 'getByValue', fn: () => ds.getByValue('#f00') }, + { label: 'getByCategory', fn: () => ds.getByCategory('color') }, + { label: 'getByScope', fn: () => ds.getByScope({ brand: 'x' }) }, + { + label: 'getByValueInScope', + fn: () => ds.getByValueInScope('#f00', { brand: 'x' }), + }, + { + label: 'getByCategoryInScope', + fn: () => ds.getByCategoryInScope('color', { brand: 'x' }), + }, + ]; + + it.each(queryCases)( + '$label results contain all required fields', + ({ fn }) => { + const results = fn(); + expect(results.length).toBeGreaterThan(0); + for (const r of results) { + assertShape(r as unknown as Record); + } + }, + ); +}); + +/** + * **Validates: Requirements 7.6** + * Property 16: Token dataset scope lookup + * + * For any scope key-value pair and any TokenDataset, getByScope({ key: value }) + * SHALL return exactly the set of tokens whose scope contains that key with that + * value. When multiple key-value pairs are provided, only tokens matching all + * pairs SHALL be returned. + */ +describe('Property 16: Token dataset scope lookup', () => { + const tokens: TokenEntry[] = [ + entry({ name: '--t1', value: 'v1', scope: {} }), + entry({ name: '--t2', value: 'v2', scope: { brand: 'acme' } }), + entry({ + name: '--t3', + value: 'v3', + scope: { brand: 'acme', theme: 'dark' }, + }), + entry({ + name: '--t4', + value: 'v4', + scope: { brand: 'acme', theme: 'light' }, + }), + entry({ name: '--t5', value: 'v5', scope: { brand: 'beta' } }), + entry({ + name: '--t6', + value: 'v6', + scope: { brand: 'beta', theme: 'dark' }, + }), + ]; + + const ds = new TokenDatasetImpl(tokens); + + const scopeCases: Array<{ + label: string; + scope: Record; + expectedNames: string[]; + }> = [ + { + label: 'single key: brand=acme', + scope: { brand: 'acme' }, + expectedNames: ['--t2', '--t3', '--t4'], + }, + { + label: 'single key: brand=beta', + scope: { brand: 'beta' }, + expectedNames: ['--t5', '--t6'], + }, + { + label: 'single key: theme=dark', + scope: { theme: 'dark' }, + expectedNames: ['--t3', '--t6'], + }, + { + label: 'multiple keys: brand=acme, theme=dark', + scope: { brand: 'acme', theme: 'dark' }, + expectedNames: ['--t3'], + }, + { + label: 'multiple keys: brand=acme, theme=light', + scope: { brand: 'acme', theme: 'light' }, + expectedNames: ['--t4'], + }, + { + label: 'empty scope returns all tokens', + scope: {}, + expectedNames: ['--t1', '--t2', '--t3', '--t4', '--t5', '--t6'], + }, + { + label: 'non-matching scope key', + scope: { variant: 'unknown' }, + expectedNames: [], + }, + { + label: 'non-matching scope value', + scope: { brand: 'nonexistent' }, + expectedNames: [], + }, + ]; + + it.each(scopeCases)( + 'getByScope($label) returns $expectedNames', + ({ scope, expectedNames }) => { + const result = ds.getByScope(scope); + const resultNames = result.map((t) => t.name).sort(); + expect(resultNames).toEqual([...expectedNames].sort()); + }, + ); +}); + +/** + * **Validates: Requirements 7.7** + * Property 17: Token dataset scope-filtered value lookup + * + * For any value string, any scope filter, and any TokenDataset, + * getByValueInScope(value, scope) SHALL return exactly the subset of + * getByValue(value) results whose scope also matches all provided key-value pairs. + */ +describe('Property 17: Token dataset scope-filtered value lookup', () => { + const tokens: TokenEntry[] = [ + entry({ name: '--a1', value: '#f00', scope: {} }), + entry({ name: '--a2', value: '#f00', scope: { brand: 'acme' } }), + entry({ + name: '--a3', + value: '#f00', + scope: { brand: 'acme', theme: 'dark' }, + }), + entry({ name: '--a4', value: '#f00', scope: { brand: 'beta' } }), + entry({ name: '--b1', value: '#0f0', scope: { brand: 'acme' } }), + entry({ name: '--b2', value: '#0f0', scope: { brand: 'beta' } }), + ]; + + const ds = new TokenDatasetImpl(tokens); + + const cases: Array<{ + label: string; + value: string; + scope: Record; + expectedNames: string[]; + }> = [ + { + label: 'value=#f00, scope={brand:acme}', + value: '#f00', + scope: { brand: 'acme' }, + expectedNames: ['--a2', '--a3'], + }, + { + label: 'value=#f00, scope={brand:acme, theme:dark}', + value: '#f00', + scope: { brand: 'acme', theme: 'dark' }, + expectedNames: ['--a3'], + }, + { + label: 'value=#f00, scope={brand:beta}', + value: '#f00', + scope: { brand: 'beta' }, + expectedNames: ['--a4'], + }, + { + label: 'value=#0f0, scope={brand:acme}', + value: '#0f0', + scope: { brand: 'acme' }, + expectedNames: ['--b1'], + }, + { + label: 'value=#f00, scope={brand:nonexistent}', + value: '#f00', + scope: { brand: 'nonexistent' }, + expectedNames: [], + }, + { + label: 'value=nonexistent, scope={brand:acme}', + value: 'nonexistent', + scope: { brand: 'acme' }, + expectedNames: [], + }, + ]; + + it.each(cases)( + 'getByValueInScope($label) returns correct subset', + ({ value, scope, expectedNames }) => { + const result = ds.getByValueInScope(value, scope); + const resultNames = result.map((t) => t.name).sort(); + expect(resultNames).toEqual([...expectedNames].sort()); + + // Verify the property: result is the intersection of getByValue and getByScope + const byVal = ds.getByValue(value); + const byScope = new Set(ds.getByScope(scope)); + const expected = byVal.filter((t) => byScope.has(t)); + expect(result.length).toBe(expected.length); + }, + ); +}); 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 new file mode 100644 index 0000000..10a5095 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts @@ -0,0 +1,214 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { globToRegex, walkDirectorySync } from '@push-based/utils'; +import { parseCssCustomProperties } from '@push-based/styles-ast-utils'; +import type { TokensConfig } from '../../../../validation/angular-mcp-server-options.schema.js'; +import { + type TokenEntry, + type TokenScope, + type TokenDataset, + TokenDatasetImpl, + createEmptyTokenDataset, +} from './token-dataset.js'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export interface TokenDatasetLoaderOptions { + generatedStylesRoot: string; + workspaceRoot: string; + tokens: TokensConfig; +} + +/** + * Discovers token files, parses them, categorises tokens, and returns + * an immutable queryable TokenDataset. + */ +export async function loadTokenDataset( + options: TokenDatasetLoaderOptions, +): Promise { + const { generatedStylesRoot, workspaceRoot, tokens } = options; + + const absRoot = path.resolve(workspaceRoot, generatedStylesRoot); + + // Guard: root must exist and be a directory + if (!isReadableDirectory(absRoot)) { + return createEmptyTokenDataset( + `generatedStylesRoot '${generatedStylesRoot}' does not exist or is not a readable directory`, + ); + } + + // Discover files + const files = discoverFiles(absRoot, tokens.filePattern); + + if (files.length === 0) { + return createEmptyTokenDataset( + `No files matched pattern '${tokens.filePattern}' in '${generatedStylesRoot}'`, + ); + } + + // Determine effective scope strategy + const strategy = resolveScopeStrategy(tokens.scopeStrategy); + + // Parse and build tokens + const allTokens: TokenEntry[] = []; + + for (const filePath of files) { + const scope = computeScope(strategy, filePath, absRoot); + const properties = await parseCssCustomProperties(filePath, { + propertyPrefix: tokens.propertyPrefix, + }); + + for (const [name, value] of properties) { + const category = categoriseToken( + name, + value, + tokens.categoryInference, + tokens.categoryPrefixMap, + ); + + allTokens.push({ + name, + value, + category, + scope, + sourceFile: path.relative(workspaceRoot, filePath), + }); + } + } + + return new TokenDatasetImpl(allTokens); +} + +// Re-export for convenience +export { createEmptyTokenDataset } from './token-dataset.js'; + +// --------------------------------------------------------------------------- +// File Discovery +// --------------------------------------------------------------------------- + +/** + * Discovers files matching a glob-like pattern under the given root. + */ +function discoverFiles(absRoot: string, filePattern: string): string[] { + const allFiles = walkDirectorySync(absRoot); + const regex = globToRegex(filePattern); + return allFiles + .filter((f) => regex.test(path.relative(absRoot, f).replace(/\\/g, '/'))) + .sort(); +} + +// --------------------------------------------------------------------------- +// Directory Strategy +// --------------------------------------------------------------------------- + +type EffectiveStrategy = 'flat' | 'brand-theme'; + +function resolveScopeStrategy( + configured: TokensConfig['scopeStrategy'], +): EffectiveStrategy { + return configured; +} + +function computeScope( + strategy: EffectiveStrategy, + filePath: string, + absRoot: string, +): TokenScope { + if (strategy === 'flat') return {}; + + // brand-theme: parse path segments relative to root + const rel = path.relative(absRoot, path.dirname(filePath)); + if (rel === '' || rel === '.') return {}; + + const segments = rel.split(path.sep); + const scope: TokenScope = {}; + + const scopeKeys = ['brand', 'theme']; + for (let i = 0; i < Math.min(segments.length, scopeKeys.length); i++) { + scope[scopeKeys[i]] = segments[i]; + } + + return scope; +} + +// --------------------------------------------------------------------------- +// Categorisation +// --------------------------------------------------------------------------- + +function categoriseToken( + name: string, + value: string, + inference: TokensConfig['categoryInference'], + prefixMap: Record, +): string | undefined { + switch (inference) { + case 'by-prefix': + return categoriseByPrefix(name, prefixMap); + case 'by-value': + return categoriseByValue(value); + case 'none': + return undefined; + } +} + +/** + * Longest prefix match from categoryPrefixMap. + * The map is { category: prefix }, e.g. { color: '--semantic-color' }. + * We find the entry whose prefix is the longest match for the token name. + */ +function categoriseByPrefix( + name: string, + prefixMap: Record, +): string | undefined { + let bestCategory: string | undefined; + let bestLength = 0; + + for (const [category, prefix] of Object.entries(prefixMap)) { + if (name.startsWith(prefix) && prefix.length > bestLength) { + bestCategory = category; + bestLength = prefix.length; + } + } + + return bestCategory; +} + +/** Value-pattern regexes for category inference */ +const VALUE_CATEGORY_PATTERNS: Array<{ pattern: RegExp; category: string }> = [ + // Colors: hex, rgb, rgba, hsl, hsla + { pattern: /^#([0-9a-fA-F]{3,8})$/, category: 'color' }, + { pattern: /^rgba?\s*\(/, category: 'color' }, + { pattern: /^hsla?\s*\(/, category: 'color' }, + // Spacing: px, rem, em values + { pattern: /^-?[\d.]+px$/, category: 'spacing' }, + { pattern: /^-?[\d.]+rem$/, category: 'spacing' }, + { pattern: /^-?[\d.]+em$/, category: 'spacing' }, + // Opacity: percentage + { pattern: /^[\d.]+%$/, category: 'opacity' }, +]; + +function categoriseByValue(value: string): string | undefined { + const trimmed = value.trim(); + for (const { pattern, category } of VALUE_CATEGORY_PATTERNS) { + if (pattern.test(trimmed)) { + return category; + } + } + return undefined; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isReadableDirectory(absPath: string): boolean { + try { + const stat = fs.statSync(absPath); + return stat.isDirectory(); + } catch { + return false; + } +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset.ts new file mode 100644 index 0000000..92b6d6e --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset.ts @@ -0,0 +1,222 @@ +/** + * Token Dataset — queryable data structure for design tokens. + * + * Provides interfaces and an indexed implementation for efficient + * lookup by name, prefix, value, category, and scope. + */ + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +/** + * Scope derived from directory path segments. + * Keys are determined by the scopeStrategy config. + * Empty object for flat strategy. + */ +export interface TokenScope { + [key: string]: string; +} + +/** + * A single design token entry with metadata. + */ +export interface TokenEntry { + /** Full custom property name, e.g. '--semantic-color-primary' */ + name: string; + /** Resolved value string, e.g. '#86b521' or 'var(--other-token)' */ + value: string; + /** Category assigned by inference strategy. Undefined if uncategorised. */ + category?: string; + /** Scope from directory strategy */ + scope: TokenScope; + /** Source file path */ + sourceFile: string; +} + +/** + * Queryable, immutable token dataset. + */ +export interface TokenDataset { + /** True when no tokens were loaded */ + readonly isEmpty: boolean; + /** Diagnostic messages from loading */ + readonly diagnostics: string[]; + /** All loaded tokens */ + readonly tokens: ReadonlyArray; + + /** Lookup by exact token name */ + getByName(name: string): TokenEntry | undefined; + /** Lookup by token name prefix */ + getByPrefix(prefix: string): TokenEntry[]; + /** Reverse lookup: find all tokens resolving to the given value */ + getByValue(value: string): TokenEntry[]; + /** Lookup by category */ + getByCategory(category: string): TokenEntry[]; + /** Lookup by scope: returns tokens matching all provided key-value pairs */ + getByScope(scope: Record): TokenEntry[]; + /** Scope-filtered reverse value lookup */ + getByValueInScope(value: string, scope: Record): TokenEntry[]; + /** Scope-filtered category lookup */ + getByCategoryInScope( + category: string, + scope: Record, + ): TokenEntry[]; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Indexed implementation of {@link TokenDataset}. + * + * Builds four internal indexes at construction time: + * - `byName` — Map (O(1) lookup) + * - `byValue` — Map (O(1) lookup) + * - `byCategory` — Map (O(1) lookup) + * - `byScopeKey` — Map> (O(k) lookup) + * + * `getByPrefix()` performs a linear scan (O(n)). + * + * Note: individual TokenEntry objects are not deep-frozen for performance. + * ReadonlyArray typing prevents structural mutation at compile time. + * Deep-freeze can be added if consumers require runtime immutability guarantees. + */ +export class TokenDatasetImpl implements TokenDataset { + readonly isEmpty: boolean; + readonly diagnostics: string[]; + readonly tokens: ReadonlyArray; + + private readonly byName: Map; + private readonly byValue: Map; + private readonly byCategory: Map; + private readonly byScopeKey: Map>; + + constructor(tokens: TokenEntry[], diagnostics: string[] = []) { + this.tokens = Object.freeze([...tokens]); + this.diagnostics = Object.freeze([...diagnostics]) as string[]; + this.isEmpty = tokens.length === 0; + + // Build indexes + this.byName = new Map(); + this.byValue = new Map(); + this.byCategory = new Map(); + this.byScopeKey = new Map(); + + for (const token of tokens) { + // by name (last-write wins for duplicates) + this.byName.set(token.name, token); + + // by value + const valueList = this.byValue.get(token.value); + if (valueList) { + valueList.push(token); + } else { + this.byValue.set(token.value, [token]); + } + + // by category + if (token.category != null) { + const catList = this.byCategory.get(token.category); + if (catList) { + catList.push(token); + } else { + this.byCategory.set(token.category, [token]); + } + } + + // by scope key → value + for (const [key, val] of Object.entries(token.scope)) { + let keyMap = this.byScopeKey.get(key); + if (!keyMap) { + keyMap = new Map(); + this.byScopeKey.set(key, keyMap); + } + const scopeList = keyMap.get(val); + if (scopeList) { + scopeList.push(token); + } else { + keyMap.set(val, [token]); + } + } + } + } + + // -- Query methods -------------------------------------------------------- + + getByName(name: string): TokenEntry | undefined { + return this.byName.get(name); + } + + getByPrefix(prefix: string): TokenEntry[] { + return this.tokens.filter((t) => t.name.startsWith(prefix)); + } + + getByValue(value: string): TokenEntry[] { + return [...(this.byValue.get(value) ?? [])]; + } + + getByCategory(category: string): TokenEntry[] { + return [...(this.byCategory.get(category) ?? [])]; + } + + getByScope(scope: Record): TokenEntry[] { + const entries = Object.entries(scope); + if (entries.length === 0) { + return [...this.tokens]; + } + + // Start with the first key-value pair, then intersect + let result: Set | undefined; + + for (const [key, val] of entries) { + const keyMap = this.byScopeKey.get(key); + const matching = keyMap?.get(val) ?? []; + const matchSet = new Set(matching); + + if (result === undefined) { + result = matchSet; + } else { + // Intersect + for (const token of result) { + if (!matchSet.has(token)) { + result.delete(token); + } + } + } + } + + return result ? [...result] : []; + } + + getByValueInScope( + value: string, + scope: Record, + ): TokenEntry[] { + const byVal = this.getByValue(value); + const byScope = new Set(this.getByScope(scope)); + return byVal.filter((t) => byScope.has(t)); + } + + getByCategoryInScope( + category: string, + scope: Record, + ): TokenEntry[] { + const byCat = this.getByCategory(category); + const byScope = new Set(this.getByScope(scope)); + return byCat.filter((t) => byScope.has(t)); + } +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Creates an empty {@link TokenDataset}, optionally with a diagnostic message. + */ +export function createEmptyTokenDataset(diagnostic?: string): TokenDataset { + const diagnostics = diagnostic ? [diagnostic] : []; + return new TokenDatasetImpl([], diagnostics); +} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/violation-analysis/coverage-analyzer.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/violation-analysis/coverage-analyzer.ts index 150146b..ced8891 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/violation-analysis/coverage-analyzer.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/violation-analysis/coverage-analyzer.ts @@ -1,5 +1,6 @@ import { dsComponentCoveragePlugin } from '@push-based/ds-component-coverage'; import * as process from 'node:process'; +import { globToRegex } from '@push-based/utils'; import { validateDsComponentsArray } from '../../../../validation/ds-components-file-loader.validation.js'; import { ReportCoverageParams, @@ -39,31 +40,9 @@ function normalizeExcludePatterns( } /** - * Converts a glob pattern to a regular expression. - * Supports: *, **, ? + * Converts a glob pattern to a regular expression — delegates to shared utility. + * Local `validateGlobPattern` kept for domain-specific error messaging. */ -function globToRegex(pattern: string): RegExp { - let regexPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\?/g, '[^/]') - .replace(/\*\*/g, '') - .replace(/\*/g, '[^/]*') - .replace(//g, '.*'); - - if (pattern.startsWith('**/')) { - regexPattern = regexPattern.replace(/^\.\*\//, ''); - regexPattern = `^(?:.*\\/)?${regexPattern}`; - } else { - regexPattern = `^${regexPattern}`; - } - - if (!regexPattern.endsWith('$')) { - regexPattern = `${regexPattern}$`; - } - - return new RegExp(regexPattern); -} - function validateGlobPattern(pattern: string): void { try { globToRegex(pattern); 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 acbfd04..8c2b3d4 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 @@ -4,6 +4,36 @@ import * as path from 'path'; const isAbsolutePath = (val: string) => path.isAbsolute(val); const isRelativePath = (val: string) => !path.isAbsolute(val); +export const TokensConfigSchema = z + .object({ + filePattern: z.string().default('**/semantic.css'), + propertyPrefix: z.string().nullable().default(null), + /** + * How directory structure under generatedStylesRoot maps to token scope metadata. + * - 'flat': All token files are treated as a single set. Tokens get no scope metadata (scope: {}). + * Use when tokens are not organised by brand or theme. + * - 'brand-theme': Path segments relative to generatedStylesRoot are mapped to scope keys. + * First segment → 'brand', second segment → 'theme'. + * Example: generatedStylesRoot/acme/dark/semantic.css → scope: { brand: 'acme', theme: 'dark' }. + * Use when tokens are organised in a {brand}/{theme}/ directory layout. + */ + scopeStrategy: z.enum(['flat', 'brand-theme']).default('flat'), + categoryInference: z + .enum(['by-prefix', 'by-value', 'none']) + .default('by-prefix'), + categoryPrefixMap: z.record(z.string(), z.string()).default({ + color: '--semantic-color', + spacing: '--semantic-spacing', + radius: '--semantic-radius', + typography: '--semantic-typography', + size: '--semantic-size', + opacity: '--semantic-opacity', + }), + }) + .default({}); + +export type TokensConfig = z.infer; + export const AngularMcpServerOptionsSchema = z.object({ workspaceRoot: z.string().refine(isAbsolutePath, { message: @@ -28,6 +58,14 @@ export const AngularMcpServerOptionsSchema = z.object({ message: 'ds.uiRoot must be a relative path from workspace root to the components folder (e.g., path/to/components)', }), + generatedStylesRoot: z + .string() + .optional() + .refine((val) => val === undefined || isRelativePath(val), { + message: + 'ds.generatedStylesRoot must be a relative path from workspace root', + }), + tokens: TokensConfigSchema, }), }); diff --git a/packages/angular-mcp-server/src/lib/validation/file-existence.ts b/packages/angular-mcp-server/src/lib/validation/file-existence.ts index f61f702..361c183 100644 --- a/packages/angular-mcp-server/src/lib/validation/file-existence.ts +++ b/packages/angular-mcp-server/src/lib/validation/file-existence.ts @@ -4,7 +4,7 @@ import { AngularMcpServerOptions } from './angular-mcp-server-options.schema.js' export function validateAngularMcpServerFilesExist( config: AngularMcpServerOptions, -) { +): AngularMcpServerOptions { const root = config.workspaceRoot; if (!fs.existsSync(root)) { @@ -40,4 +40,27 @@ export function validateAngularMcpServerFilesExist( missingFiles.join('\n'), ); } + + // Validate generatedStylesRoot separately: warn and continue (never throw) + let result = config; + if (config.ds.generatedStylesRoot) { + const absPath = path.resolve(root, config.ds.generatedStylesRoot); + let isValidDir = false; + try { + isValidDir = fs.statSync(absPath).isDirectory(); + } catch { + // permission error, TOCTOU race, etc. + } + if (!isValidDir) { + console.warn( + `ds.generatedStylesRoot resolved to '${absPath}' which does not exist or is not a directory. Token features will be disabled.`, + ); + result = { + ...config, + ds: { ...config.ds, generatedStylesRoot: undefined }, + }; + } + } + + return result; } diff --git a/packages/angular-mcp-server/src/lib/validation/spec/config-schema-and-bootstrap.spec.ts b/packages/angular-mcp-server/src/lib/validation/spec/config-schema-and-bootstrap.spec.ts new file mode 100644 index 0000000..3e24fd6 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/validation/spec/config-schema-and-bootstrap.spec.ts @@ -0,0 +1,404 @@ +/* eslint-disable prefer-const */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +let existsSyncMock: any; +let statSyncMock: any; + +vi.mock('node:fs', () => ({ + get existsSync() { + return existsSyncMock; + }, + get statSync() { + return statSyncMock; + }, +})); + +import { + AngularMcpServerOptionsSchema, + TokensConfigSchema, +} from '../angular-mcp-server-options.schema.js'; +import { validateAngularMcpServerFilesExist } from '../file-existence.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal valid config that satisfies the existing schema (no new fields). */ +function baseConfig(overrides: Record = {}) { + return { + workspaceRoot: '/workspace', + ds: { + uiRoot: 'packages/ui', + ...overrides, + }, + }; +} + +/** Build a full parsed config object for bootstrap validation tests. */ +function parsedConfig(dsOverrides: Record = {}) { + const raw = baseConfig(dsOverrides); + return AngularMcpServerOptionsSchema.parse(raw); +} + +// --------------------------------------------------------------------------- +// Unit Tests — Config Schema +// --------------------------------------------------------------------------- + +describe('AngularMcpServerOptionsSchema', () => { + // ---- Backward compatibility (Req 11.1) ---- + describe('backward compatibility', () => { + it('accepts existing config without any new fields', () => { + const result = AngularMcpServerOptionsSchema.safeParse(baseConfig()); + expect(result.success).toBe(true); + }); + + it('accepts config with optional storybookDocsRoot and deprecatedCssClassesPath', () => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ + storybookDocsRoot: 'docs/storybook', + deprecatedCssClassesPath: 'config/deprecated.js', + }), + ); + expect(result.success).toBe(true); + }); + }); + + // ---- generatedStylesRoot path validation (Req 1.1, 1.2) ---- + describe('ds.generatedStylesRoot', () => { + it('accepts a relative path', () => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ generatedStylesRoot: 'dist/styles' }), + ); + expect(result.success).toBe(true); + }); + + it('accepts undefined (field is optional)', () => { + const result = AngularMcpServerOptionsSchema.safeParse(baseConfig()); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.ds.generatedStylesRoot).toBeUndefined(); + } + }); + + it('rejects an absolute path', () => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ generatedStylesRoot: '/absolute/path' }), + ); + expect(result.success).toBe(false); + }); + }); + + // ---- TokensConfigSchema defaults (Req 2.1–2.10) ---- + describe('ds.tokens defaults', () => { + it('defaults the entire tokens block when not provided', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens).toBeDefined(); + }); + + it('defaults filePattern to **/semantic.css', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens.filePattern).toBe('**/semantic.css'); + }); + + it('defaults propertyPrefix to null', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens.propertyPrefix).toBeNull(); + }); + + it('defaults scopeStrategy to flat', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens.scopeStrategy).toBe('flat'); + }); + + it('defaults categoryInference to by-prefix', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens.categoryInference).toBe('by-prefix'); + }); + + it('defaults categoryPrefixMap with all expected entries', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + const map = result.ds.tokens.categoryPrefixMap; + expect(map).toEqual({ + color: '--semantic-color', + spacing: '--semantic-spacing', + radius: '--semantic-radius', + typography: '--semantic-typography', + size: '--semantic-size', + opacity: '--semantic-opacity', + }); + }); + }); + + // ---- scopeStrategy enum validation (Req 2.6) ---- + describe('ds.tokens.scopeStrategy enum', () => { + it.each(['flat', 'brand-theme'] as const)( + 'accepts valid value: %s', + (strategy) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ tokens: { scopeStrategy: strategy } }), + ); + expect(result.success).toBe(true); + }, + ); + + it.each(['invalid', 'FLAT', 'brandTheme', '', 'auto'])( + 'rejects invalid value: %s', + (strategy) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ tokens: { scopeStrategy: strategy } }), + ); + expect(result.success).toBe(false); + }, + ); + }); + + // ---- categoryInference enum validation (Req 2.8) ---- + describe('ds.tokens.categoryInference enum', () => { + it.each(['by-prefix', 'by-value', 'none'] as const)( + 'accepts valid value: %s', + (inference) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ tokens: { categoryInference: inference } }), + ); + expect(result.success).toBe(true); + }, + ); + + it.each(['invalid', 'BY-PREFIX', 'byValue', ''])( + 'rejects invalid value: %s', + (inference) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ tokens: { categoryInference: inference } }), + ); + expect(result.success).toBe(false); + }, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — Bootstrap Validation (file-existence.ts) +// --------------------------------------------------------------------------- + +describe('validateAngularMcpServerFilesExist — generatedStylesRoot', () => { + beforeEach(() => { + existsSyncMock = vi.fn().mockReturnValue(true); + statSyncMock = vi.fn().mockReturnValue({ isDirectory: () => true }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('passes through config when generatedStylesRoot is not provided', () => { + const config = parsedConfig(); + const result = validateAngularMcpServerFilesExist(config); + expect(result.ds.generatedStylesRoot).toBeUndefined(); + }); + + it('keeps generatedStylesRoot when path exists and is a directory', () => { + const config = parsedConfig({ generatedStylesRoot: 'dist/styles' }); + const result = validateAngularMcpServerFilesExist(config); + expect(result.ds.generatedStylesRoot).toBe('dist/styles'); + }); + + it('sets generatedStylesRoot to undefined and warns when path does not exist', () => { + statSyncMock = vi.fn((p: string) => { + // workspace root exists, but the generatedStylesRoot does not + if (typeof p === 'string' && p.includes('dist/styles')) { + throw new Error('ENOENT: no such file or directory'); + } + return { isDirectory: () => true }; + }); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = parsedConfig({ generatedStylesRoot: 'dist/styles' }); + const result = validateAngularMcpServerFilesExist(config); + + expect(result.ds.generatedStylesRoot).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('does not exist or is not a directory'), + ); + warnSpy.mockRestore(); + }); + + it('sets generatedStylesRoot to undefined when path exists but is not a directory', () => { + statSyncMock = vi.fn().mockReturnValue({ isDirectory: () => false }); + + // eslint-disable-next-line @typescript-eslint/no-empty-function + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const config = parsedConfig({ generatedStylesRoot: 'dist/styles' }); + const result = validateAngularMcpServerFilesExist(config); + + expect(result.ds.generatedStylesRoot).toBeUndefined(); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); +}); + +// --------------------------------------------------------------------------- +// Property-Based Tests (parameterised) +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 1.1, 1.2** + * Property 1: Config schema accepts relative paths and rejects absolute paths + */ +describe('Property 1: generatedStylesRoot path validation', () => { + const relativePaths = [ + 'dist/styles', + 'packages/ui/generated', + './relative/path', + 'single-segment', + 'a/b/c/d/e', + 'path with spaces/child', + '../parent-relative', + ]; + + const absolutePaths = ['/absolute/path', '/usr/local/styles', '/a', '/root']; + + it.each(relativePaths)('accepts relative path: %s', (relPath) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ generatedStylesRoot: relPath }), + ); + expect(result.success).toBe(true); + }); + + it.each(absolutePaths)('rejects absolute path: %s', (absPath) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ generatedStylesRoot: absPath }), + ); + expect(result.success).toBe(false); + }); +}); + +/** + * **Validates: Requirements 2.6** + * Property 2: Config schema validates scopeStrategy enum + */ +describe('Property 2: scopeStrategy enum validation', () => { + const validValues = ['flat', 'brand-theme']; + const invalidValues = [ + 'invalid', + 'FLAT', + 'Brand-Theme', + 'AUTO', + 'auto', + 'none', + 'custom', + '', + 'flat ', + ' auto', + 'brand_theme', + 'brandTheme', + ]; + + it.each(validValues)('accepts valid scopeStrategy: %s', (value) => { + const result = TokensConfigSchema.safeParse({ scopeStrategy: value }); + expect(result.success).toBe(true); + }); + + it.each(invalidValues)('rejects invalid scopeStrategy: %s', (value) => { + const result = TokensConfigSchema.safeParse({ scopeStrategy: value }); + expect(result.success).toBe(false); + }); +}); + +/** + * **Validates: Requirements 2.8** + * Property 3: Config schema validates categoryInference enum + */ +describe('Property 3: categoryInference enum validation', () => { + const validValues = ['by-prefix', 'by-value', 'none']; + const invalidValues = [ + 'invalid', + 'BY-PREFIX', + 'By-Value', + 'NONE', + 'flat', + 'custom', + '', + 'by_prefix', + 'byPrefix', + 'by-prefix ', + ' none', + ]; + + it.each(validValues)('accepts valid categoryInference: %s', (value) => { + const result = TokensConfigSchema.safeParse({ categoryInference: value }); + expect(result.success).toBe(true); + }); + + it.each(invalidValues)('rejects invalid categoryInference: %s', (value) => { + const result = TokensConfigSchema.safeParse({ categoryInference: value }); + expect(result.success).toBe(false); + }); +}); + +/** + * **Validates: Requirements 11.1** + * Property 21: Backward-compatible config parsing + */ +describe('Property 21: Backward-compatible config parsing', () => { + const existingConfigs = [ + { + label: 'minimal config (only required fields)', + config: { + workspaceRoot: '/workspace', + ds: { uiRoot: 'packages/ui' }, + }, + }, + { + label: 'config with storybookDocsRoot', + config: { + workspaceRoot: '/workspace', + ds: { + uiRoot: 'packages/ui', + storybookDocsRoot: 'docs/storybook', + }, + }, + }, + { + label: 'config with deprecatedCssClassesPath', + config: { + workspaceRoot: '/workspace', + ds: { + uiRoot: 'packages/ui', + deprecatedCssClassesPath: 'config/deprecated.js', + }, + }, + }, + { + label: 'config with all existing optional fields', + config: { + workspaceRoot: '/workspace', + ds: { + uiRoot: 'packages/ui', + storybookDocsRoot: 'docs/storybook', + deprecatedCssClassesPath: 'config/deprecated.js', + }, + }, + }, + ]; + + it.each(existingConfigs)('parses successfully: $label', ({ config }) => { + const result = AngularMcpServerOptionsSchema.safeParse(config); + expect(result.success).toBe(true); + }); + + it.each(existingConfigs)( + 'produces valid defaults for new fields: $label', + ({ config }) => { + const parsed = AngularMcpServerOptionsSchema.parse(config); + // generatedStylesRoot should be undefined (not provided) + expect(parsed.ds.generatedStylesRoot).toBeUndefined(); + // tokens block should have all defaults + expect(parsed.ds.tokens).toBeDefined(); + expect(parsed.ds.tokens.filePattern).toBe('**/semantic.css'); + expect(parsed.ds.tokens.propertyPrefix).toBeNull(); + expect(parsed.ds.tokens.scopeStrategy).toBe('flat'); + expect(parsed.ds.tokens.categoryInference).toBe('by-prefix'); + }, + ); +}); diff --git a/packages/angular-mcp/src/main.ts b/packages/angular-mcp/src/main.ts index dd919f2..0e87201 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -37,6 +37,30 @@ const argv = yargs(hideBin(process.argv)) 'The root directory of the actual Angular components relative from workspace root', type: 'string', }) + .option('ds.generatedStylesRoot', { + describe: 'Path to generated styles directory relative from workspace root', + type: 'string', + }) + .option('ds.tokens.filePattern', { + describe: + 'Glob pattern used to discover token CSS files within ds.generatedStylesRoot (default: "**/semantic.css")', + type: 'string', + }) + .option('ds.tokens.propertyPrefix', { + describe: + 'When set, only CSS custom properties whose name starts with this prefix are extracted. When omitted all custom properties are included (default: null)', + type: 'string', + }) + .option('ds.tokens.scopeStrategy', { + describe: + 'How directory structure maps to token scope. "flat": no scope metadata. "brand-theme": path segments map to brand/theme scope keys (default: "flat")', + type: 'string', + }) + .option('ds.tokens.categoryInference', { + describe: + 'Strategy for categorising tokens. "by-prefix" uses longest prefix match from categoryPrefixMap, "by-value" infers category from the CSS value pattern (hex→color, px→spacing, etc.), "none" skips categorisation (default: "by-prefix")', + type: 'string', + }) .option('sse', { describe: 'Configure the server to use SSE (Server-Sent Events)', type: 'boolean', @@ -64,9 +88,22 @@ const { workspaceRoot, ds } = argv as unknown as { storybookDocsRoot?: string; deprecatedCssClassesPath?: string; uiRoot: string; + generatedStylesRoot?: string; + tokens?: { + filePattern?: string; + propertyPrefix?: string; + scopeStrategy?: string; + categoryInference?: string; + }; }; }; -const { storybookDocsRoot, deprecatedCssClassesPath, uiRoot } = ds; +const { + storybookDocsRoot, + deprecatedCssClassesPath, + uiRoot, + generatedStylesRoot, + tokens, +} = ds; async function startServer() { const server = await AngularMcpServerWrapper.create({ @@ -75,8 +112,10 @@ async function startServer() { storybookDocsRoot, deprecatedCssClassesPath, uiRoot, + generatedStylesRoot, + ...(tokens ? { tokens } : {}), }, - }); + } as Parameters[0]); if (argv.sse) { const port = argv.port ?? 9921; diff --git a/packages/shared/styles-ast-utils/src/index.ts b/packages/shared/styles-ast-utils/src/index.ts index 0d84e02..27cf5ba 100644 --- a/packages/shared/styles-ast-utils/src/index.ts +++ b/packages/shared/styles-ast-utils/src/index.ts @@ -3,3 +3,5 @@ export * from './lib/stylesheet.walk.js'; export * from './lib/utils.js'; export * from './lib/stylesheet.visitor.js'; export * from './lib/stylesheet.parse.js'; +export * from './lib/scss-value-parser.js'; +export * from './lib/css-custom-property-parser.js'; diff --git a/packages/shared/styles-ast-utils/src/lib/css-custom-property-parser.ts b/packages/shared/styles-ast-utils/src/lib/css-custom-property-parser.ts new file mode 100644 index 0000000..cfbb72e --- /dev/null +++ b/packages/shared/styles-ast-utils/src/lib/css-custom-property-parser.ts @@ -0,0 +1,53 @@ +import * as fs from 'node:fs'; + +import { parseScssContent } from './scss-value-parser.js'; + +export interface CssCustomPropertyParserOptions { + /** When set, only extract properties whose name starts with this prefix */ + propertyPrefix?: string | null; +} + +/** + * Extracts CSS custom property declarations from CSS/SCSS content string. + * Uses PostCSS AST for proper parsing — handles nesting, comments, and + * multi-line declarations correctly. + */ +export async function extractCustomPropertiesFromContent( + content: string, + options?: CssCustomPropertyParserOptions, +): Promise> { + const result = new Map(); + + const parsed = await parseScssContent(content, 'inline.css'); + + for (const entry of parsed.entries) { + if (!entry.property.startsWith('--')) continue; + + const prefix = options?.propertyPrefix; + if (prefix != null && !entry.property.startsWith(prefix)) { + continue; + } + + result.set(entry.property, entry.value); + } + + return result; +} + +/** + * Extracts CSS custom property declarations from a CSS/SCSS file. + * Returns a Map of property name → resolved value. + * Returns an empty Map if the file cannot be read. + */ +export async function parseCssCustomProperties( + filePath: string, + options?: CssCustomPropertyParserOptions, +): Promise> { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return new Map(); + } + return extractCustomPropertiesFromContent(content, options); +} diff --git a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.spec.ts b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.spec.ts new file mode 100644 index 0000000..998cc6d --- /dev/null +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.spec.ts @@ -0,0 +1,431 @@ +import { describe, expect, it } from 'vitest'; + +import { parseScssContent } from './scss-value-parser.js'; + +// --------------------------------------------------------------------------- +// Unit Tests +// --------------------------------------------------------------------------- + +describe('SCSS Value Parser', () => { + describe('basic property-value extraction', () => { + it('should extract property, value, selector, and line number', async () => { + const scss = `.button { + color: red; + padding: 8px; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(2); + expect(result.entries[0]).toMatchObject({ + property: 'color', + value: 'red', + selector: '.button', + }); + expect(result.entries[0].line).toBe(2); + expect(result.entries[1]).toMatchObject({ + property: 'padding', + value: '8px', + selector: '.button', + line: 3, + }); + }); + }); + + describe('nested selectors', () => { + it('should produce correct selector path for nested rules', async () => { + const scss = `.card { + .header { + font-size: 16px; + } +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + property: 'font-size', + value: '16px', + selector: '.card .header', + }); + }); + + it('should handle deeply nested selectors', async () => { + const scss = `.wrapper { + .card { + .title { + color: blue; + } + } +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + selector: '.wrapper .card .title', + property: 'color', + value: 'blue', + }); + }); + }); + + describe('::ng-deep blocks', () => { + it('should handle ::ng-deep in selector path', async () => { + const scss = `:host { + ::ng-deep { + .inner { + margin: 4px; + } + } +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + property: 'margin', + value: '4px', + selector: ':host ::ng-deep .inner', + }); + }); + }); + + describe(':host context selectors', () => { + it('should handle :host as top-level selector', async () => { + const scss = `:host { + display: block; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + property: 'display', + value: 'block', + selector: ':host', + }); + }); + + it('should handle :host with nested children', async () => { + const scss = `:host { + .content { + padding: 12px; + } +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + selector: ':host .content', + property: 'padding', + value: '12px', + }); + }); + }); + + describe('classification: declaration', () => { + it('should classify any CSS custom property (starting with --) as declaration', async () => { + const scss = `:host { + --ds-button-bg: #ff0000; + --semantic-color-primary: #00ff00; + --anything: 10px; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(3); + expect(result.entries[0].classification).toBe('declaration'); + expect(result.entries[1].classification).toBe('declaration'); + expect(result.entries[2].classification).toBe('declaration'); + }); + }); + + describe('classification: consumption', () => { + it('should classify value containing var(--*) as consumption', async () => { + const scss = `.button { + background: var(--semantic-color-primary); +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].classification).toBe('consumption'); + }); + }); + + describe('classification: plain', () => { + it('should classify regular properties as plain', async () => { + const scss = `.button { + color: red; + padding: 8px; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(2); + expect(result.entries[0].classification).toBe('plain'); + expect(result.entries[1].classification).toBe('plain'); + }); + }); + + describe('query methods', () => { + it('getBySelector returns entries for a specific selector', async () => { + const scss = `.a { color: red; } +.b { color: blue; }`; + const result = await parseScssContent(scss, 'test.scss'); + + const aEntries = result.getBySelector('.a'); + expect(aEntries).toHaveLength(1); + expect(aEntries[0].value).toBe('red'); + }); + + it('getDeclarations returns only declaration entries', async () => { + const scss = `:host { + --ds-btn-bg: #fff; + color: var(--ds-btn-bg); + padding: 8px; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + const declarations = result.getDeclarations(); + expect(declarations).toHaveLength(1); + expect(declarations[0].property).toBe('--ds-btn-bg'); + }); + + it('getConsumptions returns only consumption entries', async () => { + const scss = `:host { + --ds-btn-bg: #fff; + color: var(--ds-btn-bg); + padding: 8px; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + const consumptions = result.getConsumptions(); + expect(consumptions).toHaveLength(1); + expect(consumptions[0].property).toBe('color'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Property-Based Tests (parameterised) +// --------------------------------------------------------------------------- + +/** + * **Validates: Requirements 8.1** + * Property 18: SCSS property extraction round-trip + * + * For any SCSS content with known selectors and property-value declarations, + * parseScssContent SHALL return entries containing every original property-value + * pair with the correct selector and line number. + */ +describe('Property 18: SCSS property extraction round-trip', () => { + const testCases = [ + { + label: 'single selector with multiple properties', + scss: `.button {\n color: red;\n padding: 8px;\n}`, + expected: [ + { selector: '.button', property: 'color', value: 'red', line: 2 }, + { selector: '.button', property: 'padding', value: '8px', line: 3 }, + ], + }, + { + label: 'nested selectors', + scss: `.card {\n .title {\n font-weight: bold;\n }\n}`, + expected: [ + { + selector: '.card .title', + property: 'font-weight', + value: 'bold', + line: 3, + }, + ], + }, + { + label: ':host with nested child', + scss: `:host {\n display: block;\n .inner {\n margin: 0;\n }\n}`, + expected: [ + { selector: ':host', property: 'display', value: 'block', line: 2 }, + { + selector: ':host .inner', + property: 'margin', + value: '0', + line: 4, + }, + ], + }, + { + label: '::ng-deep with nested selector', + scss: `:host {\n ::ng-deep {\n .deep-child {\n color: green;\n }\n }\n}`, + expected: [ + { + selector: ':host ::ng-deep .deep-child', + property: 'color', + value: 'green', + line: 4, + }, + ], + }, + { + label: 'token declarations and consumptions mixed', + scss: `:host {\n --ds-btn-bg: #ff0000;\n background: var(--ds-btn-bg);\n padding: 8px;\n}`, + expected: [ + { + selector: ':host', + property: '--ds-btn-bg', + value: '#ff0000', + line: 2, + }, + { + selector: ':host', + property: 'background', + value: 'var(--ds-btn-bg)', + line: 3, + }, + { selector: ':host', property: 'padding', value: '8px', line: 4 }, + ], + }, + { + label: 'multiple top-level selectors', + scss: `.a {\n color: red;\n}\n.b {\n color: blue;\n}`, + expected: [ + { selector: '.a', property: 'color', value: 'red', line: 2 }, + { selector: '.b', property: 'color', value: 'blue', line: 5 }, + ], + }, + ]; + + it.each(testCases)( + 'round-trips all entries: $label', + async ({ scss, expected }) => { + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(expected.length); + for (let i = 0; i < expected.length; i++) { + expect(result.entries[i]).toMatchObject(expected[i]); + } + }, + ); +}); + +/** + * **Validates: Requirements 9.1, 9.3** + * Property 19: Token declaration classification by CSS custom property syntax + * + * Any CSS custom property (starting with `--`) SHALL be classified as + * 'declaration'. Regular properties SHALL NOT be classified as 'declaration'. + */ +describe('Property 19: Token declaration classification by CSS custom property syntax', () => { + const testCases = [ + { + label: '--ds-button-bg is a declaration', + property: '--ds-button-bg', + value: '#ff0000', + expectedClassification: 'declaration' as const, + }, + { + label: '--semantic-color is a declaration', + property: '--semantic-color', + value: '#ff0000', + expectedClassification: 'declaration' as const, + }, + { + label: '--app-header-bg is a declaration', + property: '--app-header-bg', + value: 'blue', + expectedClassification: 'declaration' as const, + }, + { + label: '--comp-card-radius is a declaration', + property: '--comp-card-radius', + value: '4px', + expectedClassification: 'declaration' as const, + }, + { + label: '--anything is a declaration', + property: '--anything', + value: '10px', + expectedClassification: 'declaration' as const, + }, + { + label: 'color with var() is consumption, not declaration', + property: 'color', + value: 'var(--semantic-color-primary)', + expectedClassification: 'consumption' as const, + }, + { + label: 'padding is plain', + property: 'padding', + value: '8px', + expectedClassification: 'plain' as const, + }, + ]; + + it.each(testCases)( + '$label', + async ({ property, value, expectedClassification }) => { + const scss = `:host {\n ${property}: ${value};\n}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].classification).toBe(expectedClassification); + }, + ); +}); + +/** + * **Validates: Requirements 9.2** + * Property 20: Token consumption classification by var() reference + * + * For any CSS value string, the SCSS Value Parser SHALL classify the entry as + * 'consumption' if and only if the value contains a var(--*) reference. + */ +describe('Property 20: Token consumption classification by var() reference', () => { + const testCases = [ + { + label: 'simple var() reference', + value: 'var(--semantic-color-primary)', + expectedClassification: 'consumption' as const, + }, + { + label: 'var() with fallback', + value: 'var(--color-primary, #000)', + expectedClassification: 'consumption' as const, + }, + { + label: 'var() embedded in calc()', + value: 'calc(var(--spacing-md) * 2)', + expectedClassification: 'consumption' as const, + }, + { + label: 'plain hex color', + value: '#ff0000', + expectedClassification: 'plain' as const, + }, + { + label: 'plain pixel value', + value: '16px', + expectedClassification: 'plain' as const, + }, + { + label: 'plain keyword', + value: 'block', + expectedClassification: 'plain' as const, + }, + { + label: 'plain rgba value', + value: 'rgba(0, 0, 0, 0.5)', + expectedClassification: 'plain' as const, + }, + { + label: 'multiple var() references', + value: 'var(--a) var(--b)', + expectedClassification: 'consumption' as const, + }, + ]; + + it.each(testCases)( + '$label → $expectedClassification', + async ({ value, expectedClassification }) => { + // Use a regular property name so it won't be classified as 'declaration' + const scss = `.test {\n color: ${value};\n}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].classification).toBe(expectedClassification); + }, + ); +}); 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 new file mode 100644 index 0000000..ec8c97a --- /dev/null +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -0,0 +1,143 @@ +import fs from 'node:fs'; +import { Declaration, Root, Rule } from 'postcss'; + +import { parseStylesheet } from './stylesheet.parse.js'; +import { visitEachChild } from './stylesheet.walk.js'; + +/** + * Classification of a CSS property entry: + * - `declaration`: property is a CSS custom property (starts with `--`) + * - `consumption`: value contains a `var(--*)` reference + * - `plain`: neither a declaration nor a consumption + */ +export type ScssClassification = 'declaration' | 'consumption' | 'plain'; + +/** + * Represents a single CSS property-value pair extracted from an SCSS file, + * along with its resolved selector path and classification. + */ +export interface ScssPropertyEntry { + /** CSS property name, e.g. 'color' or '--ds-button-bg' */ + property: string; + /** CSS value, e.g. 'var(--semantic-color-primary)' */ + value: string; + /** 1-based line number in the source file, or -1 if unavailable */ + line: number; + /** Full selector path, e.g. ':host .button' */ + selector: string; + /** Classification of this entry */ + classification: ScssClassification; +} + +/** + * Result of parsing an SCSS file, containing all extracted property entries + * and query methods for filtering. + */ +export interface ScssParseResult { + /** All extracted property entries */ + entries: ScssPropertyEntry[]; + /** Get entries for a specific selector */ + getBySelector(selector: string): ScssPropertyEntry[]; + /** Get only token declarations */ + getDeclarations(): ScssPropertyEntry[]; + /** Get only token consumptions */ + getConsumptions(): ScssPropertyEntry[]; +} + +const VAR_REFERENCE_PATTERN = /var\(\s*--[\w-]+/; + +/** + * Resolves the full selector path for a PostCSS Declaration node + * by walking up through parent Rule nodes. + * Handles nested selectors, `::ng-deep`, and `:host`. + */ +function resolveSelector(node: Declaration): string { + const selectors: string[] = []; + let current = node.parent; + + while (current && current.type === 'rule') { + selectors.unshift((current as Rule).selector); + current = current.parent; + } + + return selectors.join(' ') || ':root'; +} + +/** + * Classifies a CSS property-value pair purely by syntax. + * + * - `declaration`: property is a CSS custom property (starts with `--`) + * - `consumption`: value contains a `var(--*)` reference + * - `plain`: neither + */ +function classifyEntry(property: string, value: string): ScssClassification { + if (property.startsWith('--')) { + return 'declaration'; + } + if (VAR_REFERENCE_PATTERN.test(value)) { + return 'consumption'; + } + return 'plain'; +} + +/** + * Creates a ScssParseResult with query methods from a list of entries. + */ +function createParseResult(entries: ScssPropertyEntry[]): ScssParseResult { + return { + entries, + getBySelector(selector: string): ScssPropertyEntry[] { + return entries.filter((e) => e.selector === selector); + }, + getDeclarations(): ScssPropertyEntry[] { + return entries.filter((e) => e.classification === 'declaration'); + }, + getConsumptions(): ScssPropertyEntry[] { + return entries.filter((e) => e.classification === 'consumption'); + }, + }; +} + +/** + * Parses SCSS content string and extracts CSS property-value pairs per selector. + * Uses PostCSS AST via `parseStylesheet()` and `visitEachChild()`. + */ +export async function parseScssContent( + content: string, + filePath: string, +): Promise { + const entries: ScssPropertyEntry[] = []; + + const result = parseStylesheet(content, filePath); + const root = result.root as Root; + + visitEachChild(root, { + visitDecl(decl: Declaration) { + const selector = resolveSelector(decl); + const property = decl.prop; + const value = decl.value; + const line = decl.source?.start?.line ?? -1; + const classification = classifyEntry(property, value); + + entries.push({ property, value, line, selector, classification }); + }, + }); + + return createParseResult(entries); +} + +/** + * Parses an SCSS file and extracts CSS property-value pairs per selector. + * Reads the file from disk and delegates to `parseScssContent`. + */ +export async function parseScssValues( + filePath: string, +): Promise { + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return createParseResult([]); + } + return parseScssContent(content, filePath); +} diff --git a/packages/shared/utils/src/index.ts b/packages/shared/utils/src/index.ts index 4a73c32..3d17b46 100644 --- a/packages/shared/utils/src/index.ts +++ b/packages/shared/utils/src/index.ts @@ -4,3 +4,4 @@ export * from './lib/logging.js'; export * from './lib/file/find-in-file.js'; export * from './lib/file/file.resolver.js'; export * from './lib/file/default-export-loader.js'; +export * from './lib/file/glob-utils.js'; diff --git a/packages/shared/utils/src/lib/file/glob-utils.ts b/packages/shared/utils/src/lib/file/glob-utils.ts new file mode 100644 index 0000000..fdaa05f --- /dev/null +++ b/packages/shared/utils/src/lib/file/glob-utils.ts @@ -0,0 +1,58 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +/** + * Converts a glob pattern to a regular expression. + * Supports: `*` (single segment), `**` (recursive), `?` (single char) + */ +export function globToRegex(pattern: string): RegExp { + let regexPattern = pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\?/g, '[^/]') + .replace(/\*\*/g, '') + .replace(/\*/g, '[^/]*') + .replace(//g, '.*'); + + if (pattern.startsWith('**/')) { + regexPattern = regexPattern.replace(/^\.\*\//, ''); + regexPattern = `^(?:.*\\/)?${regexPattern}`; + } else { + regexPattern = `^${regexPattern}`; + } + + if (!regexPattern.endsWith('$')) { + regexPattern = `${regexPattern}$`; + } + + return new RegExp(regexPattern); +} + +/** + * Recursively walks a directory synchronously and returns all file paths. + * Silently skips unreadable directories. + */ +export function walkDirectorySync(dir: string): string[] { + const results: string[] = []; + + function walk(currentPath: string) { + try { + const entries = fs.readdirSync(currentPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name); + if (entry.isDirectory()) { + walk(fullPath); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + } catch { + // Silently skip unreadable directories + } + } + + if (fs.existsSync(dir)) { + walk(dir); + } + + return results; +}