From cda878f63c3dc844d7b871f3686375cb223f40db Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Fri, 10 Apr 2026 10:10:23 +0200 Subject: [PATCH 1/6] feat: token infrastructure --- .gitignore | 4 + README.md | 33 +- docs/architecture-internal-design.md | 33 +- docs/getting-started.md | 5 +- package-lock.json | 283 +++---- .../src/lib/angular-mcp-server.ts | 38 +- .../lib/spec/server-token-integration.spec.ts | 147 ++++ .../utils/css-custom-property-parser.ts | 60 ++ .../tools/ds/shared/utils/handler-helpers.ts | 7 + .../tools/ds/shared/utils/regex-helpers.ts | 10 + .../spec/css-custom-property-parser.spec.ts | 395 ++++++++++ .../utils/spec/token-dataset-loader.spec.ts | 634 +++++++++++++++ .../shared/utils/spec/token-dataset.spec.ts | 745 ++++++++++++++++++ .../ds/shared/utils/token-dataset-loader.ts | 297 +++++++ .../tools/ds/shared/utils/token-dataset.ts | 219 +++++ .../angular-mcp-server-options.schema.ts | 34 + .../src/lib/validation/file-existence.ts | 19 +- .../spec/config-schema-and-bootstrap.spec.ts | 431 ++++++++++ packages/angular-mcp/src/main.ts | 24 +- packages/shared/styles-ast-utils/src/index.ts | 1 + .../src/lib/scss-value-parser.spec.ts | 452 +++++++++++ .../src/lib/scss-value-parser.ts | 155 ++++ 22 files changed, 3828 insertions(+), 198 deletions(-) create mode 100644 packages/angular-mcp-server/src/lib/spec/server-token-integration.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/css-custom-property-parser.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/css-custom-property-parser.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset-loader.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset.spec.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts create mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset.ts create mode 100644 packages/angular-mcp-server/src/lib/validation/spec/config-schema-and-bootstrap.spec.ts create mode 100644 packages/shared/styles-ast-utils/src/lib/scss-value-parser.spec.ts create mode 100644 packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts 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..3e1801b 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,27 @@ 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.directoryStrategy` | `flat \| brand-theme \| auto` | `flat` | Controls how the directory tree under `generatedStylesRoot` is interpreted. `flat`: all files belong to a single token set (empty scope). `brand-theme`: path segments map to scope keys (first → `brand`, second → `theme`). `auto`: infers from directory depth (≥ 2 levels → `brand-theme`, otherwise → `flat`). | +| `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. | +| `ds.tokens.componentTokenPrefix` | `string` | `--ds-` | Prefix identifying component-level token declarations in SCSS files. The SCSS Value Parser classifies properties starting with this prefix as `declaration` entries. Change if your components use a different prefix convention (e.g. `--app-`). | + +> **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 +187,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..8e67a68 100644 --- a/docs/architecture-internal-design.md +++ b/docs/architecture-internal-design.md @@ -93,12 +93,28 @@ 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.directoryStrategy` | enum | `flat` | `flat`, `brand-theme`, or `auto`. Controls how directory structure maps to token scope. | +| `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`). | +| `ds.tokens.componentTokenPrefix` | string | `--ds-` | Prefix identifying component token declarations in SCSS. | Validation is handled via **Zod** in `angular-mcp-server-options.schema.ts`. @@ -110,11 +126,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..c689eb1 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/spec/server-token-integration.spec.ts @@ -0,0 +1,147 @@ +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(); + 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', + directoryStrategy: 'brand-theme', + categoryInference: 'by-value', + componentTokenPrefix: '--custom-', + }, + }, + } 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/shared/utils/css-custom-property-parser.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/css-custom-property-parser.ts new file mode 100644 index 0000000..7f4a5f6 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/css-custom-property-parser.ts @@ -0,0 +1,60 @@ +import * as fs from 'node:fs'; + +import { CSS_CUSTOM_PROPERTY_REGEXES } from './regex-helpers.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 content string. + * Strips comments, extracts `--*` declarations via regex, optionally filters by `propertyPrefix`. + */ +export function extractCustomPropertiesFromContent( + content: string, + options?: CssCustomPropertyParserOptions, +): Map { + const result = new Map(); + + // Strip CSS comments + const stripped = content.replace(CSS_CUSTOM_PROPERTY_REGEXES.COMMENT, ''); + + // Extract custom property declarations + const regex = new RegExp( + CSS_CUSTOM_PROPERTY_REGEXES.DECLARATION.source, + CSS_CUSTOM_PROPERTY_REGEXES.DECLARATION.flags, + ); + + let match: RegExpExecArray | null; + while ((match = regex.exec(stripped)) !== null) { + const name = match[1]; + const value = match[2].trim(); + const prefix = options?.propertyPrefix; + + if (prefix != null && !name.startsWith(prefix)) { + continue; + } + + result.set(name, value); + } + + return result; +} + +/** + * Extracts CSS custom property declarations from a CSS file. + * Returns a Map of property name → resolved value. + * Returns an empty Map if the file cannot be read. + */ +export function parseCssCustomProperties( + filePath: string, + options?: CssCustomPropertyParserOptions, +): Map { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + return extractCustomPropertiesFromContent(content, options); + } catch { + return new Map(); + } +} 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/regex-helpers.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts index 1dcc709..0ec3815 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts @@ -100,6 +100,16 @@ export const IMPORT_REGEXES = { ), } as const; +// CSS Custom Property Regexes +export const CSS_CUSTOM_PROPERTY_REGEXES = { + /** Matches CSS comments for stripping */ + COMMENT: /\/\*[\s\S]*?\*\//g, + /** Matches CSS custom property declarations: --name: value; */ + DECLARATION: /(--[\w-]+)\s*:\s*([^;]+);/g, + /** Matches var() references in values */ + VAR_REFERENCE: /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g, +} as const; + // Regex Cache Management const REGEX_CACHE = new Map(); 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..6575d7c --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/css-custom-property-parser.spec.ts @@ -0,0 +1,395 @@ +import { describe, it, expect } from 'vitest'; +import * as path from 'node:path'; + +import { + extractCustomPropertiesFromContent, + parseCssCustomProperties, +} from '../css-custom-property-parser.js'; + +// --------------------------------------------------------------------------- +// 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', () => { + const css = cssRoot(` + --color-primary: #ff0000; + --spacing-sm: 4px; + `); + const result = 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', () => { + const css = cssRoot(` + --gradient-bg: linear-gradient( + to right, + #ff0000, + #00ff00 + ); + --simple: blue; + `); + const result = extractCustomPropertiesFromContent(css); + + // The regex captures everything up to the semicolon + expect(result.has('--gradient-bg')).toBe(true); + expect(result.get('--simple')).toBe('blue'); + }); + + it('strips comments and does not extract properties inside comments', () => { + const css = cssRoot(` + /* --commented-out: should-not-appear; */ + --real-prop: visible; + /* + --multi-line-comment: also-hidden; + --another-hidden: nope; + */ + --another-real: yes; + `); + const result = 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', () => { + const css = cssRoot(` + --color-primary: #86b521; + --button-bg: var(--color-primary); + --button-border: var(--color-primary, #000); + `); + const result = 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', () => { + const result = extractCustomPropertiesFromContent(''); + expect(result.size).toBe(0); + }); + + it('returns empty Map for content with no custom properties', () => { + const css = `body { color: red; font-size: 16px; }`; + const result = extractCustomPropertiesFromContent(css); + expect(result.size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — parseCssCustomProperties (file-based) +// --------------------------------------------------------------------------- + +describe('parseCssCustomProperties', () => { + it('returns empty Map for non-existent file', () => { + const result = 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', () => { + const result = extractCustomPropertiesFromContent(css, { + propertyPrefix: null, + }); + expect(result.size).toBe(5); + }); + + it('returns all properties when propertyPrefix is undefined', () => { + const result = extractCustomPropertiesFromContent(css); + expect(result.size).toBe(5); + }); + + it('filters by prefix --semantic-color', () => { + const result = 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-', () => { + const result = 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', () => { + const result = 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 + * + * For any set of valid CSS custom property declarations, embedding them in a + * CSS :root block and parsing with extractCustomPropertiesFromContent SHALL + * produce a Map containing every original name-value pair. + */ +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', + ({ properties }) => { + const declarations = properties + .map(([name, value]) => ` ${name}: ${value};`) + .join('\n'); + const css = cssRoot(declarations); + + const result = 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 + * + * For any CSS content where custom property patterns appear only inside + * comments, extractCustomPropertiesFromContent SHALL return an empty Map. + */ +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', + ({ css }) => { + const result = extractCustomPropertiesFromContent(css); + expect(result.size).toBe(0); + }, + ); + + // Also verify that real properties adjacent to comments ARE extracted + it('extracts real properties while ignoring commented ones', () => { + const css = ` + /* --hidden: nope; */ + :root { + --visible: yes; + /* --also-hidden: nope; */ + --also-visible: yes; + } + `; + const result = 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 + * + * For any set of custom properties and any non-null propertyPrefix, only + * properties whose name starts with the given prefix SHALL be included. + * When propertyPrefix is null, all properties SHALL be included. + */ +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', + ({ prefix, expectedCount, expectedNames }) => { + const result = 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', () => { + const result = 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', () => { + const result = 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..fb60282 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset-loader.spec.ts @@ -0,0 +1,634 @@ +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 directory 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(), directoryStrategy: '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 directory 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(), directoryStrategy: '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(), directoryStrategy: 'brand-theme' }, + }); + + expect(ds.tokens.length).toBe(1); + expect(ds.tokens[0].scope).toEqual({ brand: 'acme' }); + }); +}); + +// --------------------------------------------------------------------------- +// Unit Tests — auto directory strategy +// --------------------------------------------------------------------------- + +describe('loadTokenDataset — auto directory strategy', () => { + it('infers flat when files are at root level (depth < 2)', async () => { + const stylesDir = makeTempDir('auto-flat'); + writeCssFile( + path.join(stylesDir, 'semantic.css'), + ` --semantic-color-primary: #ff0000;`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), directoryStrategy: 'auto' }, + }); + + expect(ds.isEmpty).toBe(false); + for (const token of ds.tokens) { + expect(token.scope).toEqual({}); + } + }); + + it('infers brand-theme when files are at depth >= 2', async () => { + const stylesDir = makeTempDir('auto-brand-theme'); + writeCssFile( + path.join(stylesDir, 'acme', 'dark', 'semantic.css'), + ` --semantic-color-primary: #111111;`, + ); + + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), directoryStrategy: 'auto' }, + }); + + expect(ds.isEmpty).toBe(false); + expect(ds.tokens[0].scope).toEqual({ brand: 'acme', theme: 'dark' }); + }); +}); + +// --------------------------------------------------------------------------- +// 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 directory strategy produces scopeless tokens + * + * For any set of token files discovered under generatedStylesRoot with + * directoryStrategy set to 'flat', all tokens in the resulting dataset + * SHALL have an empty scope (no brand, no theme). + */ +describe('Property 7: Flat directory 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(), + directoryStrategy: '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 directory strategy assigns correct scope + * + * For any token file at path {generatedStylesRoot}/{segment1}/{segment2}/... + * with directoryStrategy 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 directory 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(), directoryStrategy: '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..4d5593f --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/spec/token-dataset.spec.ts @@ -0,0 +1,745 @@ +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..d285e05 --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts @@ -0,0 +1,297 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { TokensConfig } from '../../../../validation/angular-mcp-server-options.schema.js'; +import { parseCssCustomProperties } from './css-custom-property-parser.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 directory strategy + const strategy = resolveDirectoryStrategy( + tokens.directoryStrategy, + files, + absRoot, + ); + + // Parse and build tokens + const allTokens: TokenEntry[] = []; + + for (const filePath of files) { + const scope = computeScope(strategy, filePath, absRoot); + const properties = 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. + * Supports `**` for recursive directory matching and `*` for single-segment wildcards. + * Uses synchronous fs operations consistent with existing codebase patterns. + */ +function discoverFiles(absRoot: string, filePattern: string): string[] { + const allFiles = walkDirectory(absRoot); + const matcher = createGlobMatcher(filePattern); + return allFiles + .filter((f) => matcher(path.relative(absRoot, f))) + .sort(); +} + +/** + * Recursively walks a directory and returns all file paths. + */ +function walkDirectory(dir: string): string[] { + const results: string[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + results.push(...walkDirectory(fullPath)); + } else if (entry.isFile()) { + results.push(fullPath); + } + } + } catch { + // Silently skip unreadable directories + } + return results; +} + +/** + * Creates a matcher function from a glob-like pattern. + * Converts glob syntax to a RegExp: + * - `**` matches any number of path segments (including zero) + * - `*` matches any characters within a single path segment + * - `.` and other regex specials are escaped + */ +function createGlobMatcher(pattern: string): (filePath: string) => boolean { + // Normalise to forward slashes + const normalised = pattern.replace(/\\/g, '/'); + + // Build regex from glob pattern + let regexStr = ''; + let i = 0; + while (i < normalised.length) { + if (normalised[i] === '*' && normalised[i + 1] === '*') { + // ** matches any path segments + regexStr += '.*'; + i += 2; + // Skip trailing slash after ** + if (normalised[i] === '/') i++; + } else if (normalised[i] === '*') { + // * matches anything except path separator + regexStr += '[^/]*'; + i++; + } else if ('.+?^${}()|[]\\'.includes(normalised[i])) { + // Escape regex special characters + regexStr += '\\' + normalised[i]; + i++; + } else { + regexStr += normalised[i]; + i++; + } + } + + const regex = new RegExp(`^${regexStr}$`); + return (filePath: string) => regex.test(filePath.replace(/\\/g, '/')); +} + +// --------------------------------------------------------------------------- +// Directory Strategy +// --------------------------------------------------------------------------- + +type EffectiveStrategy = 'flat' | 'brand-theme'; + +function resolveDirectoryStrategy( + configured: TokensConfig['directoryStrategy'], + files: string[], + absRoot: string, +): EffectiveStrategy { + if (configured === 'flat') return 'flat'; + if (configured === 'brand-theme') return 'brand-theme'; + + // auto: infer from max directory depth + const maxDepth = computeMaxDepth(files, absRoot); + return maxDepth >= 2 ? 'brand-theme' : 'flat'; +} + +function computeMaxDepth(files: string[], absRoot: string): number { + let max = 0; + for (const filePath of files) { + const rel = path.relative(absRoot, path.dirname(filePath)); + if (rel === '' || rel === '.') continue; + const depth = rel.split(path.sep).length; + if (depth > max) max = depth; + } + return max; +} + +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..e3c85de --- /dev/null +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset.ts @@ -0,0 +1,219 @@ +/** + * 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 directoryStrategy 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 for O(1) / O(k) lookups: + * - `byName` — Map + * - `byValue` — Map + * - `byCategory` — Map + * - `byScopeKey` — Map> + */ +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/validation/angular-mcp-server-options.schema.ts b/packages/angular-mcp-server/src/lib/validation/angular-mcp-server-options.schema.ts index acbfd04..acfcc35 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,32 @@ 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), + directoryStrategy: z + .enum(['flat', 'brand-theme', 'auto']) + .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', + }), + componentTokenPrefix: z.string().default('--ds-'), + }) + .default({}); + +export type TokensConfig = z.infer; + export const AngularMcpServerOptionsSchema = z.object({ workspaceRoot: z.string().refine(isAbsolutePath, { message: @@ -28,6 +54,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..ebbcaef 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,21 @@ 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); + if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) { + 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..d68c4cc --- /dev/null +++ b/packages/angular-mcp-server/src/lib/validation/spec/config-schema-and-bootstrap.spec.ts @@ -0,0 +1,431 @@ +/* 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 directoryStrategy to flat', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens.directoryStrategy).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', + }); + }); + + it('defaults componentTokenPrefix to --ds-', () => { + const result = AngularMcpServerOptionsSchema.parse(baseConfig()); + expect(result.ds.tokens.componentTokenPrefix).toBe('--ds-'); + }); + }); + + // ---- directoryStrategy enum validation (Req 2.6) ---- + describe('ds.tokens.directoryStrategy enum', () => { + it.each(['flat', 'brand-theme', 'auto'] as const)( + 'accepts valid value: %s', + (strategy) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ tokens: { directoryStrategy: strategy } }), + ); + expect(result.success).toBe(true); + }, + ); + + it.each(['invalid', 'FLAT', 'brandTheme', ''])( + 'rejects invalid value: %s', + (strategy) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ tokens: { directoryStrategy: 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', () => { + existsSyncMock = vi.fn((p: string) => { + // workspace root exists, but the generatedStylesRoot does not + if (typeof p === 'string' && p.includes('dist/styles')) return false; + return true; + }); + + 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 }); + + 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 directoryStrategy enum + */ +describe('Property 2: directoryStrategy enum validation', () => { + const validValues = ['flat', 'brand-theme', 'auto']; + const invalidValues = [ + 'invalid', + 'FLAT', + 'Brand-Theme', + 'AUTO', + 'none', + 'custom', + '', + 'flat ', + ' auto', + 'brand_theme', + 'brandTheme', + ]; + + it.each(validValues)( + 'accepts valid directoryStrategy: %s', + (value) => { + const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); + expect(result.success).toBe(true); + }, + ); + + it.each(invalidValues)( + 'rejects invalid directoryStrategy: %s', + (value) => { + const result = TokensConfigSchema.safeParse({ directoryStrategy: 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.directoryStrategy).toBe('flat'); + expect(parsed.ds.tokens.categoryInference).toBe('by-prefix'); + expect(parsed.ds.tokens.componentTokenPrefix).toBe('--ds-'); + }, + ); +}); diff --git a/packages/angular-mcp/src/main.ts b/packages/angular-mcp/src/main.ts index dd919f2..a70f4cc 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -37,6 +37,16 @@ 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', { type: 'string' }) + .option('ds.tokens.propertyPrefix', { type: 'string' }) + .option('ds.tokens.directoryStrategy', { type: 'string' }) + .option('ds.tokens.categoryInference', { type: 'string' }) + .option('ds.tokens.componentTokenPrefix', { type: 'string' }) .option('sse', { describe: 'Configure the server to use SSE (Server-Sent Events)', type: 'boolean', @@ -64,9 +74,17 @@ const { workspaceRoot, ds } = argv as unknown as { storybookDocsRoot?: string; deprecatedCssClassesPath?: string; uiRoot: string; + generatedStylesRoot?: string; + tokens?: { + filePattern?: string; + propertyPrefix?: string; + directoryStrategy?: string; + categoryInference?: string; + componentTokenPrefix?: string; + }; }; }; -const { storybookDocsRoot, deprecatedCssClassesPath, uiRoot } = ds; +const { storybookDocsRoot, deprecatedCssClassesPath, uiRoot, generatedStylesRoot, tokens } = ds; async function startServer() { const server = await AngularMcpServerWrapper.create({ @@ -75,8 +93,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..f437d0b 100644 --- a/packages/shared/styles-ast-utils/src/index.ts +++ b/packages/shared/styles-ast-utils/src/index.ts @@ -3,3 +3,4 @@ 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'; 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..cd5b809 --- /dev/null +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.spec.ts @@ -0,0 +1,452 @@ +import { describe, expect, it } from 'vitest'; + +import { parseScssContent, ScssPropertyEntry } 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 property starting with componentTokenPrefix as declaration', async () => { + const scss = `:host { + --ds-button-bg: #ff0000; +}`; + const result = await parseScssContent(scss, 'test.scss'); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0].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('configurable componentTokenPrefix', () => { + it('should use custom prefix for declaration classification', async () => { + const scss = `:host { + --custom-button-bg: #ff0000; + --ds-button-bg: #00ff00; +}`; + const result = await parseScssContent(scss, 'test.scss', { + componentTokenPrefix: '--custom-', + }); + + const customEntry = result.entries.find( + (e) => e.property === '--custom-button-bg', + ); + const dsEntry = result.entries.find( + (e) => e.property === '--ds-button-bg', + ); + + expect(customEntry?.classification).toBe('declaration'); + expect(dsEntry?.classification).not.toBe('declaration'); + }); + }); + + 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 configurable prefix + * + * For any CSS property name and any componentTokenPrefix, the SCSS Value Parser + * SHALL classify the property as 'declaration' if and only if the property name + * starts with the configured prefix. + */ +describe('Property 19: Token declaration classification by configurable prefix', () => { + const testCases = [ + { + label: 'default prefix --ds- matches --ds-button-bg', + prefix: '--ds-', + property: '--ds-button-bg', + value: '#ff0000', + expectedClassification: 'declaration' as const, + }, + { + label: 'default prefix --ds- does not match --semantic-color', + prefix: '--ds-', + property: '--semantic-color', + value: '#ff0000', + expectedClassification: 'plain' as const, + }, + { + label: 'custom prefix --app- matches --app-header-bg', + prefix: '--app-', + property: '--app-header-bg', + value: 'blue', + expectedClassification: 'declaration' as const, + }, + { + label: 'custom prefix --app- does not match --ds-button-bg', + prefix: '--app-', + property: '--ds-button-bg', + value: 'red', + expectedClassification: 'plain' as const, + }, + { + label: 'prefix --comp- matches --comp-card-radius', + prefix: '--comp-', + property: '--comp-card-radius', + value: '4px', + expectedClassification: 'declaration' as const, + }, + { + label: 'empty-ish prefix -- matches any custom property', + prefix: '--', + property: '--anything', + value: '10px', + expectedClassification: 'declaration' as const, + }, + ]; + + it.each(testCases)( + '$label', + async ({ prefix, property, value, expectedClassification }) => { + const scss = `:host {\n ${property}: ${value};\n}`; + const result = await parseScssContent(scss, 'test.scss', { + componentTokenPrefix: prefix, + }); + + 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..261e8ec --- /dev/null +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -0,0 +1,155 @@ +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 name starts with the configured componentTokenPrefix (token declaration) + * - `consumption`: value contains a `var(--*)` reference (token consumption) + * - `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 */ + 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[]; +} + +/** + * Options for the SCSS Value Parser. + */ +export interface ScssValueParserOptions { + /** Prefix for component token declarations. Default: '--ds-' */ + componentTokenPrefix?: string; +} + +const DEFAULT_COMPONENT_TOKEN_PREFIX = '--ds-'; +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 based on the configured prefix. + * + * - `declaration`: property name starts with componentTokenPrefix + * - `consumption`: value contains a `var(--*)` reference + * - `plain`: neither + */ +function classifyEntry( + property: string, + value: string, + componentTokenPrefix: string, +): ScssClassification { + if (property.startsWith(componentTokenPrefix)) { + 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, + options?: ScssValueParserOptions, +): Promise { + const componentTokenPrefix = + options?.componentTokenPrefix ?? DEFAULT_COMPONENT_TOKEN_PREFIX; + 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 ?? 0; + const classification = classifyEntry(property, value, componentTokenPrefix); + + 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, + options?: ScssValueParserOptions, +): Promise { + const content = fs.readFileSync(filePath, 'utf-8'); + return parseScssContent(content, filePath, options); +} From 26029d9a7913a19d069e4f9d2a386b4ff5b8e6d8 Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Fri, 10 Apr 2026 11:54:47 +0200 Subject: [PATCH 2/6] fix: test lint --- .cursor/flows/README.md | 95 --------- .../01-review-component.mdc | 66 ------ .../02-refactor-component.mdc | 55 ----- .../03-validate-component.mdc | 69 ------ .../flows/component-refactoring/angular-20.md | 131 ------------ .../01-find-violations.mdc | 90 -------- .../01b-find-all-violations.mdc | 79 ------- .../02-plan-refactoring.mdc | 81 ------- ...2b-plan-refactoring-for-all-violations.mdc | 37 ---- .../ds-refactoring-flow/03-fix-violations.mdc | 49 ----- .../03-non-viable-cases.mdc | 100 --------- .../04-validate-changes.mdc | 67 ------ .../ds-refactoring-flow/05-prepare-report.mdc | 64 ------ .../clean-global-styles.mdc | 43 ---- .cursor/mcp.json.example | 22 -- .../lib/spec/server-token-integration.spec.ts | 12 +- .../spec/css-custom-property-parser.spec.ts | 61 +++--- .../utils/spec/token-dataset-loader.spec.ts | 197 +++++++++++++----- .../shared/utils/spec/token-dataset.spec.ts | 195 ++++++++++++++--- .../ds/shared/utils/token-dataset-loader.ts | 4 +- .../tools/ds/shared/utils/token-dataset.ts | 5 +- .../angular-mcp-server-options.schema.ts | 22 +- .../spec/config-schema-and-bootstrap.spec.ts | 94 ++++----- packages/angular-mcp/src/main.ts | 11 +- .../src/lib/scss-value-parser.spec.ts | 2 +- .../src/lib/scss-value-parser.ts | 6 +- 26 files changed, 404 insertions(+), 1253 deletions(-) delete mode 100644 .cursor/flows/README.md delete mode 100644 .cursor/flows/component-refactoring/01-review-component.mdc delete mode 100644 .cursor/flows/component-refactoring/02-refactor-component.mdc delete mode 100644 .cursor/flows/component-refactoring/03-validate-component.mdc delete mode 100644 .cursor/flows/component-refactoring/angular-20.md delete mode 100644 .cursor/flows/ds-refactoring-flow/01-find-violations.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/01b-find-all-violations.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/02-plan-refactoring.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/02b-plan-refactoring-for-all-violations.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/03-fix-violations.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/03-non-viable-cases.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/04-validate-changes.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/05-prepare-report.mdc delete mode 100644 .cursor/flows/ds-refactoring-flow/clean-global-styles.mdc delete mode 100644 .cursor/mcp.json.example 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/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 index c689eb1..f7f7b50 100644 --- 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 @@ -14,7 +14,10 @@ function createTmpDir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), 'mcp-server-test-')); } -function setupWorkspace(options?: { generatedStylesRoot?: string; cssContent?: string }) { +function setupWorkspace(options?: { + generatedStylesRoot?: string; + cssContent?: string; +}) { tmpDir = createTmpDir(); // Create the required uiRoot directory @@ -27,7 +30,11 @@ function setupWorkspace(options?: { generatedStylesRoot?: string; cssContent?: s fs.mkdirSync(stylesDir, { recursive: true }); if (options.cssContent) { - fs.writeFileSync(path.join(stylesDir, 'semantic.css'), options.cssContent, 'utf-8'); + fs.writeFileSync( + path.join(stylesDir, 'semantic.css'), + options.cssContent, + 'utf-8', + ); } } @@ -90,6 +97,7 @@ describe('Server bootstrap with token config (integration)', () => { // ---- 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({ 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 index 6575d7c..3b8de4e 100644 --- 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 @@ -217,22 +217,19 @@ describe('Property 4: CSS custom property parsing round-trip', () => { }, ]; - it.each(testCases)( - 'round-trips all properties: $label', - ({ properties }) => { - const declarations = properties - .map(([name, value]) => ` ${name}: ${value};`) - .join('\n'); - const css = cssRoot(declarations); - - const result = extractCustomPropertiesFromContent(css); - - for (const [name, value] of properties) { - expect(result.get(name)).toBe(value); - } - expect(result.size).toBe(properties.length); - }, - ); + it.each(testCases)('round-trips all properties: $label', ({ properties }) => { + const declarations = properties + .map(([name, value]) => ` ${name}: ${value};`) + .join('\n'); + const css = cssRoot(declarations); + + const result = extractCustomPropertiesFromContent(css); + + for (const [name, value] of properties) { + expect(result.get(name)).toBe(value); + } + expect(result.size).toBe(properties.length); + }); }); /** @@ -274,13 +271,10 @@ describe('Property 5: CSS parser ignores comments', () => { }, ]; - it.each(commentOnlyCases)( - 'returns empty Map: $label', - ({ css }) => { - const result = extractCustomPropertiesFromContent(css); - expect(result.size).toBe(0); - }, - ); + it.each(commentOnlyCases)('returns empty Map: $label', ({ css }) => { + const result = extractCustomPropertiesFromContent(css); + expect(result.size).toBe(0); + }); // Also verify that real properties adjacent to comments ARE extracted it('extracts real properties while ignoring commented ones', () => { @@ -365,18 +359,15 @@ describe('Property 6: Property prefix filtering', () => { }, ]; - it.each(prefixCases)( - '$label', - ({ prefix, expectedCount, expectedNames }) => { - const result = extractCustomPropertiesFromContent(css, { - propertyPrefix: prefix, - }); - expect(result.size).toBe(expectedCount); - for (const name of expectedNames) { - expect(result.has(name)).toBe(true); - } - }, - ); + it.each(prefixCases)('$label', ({ prefix, expectedCount, expectedNames }) => { + const result = 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', () => { const result = extractCustomPropertiesFromContent(css, { 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 index fb60282..18153db 100644 --- 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 @@ -232,13 +232,27 @@ describe('loadTokenDataset — by-prefix categorisation', () => { 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(); + 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 () => { @@ -265,10 +279,16 @@ describe('loadTokenDataset — by-prefix categorisation', () => { }, }); - 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'); + 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(); + expect( + ds.tokens.find((t) => t.name === '--semantic-color-primary')?.category, + ).toBeUndefined(); }); }); @@ -302,17 +322,39 @@ describe('loadTokenDataset — by-value categorisation', () => { 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(); + 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(); }); }); @@ -394,7 +436,6 @@ describe('loadTokenDataset — propertyPrefix filtering', () => { }); }); - // =========================================================================== // Property-Based Tests (parameterised) // =========================================================================== @@ -418,7 +459,10 @@ describe('Property 7: Flat directory strategy produces scopeless tokens', () => { label: 'multiple files at root', setup: (dir: string) => { - writeCssFile(path.join(dir, 'semantic.css'), ' --a: #f00;\n --b: 4px;'); + writeCssFile( + path.join(dir, 'semantic.css'), + ' --a: #f00;\n --b: 4px;', + ); writeCssFile(path.join(dir, 'other.css'), ' --c: 8px;'); }, filePattern: '**/*.css', @@ -426,14 +470,20 @@ describe('Property 7: Flat directory strategy produces scopeless tokens', () => { 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, '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'); + const declarations = Array.from( + { length: 10 }, + (_, i) => ` --token-${i}: value-${i};`, + ).join('\n'); writeCssFile(path.join(dir, 'semantic.css'), declarations); }, }, @@ -442,7 +492,9 @@ describe('Property 7: Flat directory strategy produces scopeless tokens', () => it.each(cases)( 'all tokens have empty scope: $label', async ({ setup, filePattern }) => { - const stylesDir = makeTempDir(`p7-${cases.indexOf(cases.find((c) => c.setup === setup)!)}`); + const stylesDir = makeTempDir( + `p7-${cases.indexOf(cases.find((c) => c.setup === setup)!)}`, + ); setup(stylesDir); const ds = await loadTokenDataset({ @@ -495,23 +547,20 @@ describe('Property 8: Brand-theme directory strategy assigns correct scope', () }, ]; - 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;'); + 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(), directoryStrategy: 'brand-theme' }, - }); + const ds = await loadTokenDataset({ + generatedStylesRoot: path.relative(tmpRoot, stylesDir), + workspaceRoot: tmpRoot, + tokens: { ...defaultTokensConfig(), directoryStrategy: 'brand-theme' }, + }); - expect(ds.isEmpty).toBe(false); - expect(ds.tokens[0].scope).toEqual(expectedScope); - }, - ); + expect(ds.isEmpty).toBe(false); + expect(ds.tokens[0].scope).toEqual(expectedScope); + }); }); /** @@ -594,21 +643,65 @@ describe('Property 9: Category assignment by prefix', () => { */ describe('Property 10: Category inference by value', () => { const cases = [ - { label: 'hex 6-digit → color', value: '#ff0000', expectedCategory: 'color' }, + { + 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: '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: '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 }, + { + 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)( 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 index 4d5593f..eccc560 100644 --- 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 @@ -24,35 +24,110 @@ function entry( /** 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' }), + 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' }), + 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' }), + 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' }), + 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' }), + 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' }), + 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' }), + 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 { @@ -84,7 +159,6 @@ describe('TokenDatasetImpl — getByName', () => { }); }); - // --------------------------------------------------------------------------- // Unit Tests — getByPrefix // --------------------------------------------------------------------------- @@ -229,7 +303,10 @@ describe('TokenDatasetImpl — getByCategoryInScope', () => { const ds = buildDataset(); it('returns only tokens matching both category and scope', () => { - const result = ds.getByCategoryInScope('color', { brand: 'acme', theme: 'dark' }); + 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'); @@ -237,11 +314,15 @@ describe('TokenDatasetImpl — getByCategoryInScope', () => { }); it('returns empty when category matches but scope does not', () => { - expect(ds.getByCategoryInScope('color', { brand: 'nonexistent' })).toEqual([]); + expect(ds.getByCategoryInScope('color', { brand: 'nonexistent' })).toEqual( + [], + ); }); it('returns empty when scope matches but category does not', () => { - expect(ds.getByCategoryInScope('nonexistent', { brand: 'acme' })).toEqual([]); + expect(ds.getByCategoryInScope('nonexistent', { brand: 'acme' })).toEqual( + [], + ); }); }); @@ -276,7 +357,9 @@ describe('TokenDatasetImpl — result shape', () => { 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); + expect( + result!.category === undefined || typeof result!.category === 'string', + ).toBe(true); }); it('getByPrefix results contain all required fields', () => { @@ -343,7 +426,6 @@ describe('TokenDatasetImpl — diagnostics', () => { }); }); - // =========================================================================== // Property-Based Tests (parameterised) // =========================================================================== @@ -365,7 +447,11 @@ describe('Property 11: Token dataset exact name lookup', () => { 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' }), + entry({ + name: '--ds-button-bg', + value: 'var(--color-red)', + category: 'color', + }), ]; const ds = new TokenDatasetImpl(uniqueTokens); @@ -554,8 +640,20 @@ describe('Property 14: Token dataset category lookup', () => { */ 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: '--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 ]; @@ -567,7 +665,9 @@ describe('Property 15: Token dataset query results contain all required fields', 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(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'); @@ -579,8 +679,14 @@ describe('Property 15: Token dataset query results contain all required fields', { 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' }) }, + { + label: 'getByValueInScope', + fn: () => ds.getByValueInScope('#f00', { brand: 'x' }), + }, + { + label: 'getByCategoryInScope', + fn: () => ds.getByCategoryInScope('color', { brand: 'x' }), + }, ]; it.each(queryCases)( @@ -608,15 +714,31 @@ 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: '--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' } }), + entry({ + name: '--t6', + value: 'v6', + scope: { brand: 'beta', theme: 'dark' }, + }), ]; const ds = new TokenDatasetImpl(tokens); - const scopeCases: Array<{ label: string; scope: Record; expectedNames: string[] }> = [ + const scopeCases: Array<{ + label: string; + scope: Record; + expectedNames: string[]; + }> = [ { label: 'single key: brand=acme', scope: { brand: 'acme' }, @@ -681,7 +803,11 @@ 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: '--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' } }), @@ -689,7 +815,12 @@ describe('Property 17: Token dataset scope-filtered value lookup', () => { const ds = new TokenDatasetImpl(tokens); - const cases: Array<{ label: string; value: string; scope: Record; expectedNames: string[] }> = [ + const cases: Array<{ + label: string; + value: string; + scope: Record; + expectedNames: string[]; + }> = [ { label: 'value=#f00, scope={brand:acme}', value: '#f00', diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts index d285e05..0dd0fc6 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts @@ -100,9 +100,7 @@ export { createEmptyTokenDataset } from './token-dataset.js'; function discoverFiles(absRoot: string, filePattern: string): string[] { const allFiles = walkDirectory(absRoot); const matcher = createGlobMatcher(filePattern); - return allFiles - .filter((f) => matcher(path.relative(absRoot, f))) - .sort(); + return allFiles.filter((f) => matcher(path.relative(absRoot, f))).sort(); } /** 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 index e3c85de..6a6f60c 100644 --- 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 @@ -56,10 +56,7 @@ export interface TokenDataset { /** 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[]; + getByValueInScope(value: string, scope: Record): TokenEntry[]; /** Scope-filtered category lookup */ getByCategoryInScope( category: string, 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 acfcc35..f9f7b1d 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 @@ -8,22 +8,18 @@ export const TokensConfigSchema = z .object({ filePattern: z.string().default('**/semantic.css'), propertyPrefix: z.string().nullable().default(null), - directoryStrategy: z - .enum(['flat', 'brand-theme', 'auto']) - .default('flat'), + directoryStrategy: z.enum(['flat', 'brand-theme', 'auto']).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', - }), + 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', + }), componentTokenPrefix: z.string().default('--ds-'), }) .default({}); 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 index d68c4cc..ca28a41 100644 --- 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 @@ -214,6 +214,7 @@ describe('validateAngularMcpServerFilesExist — generatedStylesRoot', () => { return 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); @@ -228,6 +229,7 @@ describe('validateAngularMcpServerFilesExist — generatedStylesRoot', () => { 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); @@ -257,32 +259,21 @@ describe('Property 1: generatedStylesRoot path validation', () => { '../parent-relative', ]; - const absolutePaths = [ - '/absolute/path', - '/usr/local/styles', - '/a', - '/root', - ]; + 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(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); - }, - ); + it.each(absolutePaths)('rejects absolute path: %s', (absPath) => { + const result = AngularMcpServerOptionsSchema.safeParse( + baseConfig({ generatedStylesRoot: absPath }), + ); + expect(result.success).toBe(false); + }); }); /** @@ -305,21 +296,15 @@ describe('Property 2: directoryStrategy enum validation', () => { 'brandTheme', ]; - it.each(validValues)( - 'accepts valid directoryStrategy: %s', - (value) => { - const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); - expect(result.success).toBe(true); - }, - ); + it.each(validValues)('accepts valid directoryStrategy: %s', (value) => { + const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); + expect(result.success).toBe(true); + }); - it.each(invalidValues)( - 'rejects invalid directoryStrategy: %s', - (value) => { - const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); - expect(result.success).toBe(false); - }, - ); + it.each(invalidValues)('rejects invalid directoryStrategy: %s', (value) => { + const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); + expect(result.success).toBe(false); + }); }); /** @@ -342,21 +327,15 @@ describe('Property 3: categoryInference enum validation', () => { ' none', ]; - it.each(validValues)( - 'accepts valid categoryInference: %s', - (value) => { - const result = TokensConfigSchema.safeParse({ categoryInference: value }); - expect(result.success).toBe(true); - }, - ); + 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); - }, - ); + it.each(invalidValues)('rejects invalid categoryInference: %s', (value) => { + const result = TokensConfigSchema.safeParse({ categoryInference: value }); + expect(result.success).toBe(false); + }); }); /** @@ -405,13 +384,10 @@ describe('Property 21: Backward-compatible config parsing', () => { }, ]; - it.each(existingConfigs)( - 'parses successfully: $label', - ({ config }) => { - const result = AngularMcpServerOptionsSchema.safeParse(config); - expect(result.success).toBe(true); - }, - ); + 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', diff --git a/packages/angular-mcp/src/main.ts b/packages/angular-mcp/src/main.ts index a70f4cc..4522687 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -38,8 +38,7 @@ const argv = yargs(hideBin(process.argv)) type: 'string', }) .option('ds.generatedStylesRoot', { - describe: - 'Path to generated styles directory relative from workspace root', + describe: 'Path to generated styles directory relative from workspace root', type: 'string', }) .option('ds.tokens.filePattern', { type: 'string' }) @@ -84,7 +83,13 @@ const { workspaceRoot, ds } = argv as unknown as { }; }; }; -const { storybookDocsRoot, deprecatedCssClassesPath, uiRoot, generatedStylesRoot, tokens } = ds; +const { + storybookDocsRoot, + deprecatedCssClassesPath, + uiRoot, + generatedStylesRoot, + tokens, +} = ds; async function startServer() { const server = await AngularMcpServerWrapper.create({ 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 index cd5b809..bed2908 100644 --- 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 @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { parseScssContent, ScssPropertyEntry } from './scss-value-parser.js'; +import { parseScssContent } from './scss-value-parser.js'; // --------------------------------------------------------------------------- // Unit Tests diff --git a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts index 261e8ec..3e82532 100644 --- a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -133,7 +133,11 @@ export async function parseScssContent( const property = decl.prop; const value = decl.value; const line = decl.source?.start?.line ?? 0; - const classification = classifyEntry(property, value, componentTokenPrefix); + const classification = classifyEntry( + property, + value, + componentTokenPrefix, + ); entries.push({ property, value, line, selector, classification }); }, From ce53de383e985b6508913532541e72ea653b5ef4 Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Fri, 10 Apr 2026 16:44:10 +0200 Subject: [PATCH 3/6] refactor: clean up according to comments from cr --- .../component/get-ds-component-data.tool.ts | 32 +---- .../ds/component/list-ds-components.tool.ts | 32 +---- .../utils/css-custom-property-parser.ts | 60 --------- .../tools/ds/shared/utils/regex-helpers.ts | 10 -- .../spec/css-custom-property-parser.spec.ts | 125 +++++++++--------- .../ds/shared/utils/token-dataset-loader.ts | 75 ++--------- .../tools/ds/shared/utils/token-dataset.ts | 20 ++- .../violation-analysis/coverage-analyzer.ts | 27 +--- .../src/lib/validation/file-existence.ts | 8 +- .../spec/config-schema-and-bootstrap.spec.ts | 8 +- packages/shared/styles-ast-utils/src/index.ts | 1 + .../src/lib/css-custom-property-parser.ts | 53 ++++++++ .../src/lib/scss-value-parser.ts | 11 +- packages/shared/utils/src/index.ts | 1 + .../shared/utils/src/lib/file/glob-utils.ts | 58 ++++++++ 15 files changed, 220 insertions(+), 301 deletions(-) delete mode 100644 packages/angular-mcp-server/src/lib/tools/ds/shared/utils/css-custom-property-parser.ts create mode 100644 packages/shared/styles-ast-utils/src/lib/css-custom-property-parser.ts create mode 100644 packages/shared/utils/src/lib/file/glob-utils.ts 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/css-custom-property-parser.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/css-custom-property-parser.ts deleted file mode 100644 index 7f4a5f6..0000000 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/css-custom-property-parser.ts +++ /dev/null @@ -1,60 +0,0 @@ -import * as fs from 'node:fs'; - -import { CSS_CUSTOM_PROPERTY_REGEXES } from './regex-helpers.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 content string. - * Strips comments, extracts `--*` declarations via regex, optionally filters by `propertyPrefix`. - */ -export function extractCustomPropertiesFromContent( - content: string, - options?: CssCustomPropertyParserOptions, -): Map { - const result = new Map(); - - // Strip CSS comments - const stripped = content.replace(CSS_CUSTOM_PROPERTY_REGEXES.COMMENT, ''); - - // Extract custom property declarations - const regex = new RegExp( - CSS_CUSTOM_PROPERTY_REGEXES.DECLARATION.source, - CSS_CUSTOM_PROPERTY_REGEXES.DECLARATION.flags, - ); - - let match: RegExpExecArray | null; - while ((match = regex.exec(stripped)) !== null) { - const name = match[1]; - const value = match[2].trim(); - const prefix = options?.propertyPrefix; - - if (prefix != null && !name.startsWith(prefix)) { - continue; - } - - result.set(name, value); - } - - return result; -} - -/** - * Extracts CSS custom property declarations from a CSS file. - * Returns a Map of property name → resolved value. - * Returns an empty Map if the file cannot be read. - */ -export function parseCssCustomProperties( - filePath: string, - options?: CssCustomPropertyParserOptions, -): Map { - try { - const content = fs.readFileSync(filePath, 'utf-8'); - return extractCustomPropertiesFromContent(content, options); - } catch { - return new Map(); - } -} diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts index 0ec3815..1dcc709 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/regex-helpers.ts @@ -100,16 +100,6 @@ export const IMPORT_REGEXES = { ), } as const; -// CSS Custom Property Regexes -export const CSS_CUSTOM_PROPERTY_REGEXES = { - /** Matches CSS comments for stripping */ - COMMENT: /\/\*[\s\S]*?\*\//g, - /** Matches CSS custom property declarations: --name: value; */ - DECLARATION: /(--[\w-]+)\s*:\s*([^;]+);/g, - /** Matches var() references in values */ - VAR_REFERENCE: /var\(\s*(--[\w-]+)\s*(?:,\s*([^)]+))?\)/g, -} as const; - // Regex Cache Management const REGEX_CACHE = new Map(); 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 index 3b8de4e..4e3bc48 100644 --- 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 @@ -4,7 +4,7 @@ import * as path from 'node:path'; import { extractCustomPropertiesFromContent, parseCssCustomProperties, -} from '../css-custom-property-parser.js'; +} from '@push-based/styles-ast-utils'; // --------------------------------------------------------------------------- // Helpers @@ -20,19 +20,19 @@ function cssRoot(declarations: string): string { // --------------------------------------------------------------------------- describe('extractCustomPropertiesFromContent', () => { - it('extracts basic --name: value; pairs', () => { + it('extracts basic --name: value; pairs', async () => { const css = cssRoot(` --color-primary: #ff0000; --spacing-sm: 4px; `); - const result = extractCustomPropertiesFromContent(css); + 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', () => { + it('extracts multi-line declarations', async () => { const css = cssRoot(` --gradient-bg: linear-gradient( to right, @@ -41,14 +41,13 @@ describe('extractCustomPropertiesFromContent', () => { ); --simple: blue; `); - const result = extractCustomPropertiesFromContent(css); + const result = await extractCustomPropertiesFromContent(css); - // The regex captures everything up to the semicolon expect(result.has('--gradient-bg')).toBe(true); expect(result.get('--simple')).toBe('blue'); }); - it('strips comments and does not extract properties inside comments', () => { + it('strips comments and does not extract properties inside comments', async () => { const css = cssRoot(` /* --commented-out: should-not-appear; */ --real-prop: visible; @@ -58,7 +57,7 @@ describe('extractCustomPropertiesFromContent', () => { */ --another-real: yes; `); - const result = extractCustomPropertiesFromContent(css); + const result = await extractCustomPropertiesFromContent(css); expect(result.has('--commented-out')).toBe(false); expect(result.has('--multi-line-comment')).toBe(false); @@ -68,26 +67,26 @@ describe('extractCustomPropertiesFromContent', () => { expect(result.size).toBe(2); }); - it('preserves var() references in values', () => { + 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 = extractCustomPropertiesFromContent(css); + 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', () => { - const result = extractCustomPropertiesFromContent(''); + 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', () => { + it('returns empty Map for content with no custom properties', async () => { const css = `body { color: red; font-size: 16px; }`; - const result = extractCustomPropertiesFromContent(css); + const result = await extractCustomPropertiesFromContent(css); expect(result.size).toBe(0); }); }); @@ -97,8 +96,8 @@ describe('extractCustomPropertiesFromContent', () => { // --------------------------------------------------------------------------- describe('parseCssCustomProperties', () => { - it('returns empty Map for non-existent file', () => { - const result = 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); @@ -119,20 +118,20 @@ describe('extractCustomPropertiesFromContent — propertyPrefix filtering', () = --other-prop: 10px; `); - it('returns all properties when propertyPrefix is null', () => { - const result = extractCustomPropertiesFromContent(css, { + 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', () => { - const result = extractCustomPropertiesFromContent(css); + 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', () => { - const result = extractCustomPropertiesFromContent(css, { + it('filters by prefix --semantic-color', async () => { + const result = await extractCustomPropertiesFromContent(css, { propertyPrefix: '--semantic-color', }); expect(result.size).toBe(2); @@ -140,16 +139,16 @@ describe('extractCustomPropertiesFromContent — propertyPrefix filtering', () = expect(result.has('--semantic-color-secondary')).toBe(true); }); - it('filters by prefix --ds-', () => { - const result = extractCustomPropertiesFromContent(css, { + 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', () => { - const result = extractCustomPropertiesFromContent(css, { + it('returns empty Map when no properties match prefix', async () => { + const result = await extractCustomPropertiesFromContent(css, { propertyPrefix: '--nonexistent-', }); expect(result.size).toBe(0); @@ -163,10 +162,6 @@ describe('extractCustomPropertiesFromContent — propertyPrefix filtering', () = /** * **Validates: Requirements 3.1, 3.5** * Property 4: CSS custom property parsing round-trip - * - * For any set of valid CSS custom property declarations, embedding them in a - * CSS :root block and parsing with extractCustomPropertiesFromContent SHALL - * produce a Map containing every original name-value pair. */ describe('Property 4: CSS custom property parsing round-trip', () => { const testCases = [ @@ -217,27 +212,27 @@ describe('Property 4: CSS custom property parsing round-trip', () => { }, ]; - it.each(testCases)('round-trips all properties: $label', ({ properties }) => { - const declarations = properties - .map(([name, value]) => ` ${name}: ${value};`) - .join('\n'); - const css = cssRoot(declarations); + 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 = extractCustomPropertiesFromContent(css); + const result = await extractCustomPropertiesFromContent(css); - for (const [name, value] of properties) { - expect(result.get(name)).toBe(value); - } - expect(result.size).toBe(properties.length); - }); + 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 - * - * For any CSS content where custom property patterns appear only inside - * comments, extractCustomPropertiesFromContent SHALL return an empty Map. */ describe('Property 5: CSS parser ignores comments', () => { const commentOnlyCases = [ @@ -271,13 +266,12 @@ describe('Property 5: CSS parser ignores comments', () => { }, ]; - it.each(commentOnlyCases)('returns empty Map: $label', ({ css }) => { - const result = extractCustomPropertiesFromContent(css); + it.each(commentOnlyCases)('returns empty Map: $label', async ({ css }) => { + const result = await extractCustomPropertiesFromContent(css); expect(result.size).toBe(0); }); - // Also verify that real properties adjacent to comments ARE extracted - it('extracts real properties while ignoring commented ones', () => { + it('extracts real properties while ignoring commented ones', async () => { const css = ` /* --hidden: nope; */ :root { @@ -286,7 +280,7 @@ describe('Property 5: CSS parser ignores comments', () => { --also-visible: yes; } `; - const result = extractCustomPropertiesFromContent(css); + const result = await extractCustomPropertiesFromContent(css); expect(result.size).toBe(2); expect(result.has('--visible')).toBe(true); expect(result.has('--also-visible')).toBe(true); @@ -298,10 +292,6 @@ describe('Property 5: CSS parser ignores comments', () => { /** * **Validates: Requirements 4.5, 4.6** * Property 6: Property prefix filtering - * - * For any set of custom properties and any non-null propertyPrefix, only - * properties whose name starts with the given prefix SHALL be included. - * When propertyPrefix is null, all properties SHALL be included. */ describe('Property 6: Property prefix filtering', () => { const allProperties: [string, string][] = [ @@ -359,18 +349,21 @@ describe('Property 6: Property prefix filtering', () => { }, ]; - it.each(prefixCases)('$label', ({ prefix, expectedCount, expectedNames }) => { - const result = extractCustomPropertiesFromContent(css, { - propertyPrefix: prefix, - }); - expect(result.size).toBe(expectedCount); - for (const name of expectedNames) { - expect(result.has(name)).toBe(true); - } - }); + 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', () => { - const result = extractCustomPropertiesFromContent(css, { + it('null prefix includes all properties', async () => { + const result = await extractCustomPropertiesFromContent(css, { propertyPrefix: null, }); expect(result.size).toBe(allProperties.length); @@ -379,8 +372,8 @@ describe('Property 6: Property prefix filtering', () => { } }); - it('undefined options includes all properties', () => { - const result = extractCustomPropertiesFromContent(css); + 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/token-dataset-loader.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts index 0dd0fc6..e9af07e 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts @@ -1,8 +1,9 @@ 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 { parseCssCustomProperties } from './css-custom-property-parser.js'; import { type TokenEntry, type TokenScope, @@ -60,7 +61,7 @@ export async function loadTokenDataset( for (const filePath of files) { const scope = computeScope(strategy, filePath, absRoot); - const properties = parseCssCustomProperties(filePath, { + const properties = await parseCssCustomProperties(filePath, { propertyPrefix: tokens.propertyPrefix, }); @@ -94,73 +95,13 @@ export { createEmptyTokenDataset } from './token-dataset.js'; /** * Discovers files matching a glob-like pattern under the given root. - * Supports `**` for recursive directory matching and `*` for single-segment wildcards. - * Uses synchronous fs operations consistent with existing codebase patterns. */ function discoverFiles(absRoot: string, filePattern: string): string[] { - const allFiles = walkDirectory(absRoot); - const matcher = createGlobMatcher(filePattern); - return allFiles.filter((f) => matcher(path.relative(absRoot, f))).sort(); -} - -/** - * Recursively walks a directory and returns all file paths. - */ -function walkDirectory(dir: string): string[] { - const results: string[] = []; - try { - const entries = fs.readdirSync(dir, { withFileTypes: true }); - for (const entry of entries) { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) { - results.push(...walkDirectory(fullPath)); - } else if (entry.isFile()) { - results.push(fullPath); - } - } - } catch { - // Silently skip unreadable directories - } - return results; -} - -/** - * Creates a matcher function from a glob-like pattern. - * Converts glob syntax to a RegExp: - * - `**` matches any number of path segments (including zero) - * - `*` matches any characters within a single path segment - * - `.` and other regex specials are escaped - */ -function createGlobMatcher(pattern: string): (filePath: string) => boolean { - // Normalise to forward slashes - const normalised = pattern.replace(/\\/g, '/'); - - // Build regex from glob pattern - let regexStr = ''; - let i = 0; - while (i < normalised.length) { - if (normalised[i] === '*' && normalised[i + 1] === '*') { - // ** matches any path segments - regexStr += '.*'; - i += 2; - // Skip trailing slash after ** - if (normalised[i] === '/') i++; - } else if (normalised[i] === '*') { - // * matches anything except path separator - regexStr += '[^/]*'; - i++; - } else if ('.+?^${}()|[]\\'.includes(normalised[i])) { - // Escape regex special characters - regexStr += '\\' + normalised[i]; - i++; - } else { - regexStr += normalised[i]; - i++; - } - } - - const regex = new RegExp(`^${regexStr}$`); - return (filePath: string) => regex.test(filePath.replace(/\\/g, '/')); + const allFiles = walkDirectorySync(absRoot); + const regex = globToRegex(filePattern); + return allFiles + .filter((f) => regex.test(path.relative(absRoot, f).replace(/\\/g, '/'))) + .sort(); } // --------------------------------------------------------------------------- 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 index 6a6f60c..93b8ae6 100644 --- 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 @@ -71,11 +71,17 @@ export interface TokenDataset { /** * Indexed implementation of {@link TokenDataset}. * - * Builds four internal indexes at construction time for O(1) / O(k) lookups: - * - `byName` — Map - * - `byValue` — Map - * - `byCategory` — Map - * - `byScopeKey` — Map> + * 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; @@ -148,11 +154,11 @@ export class TokenDatasetImpl implements TokenDataset { } getByValue(value: string): TokenEntry[] { - return this.byValue.get(value) ?? []; + return [...(this.byValue.get(value) ?? [])]; } getByCategory(category: string): TokenEntry[] { - return this.byCategory.get(category) ?? []; + return [...(this.byCategory.get(category) ?? [])]; } getByScope(scope: Record): TokenEntry[] { 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/file-existence.ts b/packages/angular-mcp-server/src/lib/validation/file-existence.ts index ebbcaef..361c183 100644 --- a/packages/angular-mcp-server/src/lib/validation/file-existence.ts +++ b/packages/angular-mcp-server/src/lib/validation/file-existence.ts @@ -45,7 +45,13 @@ export function validateAngularMcpServerFilesExist( let result = config; if (config.ds.generatedStylesRoot) { const absPath = path.resolve(root, config.ds.generatedStylesRoot); - if (!fs.existsSync(absPath) || !fs.statSync(absPath).isDirectory()) { + 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.`, ); 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 index ca28a41..ebdda9d 100644 --- 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 @@ -208,10 +208,12 @@ describe('validateAngularMcpServerFilesExist — generatedStylesRoot', () => { }); it('sets generatedStylesRoot to undefined and warns when path does not exist', () => { - existsSyncMock = vi.fn((p: string) => { + statSyncMock = vi.fn((p: string) => { // workspace root exists, but the generatedStylesRoot does not - if (typeof p === 'string' && p.includes('dist/styles')) return false; - return true; + 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 diff --git a/packages/shared/styles-ast-utils/src/index.ts b/packages/shared/styles-ast-utils/src/index.ts index f437d0b..27cf5ba 100644 --- a/packages/shared/styles-ast-utils/src/index.ts +++ b/packages/shared/styles-ast-utils/src/index.ts @@ -4,3 +4,4 @@ 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.ts b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts index 3e82532..6c51af7 100644 --- a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -21,7 +21,7 @@ export interface ScssPropertyEntry { property: string; /** CSS value, e.g. 'var(--semantic-color-primary)' */ value: string; - /** 1-based line number in the source file */ + /** 1-based line number in the source file, or -1 if unavailable */ line: number; /** Full selector path, e.g. ':host .button' */ selector: string; @@ -132,7 +132,7 @@ export async function parseScssContent( const selector = resolveSelector(decl); const property = decl.prop; const value = decl.value; - const line = decl.source?.start?.line ?? 0; + const line = decl.source?.start?.line ?? -1; const classification = classifyEntry( property, value, @@ -154,6 +154,11 @@ export async function parseScssValues( filePath: string, options?: ScssValueParserOptions, ): Promise { - const content = fs.readFileSync(filePath, 'utf-8'); + let content: string; + try { + content = fs.readFileSync(filePath, 'utf-8'); + } catch { + return createParseResult([]); + } return parseScssContent(content, filePath, options); } 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; +} From 3b291e8ae5b1f756ecb690fe6defa85382d953f9 Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Tue, 14 Apr 2026 10:20:25 +0200 Subject: [PATCH 4/6] refactor: remove componentTokenPrefix --- README.md | 1 - docs/architecture-internal-design.md | 1 - .../lib/spec/server-token-integration.spec.ts | 1 - .../angular-mcp-server-options.schema.ts | 1 - .../spec/config-schema-and-bootstrap.spec.ts | 8 +- packages/angular-mcp/src/main.ts | 26 ++++-- .../src/lib/scss-value-parser.spec.ts | 81 +++++++------------ .../src/lib/scss-value-parser.ts | 27 +++---- 8 files changed, 60 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 3e1801b..977edf9 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,6 @@ These parameters control how design tokens are discovered, organised, and catego | `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.directoryStrategy` | `flat \| brand-theme \| auto` | `flat` | Controls how the directory tree under `generatedStylesRoot` is interpreted. `flat`: all files belong to a single token set (empty scope). `brand-theme`: path segments map to scope keys (first → `brand`, second → `theme`). `auto`: infers from directory depth (≥ 2 levels → `brand-theme`, otherwise → `flat`). | | `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. | -| `ds.tokens.componentTokenPrefix` | `string` | `--ds-` | Prefix identifying component-level token declarations in SCSS files. The SCSS Value Parser classifies properties starting with this prefix as `declaration` entries. Change if your components use a different prefix convention (e.g. `--app-`). | > **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`. diff --git a/docs/architecture-internal-design.md b/docs/architecture-internal-design.md index 8e67a68..df9043b 100644 --- a/docs/architecture-internal-design.md +++ b/docs/architecture-internal-design.md @@ -114,7 +114,6 @@ These options control how design tokens are discovered, organised, and categoris | `ds.tokens.directoryStrategy` | enum | `flat` | `flat`, `brand-theme`, or `auto`. Controls how directory structure maps to token scope. | | `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`). | -| `ds.tokens.componentTokenPrefix` | string | `--ds-` | Prefix identifying component token declarations in SCSS. | Validation is handled via **Zod** in `angular-mcp-server-options.schema.ts`. 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 index f7f7b50..0087894 100644 --- 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 @@ -125,7 +125,6 @@ describe('Server bootstrap with token config (integration)', () => { filePattern: '**/custom.css', directoryStrategy: 'brand-theme', categoryInference: 'by-value', - componentTokenPrefix: '--custom-', }, }, } as Parameters[0]); 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 f9f7b1d..0b5bce0 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 @@ -20,7 +20,6 @@ export const TokensConfigSchema = z size: '--semantic-size', opacity: '--semantic-opacity', }), - componentTokenPrefix: z.string().default('--ds-'), }) .default({}); 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 index ebdda9d..c15f009 100644 --- 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 @@ -126,12 +126,7 @@ describe('AngularMcpServerOptionsSchema', () => { size: '--semantic-size', opacity: '--semantic-opacity', }); - }); - - it('defaults componentTokenPrefix to --ds-', () => { - const result = AngularMcpServerOptionsSchema.parse(baseConfig()); - expect(result.ds.tokens.componentTokenPrefix).toBe('--ds-'); - }); + }); }); // ---- directoryStrategy enum validation (Req 2.6) ---- @@ -403,7 +398,6 @@ describe('Property 21: Backward-compatible config parsing', () => { expect(parsed.ds.tokens.propertyPrefix).toBeNull(); expect(parsed.ds.tokens.directoryStrategy).toBe('flat'); expect(parsed.ds.tokens.categoryInference).toBe('by-prefix'); - expect(parsed.ds.tokens.componentTokenPrefix).toBe('--ds-'); }, ); }); diff --git a/packages/angular-mcp/src/main.ts b/packages/angular-mcp/src/main.ts index 4522687..40c6394 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -41,11 +41,26 @@ const argv = yargs(hideBin(process.argv)) describe: 'Path to generated styles directory relative from workspace root', type: 'string', }) - .option('ds.tokens.filePattern', { type: 'string' }) - .option('ds.tokens.propertyPrefix', { type: 'string' }) - .option('ds.tokens.directoryStrategy', { type: 'string' }) - .option('ds.tokens.categoryInference', { type: 'string' }) - .option('ds.tokens.componentTokenPrefix', { 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.directoryStrategy', { + describe: + 'Controls how directory structure maps to token scopes. "flat" treats all files as one set, "brand-theme" derives brand/theme scope from path segments, "auto" infers from directory depth (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', @@ -79,7 +94,6 @@ const { workspaceRoot, ds } = argv as unknown as { propertyPrefix?: string; directoryStrategy?: string; categoryInference?: string; - componentTokenPrefix?: string; }; }; }; 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 index bed2908..998cc6d 100644 --- 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 @@ -120,14 +120,18 @@ describe('SCSS Value Parser', () => { }); describe('classification: declaration', () => { - it('should classify property starting with componentTokenPrefix as declaration', async () => { + 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(1); + 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'); }); }); @@ -157,28 +161,6 @@ describe('SCSS Value Parser', () => { }); }); - describe('configurable componentTokenPrefix', () => { - it('should use custom prefix for declaration classification', async () => { - const scss = `:host { - --custom-button-bg: #ff0000; - --ds-button-bg: #00ff00; -}`; - const result = await parseScssContent(scss, 'test.scss', { - componentTokenPrefix: '--custom-', - }); - - const customEntry = result.entries.find( - (e) => e.property === '--custom-button-bg', - ); - const dsEntry = result.entries.find( - (e) => e.property === '--ds-button-bg', - ); - - expect(customEntry?.classification).toBe('declaration'); - expect(dsEntry?.classification).not.toBe('declaration'); - }); - }); - describe('query methods', () => { it('getBySelector returns entries for a specific selector', async () => { const scss = `.a { color: red; } @@ -321,65 +303,62 @@ describe('Property 18: SCSS property extraction round-trip', () => { /** * **Validates: Requirements 9.1, 9.3** - * Property 19: Token declaration classification by configurable prefix + * Property 19: Token declaration classification by CSS custom property syntax * - * For any CSS property name and any componentTokenPrefix, the SCSS Value Parser - * SHALL classify the property as 'declaration' if and only if the property name - * starts with the configured prefix. + * 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 configurable prefix', () => { +describe('Property 19: Token declaration classification by CSS custom property syntax', () => { const testCases = [ { - label: 'default prefix --ds- matches --ds-button-bg', - prefix: '--ds-', + label: '--ds-button-bg is a declaration', property: '--ds-button-bg', value: '#ff0000', expectedClassification: 'declaration' as const, }, { - label: 'default prefix --ds- does not match --semantic-color', - prefix: '--ds-', + label: '--semantic-color is a declaration', property: '--semantic-color', value: '#ff0000', - expectedClassification: 'plain' as const, + expectedClassification: 'declaration' as const, }, { - label: 'custom prefix --app- matches --app-header-bg', - prefix: '--app-', + label: '--app-header-bg is a declaration', property: '--app-header-bg', value: 'blue', expectedClassification: 'declaration' as const, }, { - label: 'custom prefix --app- does not match --ds-button-bg', - prefix: '--app-', - property: '--ds-button-bg', - value: 'red', - expectedClassification: 'plain' as const, - }, - { - label: 'prefix --comp- matches --comp-card-radius', - prefix: '--comp-', + label: '--comp-card-radius is a declaration', property: '--comp-card-radius', value: '4px', expectedClassification: 'declaration' as const, }, { - label: 'empty-ish prefix -- matches any custom property', - prefix: '--', + 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 ({ prefix, property, value, expectedClassification }) => { + async ({ property, value, expectedClassification }) => { const scss = `:host {\n ${property}: ${value};\n}`; - const result = await parseScssContent(scss, 'test.scss', { - componentTokenPrefix: prefix, - }); + 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 index 6c51af7..d737694 100644 --- a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -6,8 +6,8 @@ import { visitEachChild } from './stylesheet.walk.js'; /** * Classification of a CSS property entry: - * - `declaration`: property name starts with the configured componentTokenPrefix (token declaration) - * - `consumption`: value contains a `var(--*)` reference (token consumption) + * - `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'; @@ -46,13 +46,11 @@ export interface ScssParseResult { /** * Options for the SCSS Value Parser. + * Reserved for future extensions. */ -export interface ScssValueParserOptions { - /** Prefix for component token declarations. Default: '--ds-' */ - componentTokenPrefix?: string; -} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface ScssValueParserOptions {} -const DEFAULT_COMPONENT_TOKEN_PREFIX = '--ds-'; const VAR_REFERENCE_PATTERN = /var\(\s*--[\w-]+/; /** @@ -73,18 +71,17 @@ function resolveSelector(node: Declaration): string { } /** - * Classifies a CSS property-value pair based on the configured prefix. + * Classifies a CSS property-value pair purely by syntax. * - * - `declaration`: property name starts with componentTokenPrefix + * - `declaration`: property is a CSS custom property (starts with `--`) * - `consumption`: value contains a `var(--*)` reference * - `plain`: neither */ function classifyEntry( property: string, value: string, - componentTokenPrefix: string, ): ScssClassification { - if (property.startsWith(componentTokenPrefix)) { + if (property.startsWith('--')) { return 'declaration'; } if (VAR_REFERENCE_PATTERN.test(value)) { @@ -120,8 +117,6 @@ export async function parseScssContent( filePath: string, options?: ScssValueParserOptions, ): Promise { - const componentTokenPrefix = - options?.componentTokenPrefix ?? DEFAULT_COMPONENT_TOKEN_PREFIX; const entries: ScssPropertyEntry[] = []; const result = parseStylesheet(content, filePath); @@ -133,11 +128,7 @@ export async function parseScssContent( const property = decl.prop; const value = decl.value; const line = decl.source?.start?.line ?? -1; - const classification = classifyEntry( - property, - value, - componentTokenPrefix, - ); + const classification = classifyEntry(property, value); entries.push({ property, value, line, selector, classification }); }, From a4dce7faf8b112e6c44ca3439dd13f1c9fa8708c Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Tue, 14 Apr 2026 12:09:18 +0200 Subject: [PATCH 5/6] refactor: rename directoryStrategy to scopeStrategy --- README.md | 2 +- docs/architecture-internal-design.md | 2 +- .../lib/spec/server-token-integration.spec.ts | 2 +- .../utils/spec/token-dataset-loader.spec.ts | 68 ++++--------------- .../ds/shared/utils/token-dataset-loader.ts | 32 ++------- .../tools/ds/shared/utils/token-dataset.ts | 2 +- .../angular-mcp-server-options.schema.ts | 11 ++- .../spec/config-schema-and-bootstrap.spec.ts | 35 +++++----- packages/angular-mcp/src/main.ts | 6 +- .../src/lib/scss-value-parser.ts | 16 +---- 10 files changed, 55 insertions(+), 121 deletions(-) diff --git a/README.md b/README.md index 977edf9..6513e2d 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ These parameters control how design tokens are discovered, organised, and catego |-----------|------|---------|-------------| | `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.directoryStrategy` | `flat \| brand-theme \| auto` | `flat` | Controls how the directory tree under `generatedStylesRoot` is interpreted. `flat`: all files belong to a single token set (empty scope). `brand-theme`: path segments map to scope keys (first → `brand`, second → `theme`). `auto`: infers from directory depth (≥ 2 levels → `brand-theme`, otherwise → `flat`). | +| `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`. diff --git a/docs/architecture-internal-design.md b/docs/architecture-internal-design.md index df9043b..7bdf006 100644 --- a/docs/architecture-internal-design.md +++ b/docs/architecture-internal-design.md @@ -111,7 +111,7 @@ These options control how design tokens are discovered, organised, and categoris |--------|------|---------|-------------| | `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.directoryStrategy` | enum | `flat` | `flat`, `brand-theme`, or `auto`. Controls how directory structure maps to token scope. | +| `ds.tokens.scopeStrategy` | enum | `flat` | `flat` or `brand-theme`. Controls how directory structure maps to token scope metadata. `flat`: no scope. `brand-theme`: path segments → brand/theme scope keys. | | `ds.tokens.categoryInference` | enum | `by-prefix` | `by-prefix`, `by-value`, or `none`. Controls how tokens are assigned categories. | | `ds.tokens.categoryPrefixMap` | Record | `{ color: '--semantic-color', ... }` | Category → prefix mapping (used with `by-prefix`). | diff --git a/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 index 0087894..354daec 100644 --- 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 @@ -123,7 +123,7 @@ describe('Server bootstrap with token config (integration)', () => { uiRoot, tokens: { filePattern: '**/custom.css', - directoryStrategy: 'brand-theme', + scopeStrategy: 'brand-theme', categoryInference: 'by-value', }, }, 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 index 18153db..39495ca 100644 --- 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 @@ -82,7 +82,7 @@ describe('loadTokenDataset — empty dataset with diagnostic when glob matches z // Unit Tests — flat directory strategy // --------------------------------------------------------------------------- -describe('loadTokenDataset — flat directory strategy', () => { +describe('loadTokenDataset — flat scope strategy', () => { it('produces empty scope for all tokens', async () => { const stylesDir = makeTempDir('flat-strategy'); writeCssFile( @@ -93,7 +93,7 @@ describe('loadTokenDataset — flat directory strategy', () => { const ds = await loadTokenDataset({ generatedStylesRoot: path.relative(tmpRoot, stylesDir), workspaceRoot: tmpRoot, - tokens: { ...defaultTokensConfig(), directoryStrategy: 'flat' }, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'flat' }, }); expect(ds.isEmpty).toBe(false); @@ -108,7 +108,7 @@ describe('loadTokenDataset — flat directory strategy', () => { // Unit Tests — brand-theme directory strategy // --------------------------------------------------------------------------- -describe('loadTokenDataset — 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( @@ -127,7 +127,7 @@ describe('loadTokenDataset — brand-theme directory strategy', () => { const ds = await loadTokenDataset({ generatedStylesRoot: path.relative(tmpRoot, stylesDir), workspaceRoot: tmpRoot, - tokens: { ...defaultTokensConfig(), directoryStrategy: 'brand-theme' }, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'brand-theme' }, }); expect(ds.isEmpty).toBe(false); @@ -156,7 +156,7 @@ describe('loadTokenDataset — brand-theme directory strategy', () => { const ds = await loadTokenDataset({ generatedStylesRoot: path.relative(tmpRoot, stylesDir), workspaceRoot: tmpRoot, - tokens: { ...defaultTokensConfig(), directoryStrategy: 'brand-theme' }, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'brand-theme' }, }); expect(ds.tokens.length).toBe(1); @@ -164,48 +164,6 @@ describe('loadTokenDataset — brand-theme directory strategy', () => { }); }); -// --------------------------------------------------------------------------- -// Unit Tests — auto directory strategy -// --------------------------------------------------------------------------- - -describe('loadTokenDataset — auto directory strategy', () => { - it('infers flat when files are at root level (depth < 2)', async () => { - const stylesDir = makeTempDir('auto-flat'); - writeCssFile( - path.join(stylesDir, 'semantic.css'), - ` --semantic-color-primary: #ff0000;`, - ); - - const ds = await loadTokenDataset({ - generatedStylesRoot: path.relative(tmpRoot, stylesDir), - workspaceRoot: tmpRoot, - tokens: { ...defaultTokensConfig(), directoryStrategy: 'auto' }, - }); - - expect(ds.isEmpty).toBe(false); - for (const token of ds.tokens) { - expect(token.scope).toEqual({}); - } - }); - - it('infers brand-theme when files are at depth >= 2', async () => { - const stylesDir = makeTempDir('auto-brand-theme'); - writeCssFile( - path.join(stylesDir, 'acme', 'dark', 'semantic.css'), - ` --semantic-color-primary: #111111;`, - ); - - const ds = await loadTokenDataset({ - generatedStylesRoot: path.relative(tmpRoot, stylesDir), - workspaceRoot: tmpRoot, - tokens: { ...defaultTokensConfig(), directoryStrategy: 'auto' }, - }); - - expect(ds.isEmpty).toBe(false); - expect(ds.tokens[0].scope).toEqual({ brand: 'acme', theme: 'dark' }); - }); -}); - // --------------------------------------------------------------------------- // Unit Tests — by-prefix categorisation // --------------------------------------------------------------------------- @@ -442,13 +400,13 @@ describe('loadTokenDataset — propertyPrefix filtering', () => { /** * **Validates: Requirements 5.1** - * Property 7: Flat directory strategy produces scopeless tokens + * Property 7: Flat scope strategy produces scopeless tokens * * For any set of token files discovered under generatedStylesRoot with - * directoryStrategy set to 'flat', all tokens in the resulting dataset + * scopeStrategy set to 'flat', all tokens in the resulting dataset * SHALL have an empty scope (no brand, no theme). */ -describe('Property 7: Flat directory strategy produces scopeless tokens', () => { +describe('Property 7: Flat scope strategy produces scopeless tokens', () => { const cases = [ { label: 'single file at root', @@ -502,7 +460,7 @@ describe('Property 7: Flat directory strategy produces scopeless tokens', () => workspaceRoot: tmpRoot, tokens: { ...defaultTokensConfig(), - directoryStrategy: 'flat', + scopeStrategy: 'flat', filePattern: filePattern ?? '**/semantic.css', }, }); @@ -517,13 +475,13 @@ describe('Property 7: Flat directory strategy produces scopeless tokens', () => /** * **Validates: Requirements 5.2, 5.4** - * Property 8: Brand-theme directory strategy assigns correct scope + * Property 8: Brand-theme scope strategy assigns correct scope * * For any token file at path {generatedStylesRoot}/{segment1}/{segment2}/... - * with directoryStrategy set to 'brand-theme', the resulting tokens SHALL have + * 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 directory strategy assigns correct scope', () => { +describe('Property 8: Brand-theme scope strategy assigns correct scope', () => { const cases = [ { label: 'brand/theme/file → { brand, theme }', @@ -555,7 +513,7 @@ describe('Property 8: Brand-theme directory strategy assigns correct scope', () const ds = await loadTokenDataset({ generatedStylesRoot: path.relative(tmpRoot, stylesDir), workspaceRoot: tmpRoot, - tokens: { ...defaultTokensConfig(), directoryStrategy: 'brand-theme' }, + tokens: { ...defaultTokensConfig(), scopeStrategy: 'brand-theme' }, }); expect(ds.isEmpty).toBe(false); diff --git a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts index e9af07e..10a5095 100644 --- a/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts +++ b/packages/angular-mcp-server/src/lib/tools/ds/shared/utils/token-dataset-loader.ts @@ -49,12 +49,8 @@ export async function loadTokenDataset( ); } - // Determine effective directory strategy - const strategy = resolveDirectoryStrategy( - tokens.directoryStrategy, - files, - absRoot, - ); + // Determine effective scope strategy + const strategy = resolveScopeStrategy(tokens.scopeStrategy); // Parse and build tokens const allTokens: TokenEntry[] = []; @@ -110,28 +106,10 @@ function discoverFiles(absRoot: string, filePattern: string): string[] { type EffectiveStrategy = 'flat' | 'brand-theme'; -function resolveDirectoryStrategy( - configured: TokensConfig['directoryStrategy'], - files: string[], - absRoot: string, +function resolveScopeStrategy( + configured: TokensConfig['scopeStrategy'], ): EffectiveStrategy { - if (configured === 'flat') return 'flat'; - if (configured === 'brand-theme') return 'brand-theme'; - - // auto: infer from max directory depth - const maxDepth = computeMaxDepth(files, absRoot); - return maxDepth >= 2 ? 'brand-theme' : 'flat'; -} - -function computeMaxDepth(files: string[], absRoot: string): number { - let max = 0; - for (const filePath of files) { - const rel = path.relative(absRoot, path.dirname(filePath)); - if (rel === '' || rel === '.') continue; - const depth = rel.split(path.sep).length; - if (depth > max) max = depth; - } - return max; + return configured; } function computeScope( 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 index 93b8ae6..92b6d6e 100644 --- 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 @@ -11,7 +11,7 @@ /** * Scope derived from directory path segments. - * Keys are determined by the directoryStrategy config. + * Keys are determined by the scopeStrategy config. * Empty object for flat strategy. */ export interface TokenScope { 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 0b5bce0..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 @@ -8,7 +8,16 @@ export const TokensConfigSchema = z .object({ filePattern: z.string().default('**/semantic.css'), propertyPrefix: z.string().nullable().default(null), - directoryStrategy: z.enum(['flat', 'brand-theme', 'auto']).default('flat'), + /** + * 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'), 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 index c15f009..3e24fd6 100644 --- 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 @@ -105,9 +105,9 @@ describe('AngularMcpServerOptionsSchema', () => { expect(result.ds.tokens.propertyPrefix).toBeNull(); }); - it('defaults directoryStrategy to flat', () => { + it('defaults scopeStrategy to flat', () => { const result = AngularMcpServerOptionsSchema.parse(baseConfig()); - expect(result.ds.tokens.directoryStrategy).toBe('flat'); + expect(result.ds.tokens.scopeStrategy).toBe('flat'); }); it('defaults categoryInference to by-prefix', () => { @@ -126,26 +126,26 @@ describe('AngularMcpServerOptionsSchema', () => { size: '--semantic-size', opacity: '--semantic-opacity', }); - }); + }); }); - // ---- directoryStrategy enum validation (Req 2.6) ---- - describe('ds.tokens.directoryStrategy enum', () => { - it.each(['flat', 'brand-theme', 'auto'] as const)( + // ---- 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: { directoryStrategy: strategy } }), + baseConfig({ tokens: { scopeStrategy: strategy } }), ); expect(result.success).toBe(true); }, ); - it.each(['invalid', 'FLAT', 'brandTheme', ''])( + it.each(['invalid', 'FLAT', 'brandTheme', '', 'auto'])( 'rejects invalid value: %s', (strategy) => { const result = AngularMcpServerOptionsSchema.safeParse( - baseConfig({ tokens: { directoryStrategy: strategy } }), + baseConfig({ tokens: { scopeStrategy: strategy } }), ); expect(result.success).toBe(false); }, @@ -275,15 +275,16 @@ describe('Property 1: generatedStylesRoot path validation', () => { /** * **Validates: Requirements 2.6** - * Property 2: Config schema validates directoryStrategy enum + * Property 2: Config schema validates scopeStrategy enum */ -describe('Property 2: directoryStrategy enum validation', () => { - const validValues = ['flat', 'brand-theme', 'auto']; +describe('Property 2: scopeStrategy enum validation', () => { + const validValues = ['flat', 'brand-theme']; const invalidValues = [ 'invalid', 'FLAT', 'Brand-Theme', 'AUTO', + 'auto', 'none', 'custom', '', @@ -293,13 +294,13 @@ describe('Property 2: directoryStrategy enum validation', () => { 'brandTheme', ]; - it.each(validValues)('accepts valid directoryStrategy: %s', (value) => { - const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); + it.each(validValues)('accepts valid scopeStrategy: %s', (value) => { + const result = TokensConfigSchema.safeParse({ scopeStrategy: value }); expect(result.success).toBe(true); }); - it.each(invalidValues)('rejects invalid directoryStrategy: %s', (value) => { - const result = TokensConfigSchema.safeParse({ directoryStrategy: value }); + it.each(invalidValues)('rejects invalid scopeStrategy: %s', (value) => { + const result = TokensConfigSchema.safeParse({ scopeStrategy: value }); expect(result.success).toBe(false); }); }); @@ -396,7 +397,7 @@ describe('Property 21: Backward-compatible config parsing', () => { expect(parsed.ds.tokens).toBeDefined(); expect(parsed.ds.tokens.filePattern).toBe('**/semantic.css'); expect(parsed.ds.tokens.propertyPrefix).toBeNull(); - expect(parsed.ds.tokens.directoryStrategy).toBe('flat'); + 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 40c6394..0e87201 100644 --- a/packages/angular-mcp/src/main.ts +++ b/packages/angular-mcp/src/main.ts @@ -51,9 +51,9 @@ const argv = yargs(hideBin(process.argv)) '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.directoryStrategy', { + .option('ds.tokens.scopeStrategy', { describe: - 'Controls how directory structure maps to token scopes. "flat" treats all files as one set, "brand-theme" derives brand/theme scope from path segments, "auto" infers from directory depth (default: "flat")', + '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', { @@ -92,7 +92,7 @@ const { workspaceRoot, ds } = argv as unknown as { tokens?: { filePattern?: string; propertyPrefix?: string; - directoryStrategy?: string; + scopeStrategy?: string; categoryInference?: string; }; }; diff --git a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts index d737694..ec8c97a 100644 --- a/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts +++ b/packages/shared/styles-ast-utils/src/lib/scss-value-parser.ts @@ -44,13 +44,6 @@ export interface ScssParseResult { getConsumptions(): ScssPropertyEntry[]; } -/** - * Options for the SCSS Value Parser. - * Reserved for future extensions. - */ -// eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface ScssValueParserOptions {} - const VAR_REFERENCE_PATTERN = /var\(\s*--[\w-]+/; /** @@ -77,10 +70,7 @@ function resolveSelector(node: Declaration): string { * - `consumption`: value contains a `var(--*)` reference * - `plain`: neither */ -function classifyEntry( - property: string, - value: string, -): ScssClassification { +function classifyEntry(property: string, value: string): ScssClassification { if (property.startsWith('--')) { return 'declaration'; } @@ -115,7 +105,6 @@ function createParseResult(entries: ScssPropertyEntry[]): ScssParseResult { export async function parseScssContent( content: string, filePath: string, - options?: ScssValueParserOptions, ): Promise { const entries: ScssPropertyEntry[] = []; @@ -143,7 +132,6 @@ export async function parseScssContent( */ export async function parseScssValues( filePath: string, - options?: ScssValueParserOptions, ): Promise { let content: string; try { @@ -151,5 +139,5 @@ export async function parseScssValues( } catch { return createParseResult([]); } - return parseScssContent(content, filePath, options); + return parseScssContent(content, filePath); } From 2710320bb38a08040fb9efe1ea81c794d7e3788f Mon Sep 17 00:00:00 2001 From: "Szymon.Poltorak" Date: Tue, 14 Apr 2026 12:26:24 +0200 Subject: [PATCH 6/6] docs: add token dataset storage model --- docs/architecture-internal-design.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/architecture-internal-design.md b/docs/architecture-internal-design.md index 7bdf006..0864f9c 100644 --- a/docs/architecture-internal-design.md +++ b/docs/architecture-internal-design.md @@ -117,6 +117,19 @@ These options control how design tokens are discovered, organised, and categoris 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