This is page 7 of 10. Use http://codebase.md/push-based/angular-toolkit-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .aiignore ├── .cursor │ ├── flows │ │ ├── component-refactoring │ │ │ ├── 01-review-component.mdc │ │ │ ├── 02-refactor-component.mdc │ │ │ ├── 03-validate-component.mdc │ │ │ └── angular-20.md │ │ ├── ds-refactoring-flow │ │ │ ├── 01-find-violations.mdc │ │ │ ├── 01b-find-all-violations.mdc │ │ │ ├── 02-plan-refactoring.mdc │ │ │ ├── 02b-plan-refactoring-for-all-violations.mdc │ │ │ ├── 03-fix-violations.mdc │ │ │ ├── 03-non-viable-cases.mdc │ │ │ ├── 04-validate-changes.mdc │ │ │ ├── 05-prepare-report.mdc │ │ │ └── clean-global-styles.mdc │ │ └── README.md │ └── mcp.json.example ├── .github │ └── workflows │ └── ci.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc ├── assets │ ├── entain-logo.png │ └── entain.png ├── CONTRIBUTING.MD ├── docs │ ├── architecture-internal-design.md │ ├── component-refactoring-flow.md │ ├── contracts.md │ ├── ds-refactoring-flow.md │ ├── getting-started.md │ ├── README.md │ ├── tools.md │ └── writing-custom-tools.md ├── eslint.config.mjs ├── jest.config.ts ├── jest.preset.mjs ├── LICENSE ├── nx.json ├── package-lock.json ├── package.json ├── packages │ ├── .gitkeep │ ├── angular-mcp │ │ ├── eslint.config.mjs │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── assets │ │ │ │ └── .gitkeep │ │ │ └── main.ts │ │ ├── tsconfig.app.json │ │ ├── tsconfig.json │ │ ├── vitest.config.mts │ │ └── webpack.config.cjs │ ├── angular-mcp-server │ │ ├── eslint.config.mjs │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── angular-mcp-server.ts │ │ │ ├── prompts │ │ │ │ └── prompt-registry.ts │ │ │ ├── tools │ │ │ │ ├── ds │ │ │ │ │ ├── component │ │ │ │ │ │ ├── get-deprecated-css-classes.tool.ts │ │ │ │ │ │ ├── get-ds-component-data.tool.ts │ │ │ │ │ │ ├── list-ds-components.tool.ts │ │ │ │ │ │ └── utils │ │ │ │ │ │ ├── deprecated-css-helpers.ts │ │ │ │ │ │ ├── doc-helpers.ts │ │ │ │ │ │ ├── metadata-helpers.ts │ │ │ │ │ │ └── paths-helpers.ts │ │ │ │ │ ├── component-contract │ │ │ │ │ │ ├── builder │ │ │ │ │ │ │ ├── build-component-contract.tool.ts │ │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ │ ├── schema.ts │ │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ ├── spec │ │ │ │ │ │ │ │ ├── css-match.spec.ts │ │ │ │ │ │ │ │ ├── dom-slots.extractor.spec.ts │ │ │ │ │ │ │ │ ├── element-helpers.spec.ts │ │ │ │ │ │ │ │ ├── inline-styles.collector.spec.ts │ │ │ │ │ │ │ │ ├── meta.generator.spec.ts │ │ │ │ │ │ │ │ ├── public-api.extractor.spec.ts │ │ │ │ │ │ │ │ ├── styles.collector.spec.ts │ │ │ │ │ │ │ │ └── typescript-analyzer.spec.ts │ │ │ │ │ │ │ └── utils │ │ │ │ │ │ │ ├── build-contract.ts │ │ │ │ │ │ │ ├── css-match.ts │ │ │ │ │ │ │ ├── dom-slots.extractor.ts │ │ │ │ │ │ │ ├── element-helpers.ts │ │ │ │ │ │ │ ├── inline-styles.collector.ts │ │ │ │ │ │ │ ├── meta.generator.ts │ │ │ │ │ │ │ ├── public-api.extractor.ts │ │ │ │ │ │ │ ├── styles.collector.ts │ │ │ │ │ │ │ └── typescript-analyzer.ts │ │ │ │ │ │ ├── diff │ │ │ │ │ │ │ ├── diff-component-contract.tool.ts │ │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ │ └── schema.ts │ │ │ │ │ │ │ ├── spec │ │ │ │ │ │ │ │ ├── diff-utils.spec.ts │ │ │ │ │ │ │ │ └── dom-path-utils.spec.ts │ │ │ │ │ │ │ └── utils │ │ │ │ │ │ │ ├── diff-utils.ts │ │ │ │ │ │ │ └── dom-path-utils.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── list │ │ │ │ │ │ │ ├── list-component-contracts.tool.ts │ │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ │ ├── schema.ts │ │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ │ ├── spec │ │ │ │ │ │ │ │ └── contract-list-utils.spec.ts │ │ │ │ │ │ │ └── utils │ │ │ │ │ │ │ └── contract-list-utils.ts │ │ │ │ │ │ └── shared │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── spec │ │ │ │ │ │ │ └── contract-file-ops.spec.ts │ │ │ │ │ │ └── utils │ │ │ │ │ │ └── contract-file-ops.ts │ │ │ │ │ ├── component-usage-graph │ │ │ │ │ │ ├── build-component-usage-graph.tool.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ ├── config.ts │ │ │ │ │ │ │ ├── schema.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ └── utils │ │ │ │ │ │ ├── angular-parser.ts │ │ │ │ │ │ ├── component-helpers.ts │ │ │ │ │ │ ├── component-usage-graph-builder.ts │ │ │ │ │ │ ├── path-resolver.ts │ │ │ │ │ │ └── unified-ast-analyzer.ts │ │ │ │ │ ├── ds.tools.ts │ │ │ │ │ ├── project │ │ │ │ │ │ ├── get-project-dependencies.tool.ts │ │ │ │ │ │ ├── report-deprecated-css.tool.ts │ │ │ │ │ │ └── utils │ │ │ │ │ │ ├── dependencies-helpers.ts │ │ │ │ │ │ └── styles-report-helpers.ts │ │ │ │ │ ├── report-violations │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ ├── schema.ts │ │ │ │ │ │ │ └── types.ts │ │ │ │ │ │ ├── report-all-violations.tool.ts │ │ │ │ │ │ └── report-violations.tool.ts │ │ │ │ │ ├── shared │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ ├── input-schemas.model.ts │ │ │ │ │ │ │ └── schema-helpers.ts │ │ │ │ │ │ ├── utils │ │ │ │ │ │ │ ├── component-validation.ts │ │ │ │ │ │ │ ├── cross-platform-path.ts │ │ │ │ │ │ │ ├── handler-helpers.ts │ │ │ │ │ │ │ ├── output.utils.ts │ │ │ │ │ │ │ └── regex-helpers.ts │ │ │ │ │ │ └── violation-analysis │ │ │ │ │ │ ├── base-analyzer.ts │ │ │ │ │ │ ├── coverage-analyzer.ts │ │ │ │ │ │ ├── formatters.ts │ │ │ │ │ │ ├── index.ts │ │ │ │ │ │ └── types.ts │ │ │ │ │ └── tools.ts │ │ │ │ ├── schema.ts │ │ │ │ ├── tools.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils.ts │ │ │ └── validation │ │ │ ├── angular-mcp-server-options.schema.ts │ │ │ ├── ds-components-file-loader.validation.ts │ │ │ ├── ds-components-file.validation.ts │ │ │ ├── ds-components.schema.ts │ │ │ └── file-existence.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.tsbuildinfo │ │ └── vitest.config.mts │ ├── minimal-repo │ │ └── packages │ │ ├── application │ │ │ ├── angular.json │ │ │ ├── code-pushup.config.ts │ │ │ ├── src │ │ │ │ ├── app │ │ │ │ │ ├── app.component.ts │ │ │ │ │ ├── app.config.ts │ │ │ │ │ ├── app.routes.ts │ │ │ │ │ ├── components │ │ │ │ │ │ ├── refactoring-tests │ │ │ │ │ │ │ ├── bad-alert-tooltip-input.component.ts │ │ │ │ │ │ │ ├── bad-alert.component.ts │ │ │ │ │ │ │ ├── bad-button-dropdown.component.ts │ │ │ │ │ │ │ ├── bad-document.component.ts │ │ │ │ │ │ │ ├── bad-global-this.component.ts │ │ │ │ │ │ │ ├── bad-mixed-external-assets.component.css │ │ │ │ │ │ │ ├── bad-mixed-external-assets.component.html │ │ │ │ │ │ │ ├── bad-mixed-external-assets.component.ts │ │ │ │ │ │ │ ├── bad-mixed-not-standalone.component.ts │ │ │ │ │ │ │ ├── bad-mixed.component.ts │ │ │ │ │ │ │ ├── bad-mixed.module.ts │ │ │ │ │ │ │ ├── bad-modal-progress.component.ts │ │ │ │ │ │ │ ├── bad-this-window-document.component.ts │ │ │ │ │ │ │ ├── bad-window.component.ts │ │ │ │ │ │ │ ├── complex-components │ │ │ │ │ │ │ │ ├── first-case │ │ │ │ │ │ │ │ │ ├── dashboard-demo.component.html │ │ │ │ │ │ │ │ │ ├── dashboard-demo.component.scss │ │ │ │ │ │ │ │ │ ├── dashboard-demo.component.ts │ │ │ │ │ │ │ │ │ ├── dashboard-header.component.html │ │ │ │ │ │ │ │ │ ├── dashboard-header.component.scss │ │ │ │ │ │ │ │ │ └── dashboard-header.component.ts │ │ │ │ │ │ │ │ ├── second-case │ │ │ │ │ │ │ │ │ ├── complex-badge-widget.component.scss │ │ │ │ │ │ │ │ │ ├── complex-badge-widget.component.ts │ │ │ │ │ │ │ │ │ └── complex-widget-demo.component.ts │ │ │ │ │ │ │ │ └── third-case │ │ │ │ │ │ │ │ ├── product-card.component.scss │ │ │ │ │ │ │ │ ├── product-card.component.ts │ │ │ │ │ │ │ │ └── product-showcase.component.ts │ │ │ │ │ │ │ ├── group-1 │ │ │ │ │ │ │ │ ├── bad-mixed-1.component.ts │ │ │ │ │ │ │ │ ├── bad-mixed-1.module.ts │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-1.component.css │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-1.component.html │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-1.component.ts │ │ │ │ │ │ │ │ └── bad-mixed-not-standalone-1.component.ts │ │ │ │ │ │ │ ├── group-2 │ │ │ │ │ │ │ │ ├── bad-mixed-2.component.ts │ │ │ │ │ │ │ │ ├── bad-mixed-2.module.ts │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-2.component.css │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-2.component.html │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-2.component.ts │ │ │ │ │ │ │ │ └── bad-mixed-not-standalone-2.component.ts │ │ │ │ │ │ │ ├── group-3 │ │ │ │ │ │ │ │ ├── bad-mixed-3.component.spec.ts │ │ │ │ │ │ │ │ ├── bad-mixed-3.component.ts │ │ │ │ │ │ │ │ ├── bad-mixed-3.module.ts │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-3.component.css │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-3.component.html │ │ │ │ │ │ │ │ ├── bad-mixed-external-assets-3.component.ts │ │ │ │ │ │ │ │ ├── bad-mixed-not-standalone-3.component.ts │ │ │ │ │ │ │ │ └── lazy-loader-3.component.ts │ │ │ │ │ │ │ └── group-4 │ │ │ │ │ │ │ ├── multi-violation-test.component.html │ │ │ │ │ │ │ ├── multi-violation-test.component.scss │ │ │ │ │ │ │ └── multi-violation-test.component.ts │ │ │ │ │ │ └── validation-tests │ │ │ │ │ │ ├── circular-dependency.component.ts │ │ │ │ │ │ ├── external-files-missing.component.ts │ │ │ │ │ │ ├── invalid-lifecycle.component.ts │ │ │ │ │ │ ├── invalid-pipe-usage.component.ts │ │ │ │ │ │ ├── invalid-template-syntax.component.ts │ │ │ │ │ │ ├── missing-imports.component.ts │ │ │ │ │ │ ├── missing-method.component.ts │ │ │ │ │ │ ├── README.md │ │ │ │ │ │ ├── standalone-module-conflict.component.ts │ │ │ │ │ │ ├── standalone-module-conflict.module.ts │ │ │ │ │ │ ├── template-reference-error.component.ts │ │ │ │ │ │ ├── type-mismatch.component.ts │ │ │ │ │ │ ├── valid.component.ts │ │ │ │ │ │ ├── wrong-decorator-usage.component.ts │ │ │ │ │ │ └── wrong-property-binding.component.ts │ │ │ │ │ └── styles │ │ │ │ │ ├── bad-global-styles.scss │ │ │ │ │ ├── base │ │ │ │ │ │ ├── _reset.scss │ │ │ │ │ │ └── base.scss │ │ │ │ │ ├── components │ │ │ │ │ │ └── components.scss │ │ │ │ │ ├── extended-deprecated-styles.scss │ │ │ │ │ ├── layout │ │ │ │ │ │ └── layout.scss │ │ │ │ │ ├── new-styles-1.scss │ │ │ │ │ ├── new-styles-10.scss │ │ │ │ │ ├── new-styles-2.scss │ │ │ │ │ ├── new-styles-3.scss │ │ │ │ │ ├── new-styles-4.scss │ │ │ │ │ ├── new-styles-5.scss │ │ │ │ │ ├── new-styles-6.scss │ │ │ │ │ ├── new-styles-7.scss │ │ │ │ │ ├── new-styles-8.scss │ │ │ │ │ ├── new-styles-9.scss │ │ │ │ │ ├── themes │ │ │ │ │ │ └── themes.scss │ │ │ │ │ └── utilities │ │ │ │ │ └── utilities.scss │ │ │ │ ├── index.html │ │ │ │ ├── main.ts │ │ │ │ └── styles.css │ │ │ ├── tsconfig.app.json │ │ │ ├── tsconfig.json │ │ │ └── tsconfig.spec.json │ │ └── design-system │ │ ├── component-options.mjs │ │ ├── storybook │ │ │ └── card │ │ │ └── card-tabs │ │ │ └── overview.mdx │ │ ├── storybook-host-app │ │ │ └── src │ │ │ └── components │ │ │ ├── badge │ │ │ │ ├── badge-tabs │ │ │ │ │ ├── api.mdx │ │ │ │ │ ├── examples.mdx │ │ │ │ │ └── overview.mdx │ │ │ │ ├── badge.component.mdx │ │ │ │ └── badge.component.stories.ts │ │ │ ├── modal │ │ │ │ ├── demo-cdk-dialog-cmp.component.ts │ │ │ │ ├── demo-modal-cmp.component.ts │ │ │ │ ├── modal-tabs │ │ │ │ │ ├── api.mdx │ │ │ │ │ ├── examples.mdx │ │ │ │ │ └── overview.mdx │ │ │ │ ├── modal.component.mdx │ │ │ │ └── modal.component.stories.ts │ │ │ └── segmented-control │ │ │ ├── segmented-control-tabs │ │ │ │ ├── api.mdx │ │ │ │ ├── examples.mdx │ │ │ │ └── overview.mdx │ │ │ ├── segmented-control.component.mdx │ │ │ └── segmented-control.component.stories.ts │ │ └── ui │ │ ├── badge │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ └── src │ │ │ └── badge.component.ts │ │ ├── modal │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ └── src │ │ │ ├── modal-content.component.ts │ │ │ ├── modal-header │ │ │ │ └── modal-header.component.ts │ │ │ ├── modal-header-drag │ │ │ │ └── modal-header-drag.component.ts │ │ │ └── modal.component.ts │ │ ├── rx-host-listener │ │ │ ├── package.json │ │ │ ├── project.json │ │ │ └── src │ │ │ └── rx-host-listener.ts │ │ └── segmented-control │ │ ├── package.json │ │ ├── project.json │ │ └── src │ │ ├── segmented-control.component.html │ │ ├── segmented-control.component.ts │ │ ├── segmented-control.token.ts │ │ └── segmented-option.component.ts │ └── shared │ ├── angular-ast-utils │ │ ├── .spec.swcrc │ │ ├── ai │ │ │ ├── API.md │ │ │ ├── EXAMPLES.md │ │ │ └── FUNCTIONS.md │ │ ├── docs │ │ │ └── angular-component-tree.md │ │ ├── eslint.config.mjs │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── constants.ts │ │ │ ├── decorator-config.visitor.inline-styles.spec.ts │ │ │ ├── decorator-config.visitor.spec.ts │ │ │ ├── decorator-config.visitor.ts │ │ │ ├── parse-component.ts │ │ │ ├── schema.ts │ │ │ ├── styles │ │ │ │ └── utils.ts │ │ │ ├── template │ │ │ │ ├── noop-tmpl-visitor.ts │ │ │ │ ├── template.walk.ts │ │ │ │ ├── utils.spec.ts │ │ │ │ ├── utils.ts │ │ │ │ └── utils.unit.test.ts │ │ │ ├── ts.walk.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ └── vitest.config.mts │ ├── DEPENDENCIES.md │ ├── ds-component-coverage │ │ ├── .spec.swcrc │ │ ├── ai │ │ │ ├── API.md │ │ │ ├── EXAMPLES.md │ │ │ └── FUNCTIONS.md │ │ ├── docs │ │ │ ├── examples │ │ │ │ ├── report.json │ │ │ │ └── report.md │ │ │ ├── images │ │ │ │ └── report-overview.png │ │ │ └── README.md │ │ ├── jest.config.ts │ │ ├── mocks │ │ │ └── fixtures │ │ │ └── e2e │ │ │ ├── asset-location │ │ │ │ ├── code-pushup.config.ts │ │ │ │ ├── inl-styl-inl-tmpl │ │ │ │ │ └── inl-styl-inl-tmpl.component.ts │ │ │ │ ├── inl-styl-url-tmpl │ │ │ │ │ ├── inl-styl-url-tmpl.component.html │ │ │ │ │ └── inl-styl-url-tmpl.component.ts │ │ │ │ ├── multi-url-styl-inl-tmpl │ │ │ │ │ ├── multi-url-styl-inl-tmpl-1.component.css │ │ │ │ │ ├── multi-url-styl-inl-tmpl-2.component.css │ │ │ │ │ └── multi-url-styl-inl-tmpl.component.ts │ │ │ │ ├── url-styl-inl-tmpl │ │ │ │ │ ├── url-styl-inl-tmpl.component.css │ │ │ │ │ └── url-styl-inl-tmpl.component.ts │ │ │ │ ├── url-styl-single-inl-tmpl │ │ │ │ │ ├── url-styl-inl-tmpl.component.ts │ │ │ │ │ └── url-styl-single-inl-tmpl.component.css │ │ │ │ └── url-styl-url-tmpl │ │ │ │ ├── inl-styl-url-tmpl.component.css │ │ │ │ ├── inl-styl-url-tmpl.component.html │ │ │ │ └── inl-styl-url-tmpl.component.ts │ │ │ ├── demo │ │ │ │ ├── code-pushup.config.ts │ │ │ │ ├── prompt.md │ │ │ │ └── src │ │ │ │ ├── bad-button-dropdown.component.ts │ │ │ │ ├── bad-modal-progress.component.ts │ │ │ │ ├── mixed-external-assets.component.css │ │ │ │ ├── mixed-external-assets.component.html │ │ │ │ ├── mixed-external-assets.component.ts │ │ │ │ └── sub-folder-1 │ │ │ │ ├── bad-alert.component.ts │ │ │ │ ├── button.component.ts │ │ │ │ └── sub-folder-2 │ │ │ │ ├── bad-alert-tooltip-input.component.ts │ │ │ │ └── bad-mixed.component.ts │ │ │ ├── line-number │ │ │ │ ├── code-pushup.config.ts │ │ │ │ ├── inl-styl-single.component.ts │ │ │ │ ├── inl-styl-span.component.ts │ │ │ │ ├── inl-tmpl-single.component.ts │ │ │ │ ├── inl-tmpl-span.component.ts │ │ │ │ ├── url-style │ │ │ │ │ ├── url-styl-single.component.css │ │ │ │ │ ├── url-styl-single.component.ts │ │ │ │ │ ├── url-styl-span.component.css │ │ │ │ │ └── url-styl-span.component.ts │ │ │ │ └── url-tmpl │ │ │ │ ├── url-tmpl-single.component.html │ │ │ │ ├── url-tmpl-single.component.ts │ │ │ │ ├── url-tmpl-span.component.html │ │ │ │ └── url-tmpl-span.component.ts │ │ │ ├── style-format │ │ │ │ ├── code-pushup.config.ts │ │ │ │ ├── inl-css.component.ts │ │ │ │ ├── inl-scss.component.ts │ │ │ │ ├── styles.css │ │ │ │ ├── styles.scss │ │ │ │ ├── url-css.component.ts │ │ │ │ └── url-scss.component.ts │ │ │ └── template-syntax │ │ │ ├── class-attribute.component.ts │ │ │ ├── class-binding.component.ts │ │ │ ├── code-pushup.config.ts │ │ │ └── ng-class-binding.component.ts │ │ ├── package.json │ │ ├── src │ │ │ ├── core.config.ts │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── constants.ts │ │ │ ├── ds-component-coverage.plugin.ts │ │ │ ├── runner │ │ │ │ ├── audits │ │ │ │ │ └── ds-coverage │ │ │ │ │ ├── class-definition.utils.ts │ │ │ │ │ ├── class-definition.visitor.ts │ │ │ │ │ ├── class-definition.visitor.unit.test.ts │ │ │ │ │ ├── class-usage.utils.ts │ │ │ │ │ ├── class-usage.visitor.spec.ts │ │ │ │ │ ├── class-usage.visitor.ts │ │ │ │ │ ├── constants.ts │ │ │ │ │ ├── ds-coverage.audit.ts │ │ │ │ │ ├── schema.ts │ │ │ │ │ └── utils.ts │ │ │ │ ├── create-runner.ts │ │ │ │ └── schema.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ └── vitest.config.mts │ ├── LLMS.md │ ├── models │ │ ├── ai │ │ │ ├── API.md │ │ │ ├── EXAMPLES.md │ │ │ └── FUNCTIONS.md │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── cli.ts │ │ │ ├── diagnostics.ts │ │ │ └── mcp.ts │ │ ├── tsconfig.json │ │ └── tsconfig.lib.json │ ├── styles-ast-utils │ │ ├── .spec.swcrc │ │ ├── ai │ │ │ ├── API.md │ │ │ ├── EXAMPLES.md │ │ │ └── FUNCTIONS.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── postcss-safe-parser.d.ts │ │ │ ├── styles-ast-utils.spec.ts │ │ │ ├── styles-ast-utils.ts │ │ │ ├── stylesheet.parse.ts │ │ │ ├── stylesheet.parse.unit.test.ts │ │ │ ├── stylesheet.visitor.ts │ │ │ ├── stylesheet.walk.ts │ │ │ ├── types.ts │ │ │ ├── utils.ts │ │ │ └── utils.unit.test.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ └── vitest.config.mts │ ├── typescript-ast-utils │ │ ├── .spec.swcrc │ │ ├── ai │ │ │ ├── API.md │ │ │ ├── EXAMPLES.md │ │ │ └── FUNCTIONS.md │ │ ├── jest.config.ts │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── constants.ts │ │ │ └── utils.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ └── vitest.config.mts │ └── utils │ ├── .spec.swcrc │ ├── ai │ │ ├── API.md │ │ ├── EXAMPLES.md │ │ └── FUNCTIONS.md │ ├── package.json │ ├── README.md │ ├── src │ │ ├── index.ts │ │ └── lib │ │ ├── execute-process.ts │ │ ├── execute-process.unit.test.ts │ │ ├── file │ │ │ ├── default-export-loader.spec.ts │ │ │ ├── default-export-loader.ts │ │ │ ├── file.resolver.ts │ │ │ └── find-in-file.ts │ │ ├── format-command-log.integration.test.ts │ │ ├── format-command-log.ts │ │ ├── logging.ts │ │ └── utils.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ ├── vite.config.ts │ └── vitest.config.mts ├── README.md ├── testing │ ├── setup │ │ ├── eslint.config.mjs │ │ ├── eslint.next.config.mjs │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.d.ts │ │ │ ├── index.mjs │ │ │ └── memfs.constants.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ ├── vitest.config.mts │ │ └── vitest.integration.config.mts │ ├── utils │ │ ├── eslint.config.mjs │ │ ├── eslint.next.config.mjs │ │ ├── package.json │ │ ├── README.md │ │ ├── src │ │ │ ├── index.ts │ │ │ └── lib │ │ │ ├── constants.ts │ │ │ ├── e2e-setup.ts │ │ │ ├── execute-process-helper.mock.ts │ │ │ ├── execute-process.mock.mjs │ │ │ ├── os-agnostic-paths.ts │ │ │ ├── os-agnostic-paths.unit.test.ts │ │ │ ├── source-file-from.code.ts │ │ │ └── string.ts │ │ ├── tsconfig.json │ │ ├── tsconfig.lib.json │ │ ├── tsconfig.spec.json │ │ ├── vite.config.ts │ │ ├── vitest.config.mts │ │ └── vitest.integration.config.mts │ └── vitest-setup │ ├── eslint.config.mjs │ ├── eslint.next.config.mjs │ ├── package.json │ ├── README.md │ ├── src │ │ ├── index.ts │ │ └── lib │ │ ├── configuration.ts │ │ └── fs-memfs.setup-file.ts │ ├── tsconfig.json │ ├── tsconfig.lib.json │ ├── tsconfig.spec.json │ ├── vite.config.ts │ ├── vitest.config.mts │ └── vitest.integration.config.mts ├── tools │ ├── nx-advanced-profile.bin.js │ ├── nx-advanced-profile.js │ ├── nx-advanced-profile.postinstall.js │ └── perf_hooks.patch.js ├── tsconfig.base.json ├── tsconfig.json └── vitest.workspace.ts ``` # Files -------------------------------------------------------------------------------- /packages/shared/ds-component-coverage/src/lib/runner/audits/ds-coverage/class-usage.visitor.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | ASTWithSource, 3 | TmplAstBoundAttribute, 4 | TmplAstBoundEvent, 5 | TmplAstBoundText, 6 | TmplAstContent, 7 | TmplAstDeferredBlock, 8 | TmplAstDeferredBlockError, 9 | TmplAstDeferredBlockLoading, 10 | TmplAstDeferredBlockPlaceholder, 11 | TmplAstDeferredTrigger, 12 | TmplAstElement, 13 | TmplAstForLoopBlock, 14 | TmplAstForLoopBlockEmpty, 15 | TmplAstIcu, 16 | TmplAstIfBlock, 17 | TmplAstIfBlockBranch, 18 | TmplAstLetDeclaration, 19 | TmplAstReference, 20 | TmplAstSwitchBlock, 21 | TmplAstSwitchBlockCase, 22 | TmplAstTemplate, 23 | TmplAstText, 24 | TmplAstTextAttribute, 25 | TmplAstUnknownBlock, 26 | TmplAstVariable, 27 | TmplAstVisitor, 28 | } from '@angular/compiler' with { 'resolution-mode': 'import' }; 29 | import { Issue } from '@code-pushup/models'; 30 | import { DiagnosticsAware } from '@push-based/models'; 31 | 32 | import { 33 | tmplAstElementToSource, 34 | parseClassNames, 35 | extractClassNamesFromNgClassAST, 36 | } from '@push-based/angular-ast-utils'; 37 | 38 | import { 39 | EXTERNAL_ASSET_ICON, 40 | INLINE_ASSET_ICON, 41 | TEMPLATE_ASSET_ICON, 42 | } from './constants.js'; 43 | 44 | import { ComponentReplacement } from './schema.js'; 45 | 46 | function generateClassUsageMessage({ 47 | element, 48 | className, 49 | attribute, 50 | componentName = 'a DS component', 51 | docsUrl, 52 | }: { 53 | element: TmplAstElement; 54 | className: string; 55 | attribute: string; 56 | } & Pick<ComponentReplacement, 'docsUrl' | 'componentName'>): string { 57 | const elementName = element.name; 58 | const isInline = element.sourceSpan.start.file.url.match(/\.ts$/) != null; 59 | const iconString = `${ 60 | isInline ? INLINE_ASSET_ICON : EXTERNAL_ASSET_ICON 61 | }${TEMPLATE_ASSET_ICON} `; 62 | const docsLink = docsUrl 63 | ? ` <a href="${docsUrl}" target="_blank">Learn more</a>.` 64 | : ''; 65 | return `${iconString} Element <code>${elementName}</code> in attribute <code>${attribute}</code> uses deprecated class <code>${className}</code>. Use <code>${componentName}</code> instead.${docsLink}`; 66 | } 67 | 68 | export class ClassUsageVisitor 69 | implements TmplAstVisitor<void>, DiagnosticsAware 70 | { 71 | private issues: Issue[] = []; 72 | private currentElement: TmplAstElement | null = null; 73 | 74 | constructor( 75 | private readonly componentReplacement: ComponentReplacement, 76 | private readonly startLine = 0, 77 | ) {} 78 | 79 | getIssues(): Issue[] { 80 | return this.issues; 81 | } 82 | 83 | clear(): void { 84 | this.issues = []; 85 | } 86 | 87 | visitElement(element: TmplAstElement): void { 88 | this.currentElement = element; 89 | 90 | element.attributes.forEach((attr) => attr.visit(this)); // Check `class="..."` 91 | element.inputs.forEach((input) => input.visit(this)); // Check `[class.foo]`, `[ngClass]` 92 | 93 | element.children.forEach((child) => child.visit(this)); 94 | 95 | this.currentElement = null; 96 | } 97 | 98 | visitTextAttribute(attribute: TmplAstTextAttribute): void { 99 | const { deprecatedCssClasses, ...compRepl } = this.componentReplacement; 100 | if (attribute.name === 'class' && this.currentElement) { 101 | const classNames = parseClassNames(attribute.value); 102 | const deprecatedClassesFound = classNames.filter((cn) => 103 | deprecatedCssClasses.includes(cn), 104 | ); 105 | 106 | if (deprecatedClassesFound.length > 0) { 107 | const isInline = 108 | attribute.sourceSpan.start.file.url.match(/\.ts$/) != null; 109 | const startLine = isInline ? this.startLine : 0; 110 | 111 | this.issues.push({ 112 | severity: 'error', 113 | message: generateClassUsageMessage({ 114 | ...compRepl, 115 | element: this.currentElement, 116 | className: deprecatedClassesFound.join(', '), 117 | attribute: `${attribute.name}`, 118 | }), 119 | source: tmplAstElementToSource(this.currentElement, startLine), 120 | }); 121 | } 122 | } 123 | } 124 | 125 | visitBoundAttribute(attribute: TmplAstBoundAttribute): void { 126 | if (!this.currentElement) return; 127 | 128 | const { deprecatedCssClasses, ...compRepl } = this.componentReplacement; 129 | 130 | // Check `[class.foo]` 131 | // BindingType.Class === 2 132 | if (attribute.type === 2 && deprecatedCssClasses.includes(attribute.name)) { 133 | this.issues.push({ 134 | severity: 'error', // @TODO if we consider transformations this needs to be dynamic 135 | message: generateClassUsageMessage({ 136 | element: this.currentElement, 137 | className: attribute.name, 138 | attribute: '[class.*]', 139 | componentName: this.componentReplacement.componentName, 140 | docsUrl: this.componentReplacement.docsUrl, 141 | }), 142 | source: tmplAstElementToSource(this.currentElement, this.startLine), 143 | }); 144 | } 145 | 146 | // Handle class="..." with interpolation and [ngClass] 147 | if (attribute.name === 'class' || attribute.name === 'ngClass') { 148 | const value: ASTWithSource = attribute.value as ASTWithSource; 149 | 150 | // Use AST-based parsing for both [class] and [ngClass] to avoid false positives 151 | // For simple string literals, the AST parsing will still work correctly 152 | const foundClassNames = extractClassNamesFromNgClassAST( 153 | value.ast, 154 | deprecatedCssClasses, 155 | ); 156 | 157 | // Create single issue for all found deprecated classes 158 | if (foundClassNames.length > 0 && this.currentElement) { 159 | this.issues.push({ 160 | severity: 'error', // @TODO if we consider transformations this needs to be dynamic 161 | message: generateClassUsageMessage({ 162 | ...compRepl, 163 | element: this.currentElement, 164 | className: foundClassNames.join(', '), 165 | attribute: 166 | attribute.name === 'ngClass' 167 | ? `[${attribute.name}]` 168 | : attribute.name, 169 | }), 170 | source: tmplAstElementToSource(this.currentElement, this.startLine), 171 | }); 172 | } 173 | } 174 | } 175 | 176 | visitTemplate(template: TmplAstTemplate): void { 177 | template.children.forEach((child) => child.visit(this)); 178 | } 179 | 180 | visitContent(content: TmplAstContent): void { 181 | content.children.forEach((child) => child.visit(this)); 182 | } 183 | 184 | visitForLoopBlock(block: TmplAstForLoopBlock): void { 185 | block.children.forEach((child) => child.visit(this)); 186 | block.empty?.visit(this); 187 | } 188 | 189 | visitForLoopBlockEmpty(block: TmplAstForLoopBlockEmpty): void { 190 | block.children.forEach((child) => child.visit(this)); 191 | } 192 | 193 | visitIfBlock(block: TmplAstIfBlock): void { 194 | block.branches.forEach((branch) => branch.visit(this)); 195 | } 196 | 197 | visitIfBlockBranch(block: TmplAstIfBlockBranch): void { 198 | block.children.forEach((child) => child.visit(this)); 199 | } 200 | 201 | visitSwitchBlock(block: TmplAstSwitchBlock): void { 202 | block.cases.forEach((caseBlock) => caseBlock.visit(this)); 203 | } 204 | 205 | visitSwitchBlockCase(block: TmplAstSwitchBlockCase): void { 206 | block.children.forEach((child) => child.visit(this)); 207 | } 208 | 209 | visitDeferredBlock(deferred: TmplAstDeferredBlock): void { 210 | deferred.visitAll(this); 211 | } 212 | 213 | visitDeferredBlockError(block: TmplAstDeferredBlockError): void { 214 | block.children.forEach((child) => child.visit(this)); 215 | } 216 | 217 | visitDeferredBlockLoading(block: TmplAstDeferredBlockLoading): void { 218 | block.children.forEach((child) => child.visit(this)); 219 | } 220 | 221 | visitDeferredBlockPlaceholder(block: TmplAstDeferredBlockPlaceholder): void { 222 | block.children.forEach((child) => child.visit(this)); 223 | } 224 | 225 | // -- No-op Methods -- 226 | /* eslint-disable @typescript-eslint/no-empty-function */ 227 | visitVariable(_variable: TmplAstVariable): void {} 228 | 229 | visitReference(_reference: TmplAstReference): void {} 230 | 231 | visitText(_text: TmplAstText): void {} 232 | 233 | visitBoundText(_text: TmplAstBoundText): void {} 234 | 235 | visitIcu(_icu: TmplAstIcu): void {} 236 | 237 | visitBoundEvent(_event: TmplAstBoundEvent): void {} 238 | 239 | visitUnknownBlock(_block: TmplAstUnknownBlock): void {} 240 | 241 | visitDeferredTrigger(_trigger: TmplAstDeferredTrigger): void {} 242 | 243 | visitLetDeclaration(_decl: TmplAstLetDeclaration): void {} 244 | /* eslint-enable @typescript-eslint/no-empty-function */ 245 | } 246 | ``` -------------------------------------------------------------------------------- /packages/minimal-repo/packages/design-system/storybook-host-app/src/components/modal/demo-cdk-dialog-cmp.component.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | DIALOG_DATA, 3 | Dialog, 4 | DialogModule, 5 | DialogRef, 6 | } from '@angular/cdk/dialog'; 7 | import { 8 | AfterViewInit, 9 | ChangeDetectionStrategy, 10 | Component, 11 | ElementRef, 12 | Inject, 13 | Renderer2, 14 | ViewChild, 15 | booleanAttribute, 16 | inject, 17 | input, 18 | } from '@angular/core'; 19 | 20 | import { DemoCloseIconComponent } from '@design-system/storybook-demo-cmp-lib'; 21 | import { DsButton } from '@frontend/ui/button'; 22 | import { DsButtonIcon } from '@frontend/ui/button-icon'; 23 | import { 24 | DsModal, 25 | DsModalContent, 26 | DsModalHeader, 27 | DsModalHeaderDrag, 28 | DsModalHeaderVariant, 29 | DsModalVariant, 30 | } from '@frontend/ui/modal'; 31 | 32 | @Component({ 33 | selector: 'ds-demo-cdk-dialog-cmp', 34 | imports: [ 35 | DialogModule, 36 | DsButton, 37 | DsModalHeader, 38 | DsButtonIcon, 39 | DemoCloseIconComponent, 40 | DsModal, 41 | DsModalContent, 42 | DsModalHeaderDrag, 43 | ], 44 | standalone: true, 45 | template: ` 46 | <ds-modal 47 | [inverse]="data.inverse" 48 | [bottomSheet]="data.bottomSheet" 49 | [variant]="data.variant" 50 | > 51 | <ds-modal-header [variant]="data.headerVariant"> 52 | <ds-modal-header-drag #dragHandle /> 53 | <button slot="end" ds-button-icon size="small" (click)="close()"> 54 | <ds-demo-close-icon /> 55 | </button> 56 | </ds-modal-header> 57 | <!-- eslint-disable-next-line @angular-eslint/template/no-inline-styles --> 58 | <div style="height: 400px; width: 400px; overflow: auto"> 59 | <ds-modal-content> 60 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aliquam, 61 | ducimus, sequi! Ab consequatur earum expedita fugit illo illum in 62 | maiores nihil nostrum officiis ratione repellendus temporibus, vel! 63 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aliquam, 64 | ducimus, sequi! Ab consequatur earum expedita fugit illo illum in 65 | maiores nihil nostrum officiis ratione repellendus temporibus, vel! Lo 66 | rem ipsum dolor sit amet, consectetur adipisicing elit. Aliquam, 67 | ducimus, sequi! Ab consequatur earum expedita fugit illo illum in 68 | maiores nihil nostrum officiis ratione repellendus temporibus, vel! 69 | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Aliquam, 70 | ducimus, sequi! Ab consequatur earum expedita fugit illo illum in 71 | maiores nihil nostrum officiis ratione repellendus temporibus, vel! 72 | Lorem ipsum Lorem ipsum dolor sit amet, consectetur adipisicing elit. 73 | Aliquam, ducimus, sequi! Ab consequatur earum expedita fugit illo 74 | illum in maiores nihil nostrum officiis ratione repellendus 75 | temporibus, vel! dolor sit amet, consectetur adipisicing elit. 76 | Aliquam, ducimus, sequi! Ab consequatur earum expedita fugit illo 77 | illum in maiores nihil nostrum officiis ratione repellendus 78 | temporibus, vel! Lorem ipsum dolor sit amet, consectetur adipisicing 79 | elit. Aliquam, ducimus, sequi! Ab consequatur earum expedita fugit 80 | illo illum in maiores nihil nostrum officiis ratione repellendus 81 | temporibus, vel! Lorem ipsum dolor sit amet, consectetur adipisicing 82 | elit. Aliquam, ducimus, sequi! Ab consequatur earum expedita fugit 83 | illo illum in maiores nihil nostrum officiis ratione repellendus 84 | temporibus, vel! 85 | <br /> 86 | <br /> 87 | <b>Lorem ipsum dolor sit amet</b>, consectetur adipisicing elit. 88 | Aliquam, ducimus, sequi! Ab consequatur earum expedita fugit illo 89 | illum in maiores nihil nostrum officiis ratione repellendus 90 | temporibus, vel! 91 | <br /> 92 | <br /> 93 | <div class="footer-buttons"> 94 | <button 95 | ds-button 96 | [inverse]="data.inverse" 97 | kind="secondary" 98 | variant="outline" 99 | (click)="close()" 100 | > 101 | Outline Button 102 | </button> 103 | <button 104 | ds-button 105 | [inverse]="data.inverse" 106 | kind="primary" 107 | variant="filled" 108 | (click)="close()" 109 | > 110 | Filled Button 111 | </button> 112 | </div> 113 | </ds-modal-content> 114 | </div> 115 | </ds-modal> 116 | `, 117 | styles: [ 118 | ` 119 | ds-modal { 120 | width: 400px; 121 | min-height: 300px; 122 | } 123 | 124 | /* Bottom Sheet styles */ 125 | :host-context(.ds-bottom-sheet-panel) ds-modal { 126 | position: fixed; 127 | bottom: 0; 128 | left: 0; 129 | right: 0; 130 | margin-left: auto; 131 | margin-right: auto; 132 | } 133 | 134 | .footer-buttons { 135 | display: grid; 136 | grid-template-columns: 1fr 1fr; 137 | gap: 10px; 138 | } 139 | `, 140 | ], 141 | changeDetection: ChangeDetectionStrategy.OnPush, 142 | }) 143 | export class DemoCdkModalCmp implements AfterViewInit { 144 | @ViewChild('dragHandle', { static: true, read: ElementRef }) 145 | dragHandle!: ElementRef<HTMLElement>; 146 | @ViewChild(DsModal, { static: true, read: ElementRef }) 147 | modalElementRef!: ElementRef<HTMLElement>; 148 | 149 | private renderer = inject(Renderer2); 150 | private isDragging = false; 151 | private startX = 0; 152 | private startY = 0; 153 | private initialLeft = 0; 154 | private initialTop = 0; 155 | private moveListener?: () => void; 156 | private upListener?: () => void; 157 | 158 | constructor( 159 | private dialogRef: DialogRef, 160 | @Inject(DIALOG_DATA) 161 | public data: { headerVariant: string; inverse: boolean; variant: string }, 162 | ) {} 163 | 164 | ngAfterViewInit() { 165 | if (this.dragHandle) { 166 | this.renderer.listen( 167 | this.dragHandle.nativeElement, 168 | 'mousedown', 169 | (event: MouseEvent) => this.startDrag(event), 170 | ); 171 | } 172 | } 173 | 174 | startDrag(event: MouseEvent) { 175 | event.preventDefault(); 176 | this.isDragging = true; 177 | 178 | const dialogEl = this.modalElementRef.nativeElement; 179 | 180 | const rect = dialogEl.getBoundingClientRect(); 181 | this.startX = event.clientX; 182 | this.startY = event.clientY; 183 | this.initialLeft = rect.left; 184 | this.initialTop = rect.top; 185 | 186 | this.moveListener = this.renderer.listen('document', 'mousemove', (e) => 187 | this.onDragMove(e, dialogEl), 188 | ); 189 | this.upListener = this.renderer.listen('document', 'mouseup', () => 190 | this.endDrag(), 191 | ); 192 | } 193 | 194 | private onDragMove(event: MouseEvent, dialogEl: HTMLElement) { 195 | if (!this.isDragging) return; 196 | 197 | const deltaX = event.clientX - this.startX; 198 | const deltaY = event.clientY - this.startY; 199 | 200 | const left = this.initialLeft + deltaX; 201 | const top = this.initialTop + deltaY; 202 | 203 | // Apply updated position 204 | this.renderer.setStyle(dialogEl, 'position', 'fixed'); 205 | this.renderer.setStyle(dialogEl, 'left', `${left}px`); 206 | this.renderer.setStyle(dialogEl, 'top', `${top}px`); 207 | this.renderer.setStyle(dialogEl, 'margin', `0`); 208 | } 209 | 210 | private endDrag() { 211 | this.isDragging = false; 212 | this.moveListener?.(); 213 | this.upListener?.(); 214 | } 215 | 216 | close() { 217 | this.dialogRef.close(); 218 | } 219 | } 220 | 221 | @Component({ 222 | selector: 'ds-demo-cdk-dialog-container', 223 | imports: [DialogModule, DsButton], 224 | standalone: true, 225 | template: ` 226 | <button ds-button (click)="openDialog()">Open with CDK Dialog</button> 227 | `, 228 | changeDetection: ChangeDetectionStrategy.OnPush, 229 | }) 230 | export class DemoCdkModalContainer { 231 | dialog = inject(Dialog); 232 | 233 | headerVariant = input<DsModalHeaderVariant>(); 234 | inverse = input(false, { transform: booleanAttribute }); 235 | variant = input<DsModalVariant>(); 236 | bottomSheetInput = input(false, { transform: booleanAttribute }); 237 | 238 | openDialog() { 239 | const isBottomSheet = this.bottomSheetInput(); 240 | this.dialog.open(DemoCdkModalCmp, { 241 | panelClass: isBottomSheet ? 'ds-bottom-sheet-panel' : 'ds-dialog-panel', 242 | data: { 243 | headerVariant: this.headerVariant(), 244 | inverse: this.inverse(), 245 | variant: this.variant(), 246 | bottomSheet: isBottomSheet, 247 | }, 248 | }); 249 | } 250 | } 251 | ``` -------------------------------------------------------------------------------- /packages/angular-mcp-server/src/lib/tools/ds/component-usage-graph/utils/component-usage-graph-builder.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as path from 'path'; 2 | import { toUnixPath } from '@code-pushup/utils'; 3 | import { findAllFiles } from '@push-based/utils'; 4 | import { 5 | BuildComponentUsageGraphOptions, 6 | ComponentUsageGraphResult, 7 | FileInfo, 8 | } from '../models/types.js'; 9 | import { 10 | DEPENDENCY_ANALYSIS_CONFIG, 11 | clearComponentImportRegexCache, 12 | } from '../models/config.js'; 13 | import { 14 | analyzeFileWithUnifiedOptimization, 15 | extractComponentImportsUnified, 16 | } from './unified-ast-analyzer.js'; 17 | import { resolveCrossPlatformPathAndValidateWithContext } from '../../shared/utils/cross-platform-path.js'; 18 | 19 | const BATCH_SIZE = 50; // Process files in batches of 50 20 | 21 | export async function buildComponentUsageGraph( 22 | options: BuildComponentUsageGraphOptions, 23 | ): Promise<ComponentUsageGraphResult> { 24 | const startTime = performance.now(); 25 | 26 | const targetPath = resolveCrossPlatformPathAndValidateWithContext( 27 | options.cwd, 28 | options.directory, 29 | options.workspaceRoot, 30 | ); 31 | 32 | const files: Record<string, FileInfo> = {}; 33 | 34 | // Phase 1: Directory scanning 35 | const scanStartTime = performance.now(); 36 | const allFiles = await scanDirectoryWithUtils(targetPath); 37 | const scanTime = performance.now() - scanStartTime; 38 | 39 | // Phase 2: File analysis with unified AST parsing 40 | const analysisStartTime = performance.now(); 41 | await processFilesInParallel(allFiles, targetPath, files); 42 | const analysisTime = performance.now() - analysisStartTime; 43 | 44 | // Phase 3: Reverse dependency analysis 45 | const reverseDepsStartTime = performance.now(); 46 | await addReverseDependenciesOptimized(files, targetPath); 47 | const reverseDepsTime = performance.now() - reverseDepsStartTime; 48 | 49 | const totalTime = performance.now() - startTime; 50 | 51 | // Log performance metrics 52 | console.log(`🚀 Unified AST Analysis Performance:`); 53 | console.log( 54 | ` 📁 Directory scan: ${scanTime.toFixed(2)}ms (${((scanTime / totalTime) * 100).toFixed(1)}%)`, 55 | ); 56 | console.log( 57 | ` 🔍 File analysis: ${analysisTime.toFixed(2)}ms (${((analysisTime / totalTime) * 100).toFixed(1)}%)`, 58 | ); 59 | console.log( 60 | ` 🔗 Reverse deps: ${reverseDepsTime.toFixed(2)}ms (${((reverseDepsTime / totalTime) * 100).toFixed(1)}%)`, 61 | ); 62 | console.log( 63 | ` ⚡ Total time: ${totalTime.toFixed(2)}ms for ${Object.keys(files).length} files`, 64 | ); 65 | console.log( 66 | ` 📊 Avg per file: ${(totalTime / Object.keys(files).length).toFixed(2)}ms`, 67 | ); 68 | 69 | return files; 70 | } 71 | 72 | async function scanDirectoryWithUtils(dirPath: string): Promise<string[]> { 73 | const files: string[] = []; 74 | const { fileExtensions } = DEPENDENCY_ANALYSIS_CONFIG; 75 | 76 | try { 77 | // Use findAllFiles async generator for better memory efficiency 78 | for await (const file of findAllFiles(dirPath, (filePath) => { 79 | const ext = path.extname(filePath); 80 | return fileExtensions.includes(ext as any); 81 | })) { 82 | files.push(toUnixPath(file)); 83 | } 84 | } catch (ctx) { 85 | throw new Error( 86 | `Failed to scan directory ${dirPath}: ${(ctx as Error).message}`, 87 | ); 88 | } 89 | 90 | return files; 91 | } 92 | 93 | async function processFilesInParallel( 94 | allFiles: string[], 95 | targetPath: string, 96 | files: Record<string, FileInfo>, 97 | ): Promise<void> { 98 | // Single pass with slice batching (no extra helpers) 99 | for (let i = 0; i < allFiles.length; i += BATCH_SIZE) { 100 | const batch = allFiles.slice(i, i + BATCH_SIZE); 101 | const batchStartTime = performance.now(); 102 | 103 | const results = await Promise.all( 104 | batch.map(async (filePath) => { 105 | try { 106 | const relativePath = toUnixPath(path.relative(targetPath, filePath)); 107 | const fileInfo = await analyzeFileWithUnifiedOptimization( 108 | filePath, 109 | targetPath, 110 | ); 111 | return { relativePath, fileInfo } as const; 112 | } catch (ctx) { 113 | throw new Error( 114 | `Failed to analyze file ${filePath}: ${(ctx as Error).message}`, 115 | ); 116 | } 117 | }), 118 | ); 119 | 120 | for (const result of results) { 121 | if (result) { 122 | files[result.relativePath] = result.fileInfo; 123 | } 124 | } 125 | 126 | const batchTime = performance.now() - batchStartTime; 127 | console.log( 128 | ` 📦 Batch ${Math.ceil((i + batch.length) / BATCH_SIZE)}: ${batch.length} files in ${batchTime.toFixed(2)}ms (${(batchTime / batch.length).toFixed(2)}ms/file)`, 129 | ); 130 | } 131 | } 132 | 133 | async function addReverseDependenciesOptimized( 134 | files: Record<string, FileInfo>, 135 | basePath: string, 136 | ): Promise<void> { 137 | // Build component name to file path mapping 138 | const componentMap = new Map<string, string>(); 139 | const componentNames: string[] = []; 140 | 141 | for (const [filePath, fileInfo] of Object.entries(files)) { 142 | if (fileInfo.componentName) { 143 | componentMap.set(fileInfo.componentName, filePath); 144 | componentNames.push(fileInfo.componentName); 145 | } 146 | } 147 | 148 | if (componentNames.length === 0) { 149 | console.log(` ℹ️ No components found for reverse dependency analysis`); 150 | return; // No components to analyze 151 | } 152 | 153 | console.log( 154 | ` 🔍 Analyzing reverse dependencies for ${componentNames.length} components`, 155 | ); 156 | 157 | // Process files in parallel batches for reverse dependency analysis 158 | const fileEntries = Object.entries(files); 159 | const batches = createBatches(fileEntries, BATCH_SIZE); 160 | let processedBatches = 0; 161 | 162 | for (const batch of batches) { 163 | const batchStartTime = performance.now(); 164 | const promises = batch.map( 165 | async ([filePath, fileInfo]: [string, FileInfo]) => { 166 | const fullPath = path.resolve(basePath, filePath); 167 | 168 | try { 169 | // Only analyze TypeScript/JavaScript files for component imports 170 | if ( 171 | fileInfo.type === 'typescript' || 172 | fileInfo.type === 'javascript' 173 | ) { 174 | // Use unified component import extraction instead of separate file read + AST parsing 175 | const foundComponents = await extractComponentImportsUnified( 176 | fullPath, 177 | componentNames, 178 | ); 179 | 180 | // Collect all reverse dependencies for this file 181 | const reverseDependencies = []; 182 | for (const componentName of foundComponents) { 183 | const componentFilePath = componentMap.get(componentName); 184 | if (componentFilePath && componentFilePath !== filePath) { 185 | reverseDependencies.push({ 186 | componentFilePath, 187 | dependency: { 188 | path: toUnixPath(filePath), 189 | type: 'reverse-dependency' as const, 190 | resolved: true, 191 | resolvedPath: toUnixPath(filePath), 192 | componentName: componentName, 193 | sourceFile: filePath, 194 | }, 195 | }); 196 | } 197 | } 198 | 199 | return reverseDependencies; 200 | } 201 | } catch (ctx) { 202 | throw new Error( 203 | `Failed to analyze reverse dependencies for ${filePath}: ${(ctx as Error).message}`, 204 | ); 205 | } 206 | 207 | return []; 208 | }, 209 | ); 210 | 211 | const results = await Promise.all(promises); 212 | const batchTime = performance.now() - batchStartTime; 213 | processedBatches++; 214 | 215 | // Apply reverse dependencies 216 | let dependenciesAdded = 0; 217 | for (const result of results) { 218 | if (Array.isArray(result)) { 219 | for (const dependency of result) { 220 | files[dependency.componentFilePath].dependencies.push( 221 | dependency.dependency, 222 | ); 223 | dependenciesAdded++; 224 | } 225 | } 226 | } 227 | 228 | console.log( 229 | ` 🔗 Reverse deps batch ${processedBatches}: ${batch.length} files, ${dependenciesAdded} dependencies in ${batchTime.toFixed(2)}ms`, 230 | ); 231 | } 232 | } 233 | 234 | // Compact batch helper reused by reverse-dependency phase 235 | function createBatches<T>(items: T[], batchSize: number): T[][] { 236 | const batches: T[][] = []; 237 | for (let i = 0; i < items.length; i += batchSize) { 238 | batches.push(items.slice(i, i + batchSize)); 239 | } 240 | return batches; 241 | } 242 | 243 | export function clearAnalysisCache(): void { 244 | clearComponentImportRegexCache(); 245 | } 246 | ``` -------------------------------------------------------------------------------- /packages/shared/ds-component-coverage/src/lib/runner/audits/ds-coverage/class-definition.visitor.unit.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { describe, expect, it } from 'vitest'; 2 | import { createClassDefinitionVisitor } from './class-definition.visitor'; 3 | import postcss from 'postcss'; 4 | import { visitEachChild } from '@push-based/styles-ast-utils'; 5 | 6 | describe('ClassDefinitionVisitor', () => { 7 | let cssAstVisitor: ReturnType<typeof createClassDefinitionVisitor>; 8 | 9 | it('should find deprecated class in CSS selector', () => { 10 | const styles = ` 11 | /* This comment is here */ 12 | .btn { 13 | color: red; 14 | } 15 | `; 16 | 17 | cssAstVisitor = createClassDefinitionVisitor({ 18 | deprecatedCssClasses: ['btn'], 19 | componentName: 'DsButton', 20 | docsUrl: 'docs.example.com/DsButton', 21 | }); 22 | 23 | const ast = postcss.parse(styles, { from: 'styles.css' }); 24 | visitEachChild(ast, cssAstVisitor); 25 | 26 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 27 | const message = cssAstVisitor.getIssues()[0].message; 28 | expect(message).toContain('btn'); 29 | expect(message).toContain('DsButton'); 30 | expect(cssAstVisitor.getIssues()[0]).toEqual( 31 | expect.objectContaining({ 32 | severity: 'error', 33 | source: expect.objectContaining({ 34 | file: 'styles.css', 35 | position: expect.any(Object), 36 | }), 37 | }), 38 | ); 39 | }); 40 | 41 | it('should not find class when it is not deprecated', () => { 42 | const styles = ` 43 | .safe-class { 44 | color: red; 45 | } 46 | 47 | #btn-1 { 48 | color: green; 49 | } 50 | 51 | button { 52 | color: blue; 53 | } 54 | `; 55 | 56 | cssAstVisitor = createClassDefinitionVisitor({ 57 | deprecatedCssClasses: ['btn'], 58 | componentName: 'DsButton', 59 | }); 60 | 61 | const ast = postcss.parse(styles, { from: 'styles.css' }); 62 | visitEachChild(ast, cssAstVisitor); 63 | 64 | expect(cssAstVisitor.getIssues()).toHaveLength(0); 65 | }); 66 | 67 | it('should find deprecated class in complex selector', () => { 68 | const styles = ` 69 | div > button.btn { 70 | color: blue; 71 | } 72 | `; 73 | 74 | cssAstVisitor = createClassDefinitionVisitor({ 75 | deprecatedCssClasses: ['btn'], 76 | componentName: 'DsButton', 77 | }); 78 | 79 | const ast = postcss.parse(styles, { from: 'styles.css' }); 80 | visitEachChild(ast, cssAstVisitor); 81 | 82 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 83 | const message = cssAstVisitor.getIssues()[0].message; 84 | expect(message).toContain('btn'); 85 | expect(message).toContain('DsButton'); 86 | expect(cssAstVisitor.getIssues()[0]).toEqual( 87 | expect.objectContaining({ 88 | severity: 'error', 89 | source: expect.objectContaining({ 90 | file: 'styles.css', 91 | position: expect.any(Object), 92 | }), 93 | }), 94 | ); 95 | }); 96 | 97 | describe('deduplication', () => { 98 | it('should deduplicate multiple deprecated classes in same selector', () => { 99 | const styles = ` 100 | .btn.btn-primary { 101 | color: red; 102 | } 103 | `; 104 | 105 | cssAstVisitor = createClassDefinitionVisitor({ 106 | deprecatedCssClasses: ['btn', 'btn-primary'], 107 | componentName: 'DsButton', 108 | docsUrl: 'docs.example.com/DsButton', 109 | }); 110 | 111 | const ast = postcss.parse(styles, { from: 'styles.css' }); 112 | visitEachChild(ast, cssAstVisitor); 113 | 114 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 115 | const message = cssAstVisitor.getIssues()[0].message; 116 | expect(message).toContain('btn, btn-primary'); 117 | expect(message).toContain('DsButton'); 118 | expect(cssAstVisitor.getIssues()[0]).toEqual( 119 | expect.objectContaining({ 120 | severity: 'error', 121 | source: expect.objectContaining({ 122 | file: 'styles.css', 123 | position: expect.any(Object), 124 | }), 125 | }), 126 | ); 127 | }); 128 | 129 | it('should deduplicate multiple deprecated classes in comma-separated selectors', () => { 130 | const styles = ` 131 | .btn, .btn-primary { 132 | color: red; 133 | } 134 | `; 135 | 136 | cssAstVisitor = createClassDefinitionVisitor({ 137 | deprecatedCssClasses: ['btn', 'btn-primary'], 138 | componentName: 'DsButton', 139 | docsUrl: 'docs.example.com/DsButton', 140 | }); 141 | 142 | const ast = postcss.parse(styles, { from: 'styles.css' }); 143 | visitEachChild(ast, cssAstVisitor); 144 | 145 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 146 | const message = cssAstVisitor.getIssues()[0].message; 147 | expect(message).toContain('btn, btn-primary'); 148 | expect(message).toContain('DsButton'); 149 | expect(cssAstVisitor.getIssues()[0]).toEqual( 150 | expect.objectContaining({ 151 | severity: 'error', 152 | source: expect.objectContaining({ 153 | file: 'styles.css', 154 | position: expect.any(Object), 155 | }), 156 | }), 157 | ); 158 | }); 159 | 160 | it('should deduplicate three deprecated classes in same selector', () => { 161 | const styles = ` 162 | .btn.btn-primary.btn-large { 163 | color: red; 164 | } 165 | `; 166 | 167 | cssAstVisitor = createClassDefinitionVisitor({ 168 | deprecatedCssClasses: ['btn', 'btn-primary', 'btn-large'], 169 | componentName: 'DsButton', 170 | docsUrl: 'docs.example.com/DsButton', 171 | }); 172 | 173 | const ast = postcss.parse(styles, { from: 'styles.css' }); 174 | visitEachChild(ast, cssAstVisitor); 175 | 176 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 177 | const message = cssAstVisitor.getIssues()[0].message; 178 | expect(message).toContain('btn, btn-primary, btn-large'); 179 | expect(message).toContain('DsButton'); 180 | expect(cssAstVisitor.getIssues()[0]).toEqual( 181 | expect.objectContaining({ 182 | severity: 'error', 183 | source: expect.objectContaining({ 184 | file: 'styles.css', 185 | position: expect.any(Object), 186 | }), 187 | }), 188 | ); 189 | }); 190 | 191 | it('should still create single issue for single deprecated class', () => { 192 | const styles = ` 193 | .btn { 194 | color: red; 195 | } 196 | `; 197 | 198 | cssAstVisitor = createClassDefinitionVisitor({ 199 | deprecatedCssClasses: ['btn', 'btn-primary'], 200 | componentName: 'DsButton', 201 | docsUrl: 'docs.example.com/DsButton', 202 | }); 203 | 204 | const ast = postcss.parse(styles, { from: 'styles.css' }); 205 | visitEachChild(ast, cssAstVisitor); 206 | 207 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 208 | const message = cssAstVisitor.getIssues()[0].message; 209 | expect(message).toContain('btn'); 210 | expect(message).not.toContain(','); 211 | expect(message).toContain('DsButton'); 212 | expect(cssAstVisitor.getIssues()[0]).toEqual( 213 | expect.objectContaining({ 214 | severity: 'error', 215 | source: expect.objectContaining({ 216 | file: 'styles.css', 217 | position: expect.any(Object), 218 | }), 219 | }), 220 | ); 221 | }); 222 | 223 | it('should handle mixed deprecated and non-deprecated classes', () => { 224 | const styles = ` 225 | .btn.safe-class.btn-primary { 226 | color: red; 227 | } 228 | `; 229 | 230 | cssAstVisitor = createClassDefinitionVisitor({ 231 | deprecatedCssClasses: ['btn', 'btn-primary'], 232 | componentName: 'DsButton', 233 | docsUrl: 'docs.example.com/DsButton', 234 | }); 235 | 236 | const ast = postcss.parse(styles, { from: 'styles.css' }); 237 | visitEachChild(ast, cssAstVisitor); 238 | 239 | expect(cssAstVisitor.getIssues()).toHaveLength(1); 240 | const message = cssAstVisitor.getIssues()[0].message; 241 | expect(message).toContain('btn, btn-primary'); 242 | expect(message).not.toContain('safe-class'); 243 | expect(message).toContain('DsButton'); 244 | expect(cssAstVisitor.getIssues()[0]).toEqual( 245 | expect.objectContaining({ 246 | severity: 'error', 247 | source: expect.objectContaining({ 248 | file: 'styles.css', 249 | position: expect.any(Object), 250 | }), 251 | }), 252 | ); 253 | }); 254 | }); 255 | }); 256 | ``` -------------------------------------------------------------------------------- /packages/angular-mcp-server/src/lib/tools/ds/component-contract/builder/utils/dom-slots.extractor.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | visitComponentTemplate, 3 | visitEachTmplChild, 4 | NoopTmplVisitor, 5 | parseClassNames, 6 | ParsedComponent, 7 | } from '@push-based/angular-ast-utils'; 8 | import { 9 | extractBindings, 10 | extractAttributes, 11 | extractEvents, 12 | } from './element-helpers.js'; 13 | import type { 14 | TmplAstNode, 15 | TmplAstElement, 16 | TmplAstForLoopBlock, 17 | TmplAstIfBlock, 18 | TmplAstSwitchBlock, 19 | TmplAstDeferredBlock, 20 | TmplAstContent, 21 | TmplAstTemplate, 22 | } from '@angular/compiler' with { 'resolution-mode': 'import' }; 23 | import type { 24 | Slots, 25 | DomStructure, 26 | StructuralDirectiveContext, 27 | } from '../../shared/models/types.js'; 28 | 29 | /** 30 | * Extract both content projection slots (ng-content) and DOM structure 31 | * in a single pass over the component template for better performance 32 | */ 33 | export async function extractSlotsAndDom( 34 | parsedComponent: ParsedComponent, 35 | ): Promise<{ slots: Slots; dom: DomStructure }> { 36 | const slots: Slots = {}; 37 | const dom: DomStructure = {}; 38 | 39 | await visitComponentTemplate( 40 | parsedComponent, 41 | {}, 42 | async (_, templateAsset) => { 43 | const parsedTemplate = await templateAsset.parse(); 44 | const visitor = new DomAndSlotExtractionVisitor(slots, dom); 45 | visitEachTmplChild(parsedTemplate.nodes as TmplAstNode[], visitor); 46 | return []; 47 | }, 48 | ); 49 | 50 | return { slots, dom }; 51 | } 52 | 53 | /** 54 | * Combined visitor to extract ng-content slots and build DOM structure in a single pass 55 | */ 56 | class DomAndSlotExtractionVisitor extends NoopTmplVisitor { 57 | private slotCounter = 0; 58 | private pathStack: string[] = []; 59 | 60 | /** 61 | * Stack of active structural directive contexts so that nested elements inherit 62 | * the directive information of all parent control-flow blocks. 63 | */ 64 | private directiveStack: StructuralDirectiveContext[] = []; 65 | 66 | constructor( 67 | private slots: Slots, 68 | private dom: DomStructure, 69 | ) { 70 | super(); 71 | } 72 | 73 | override visitElement(element: TmplAstElement): void { 74 | // skip explicit handling of <ng-content> here – it is visited by visitContent 75 | 76 | const selectorKey = this.generateSelectorKey(element); 77 | const parentKey = 78 | this.pathStack.length > 0 ? this.pathStack.join(' > ') : null; 79 | 80 | this.dom[selectorKey] = { 81 | tag: element.name, 82 | parent: parentKey, 83 | children: [], 84 | bindings: extractBindings(element), 85 | attributes: extractAttributes(element), 86 | events: extractEvents(element), 87 | structural: 88 | this.directiveStack.length > 0 89 | ? // spread to detach reference 90 | [...this.directiveStack] 91 | : undefined, 92 | }; 93 | 94 | if (parentKey && this.dom[parentKey]) { 95 | this.dom[parentKey].children.push(selectorKey); 96 | } 97 | 98 | // Push only the current element's selector to the stack 99 | const currentSelector = this.generateCurrentElementSelector(element); 100 | this.pathStack.push(currentSelector); 101 | visitEachTmplChild(element.children as TmplAstNode[], this); 102 | this.pathStack.pop(); 103 | } 104 | 105 | private visitBlockWithChildren(block: { children?: TmplAstNode[] }): void { 106 | if (block.children) { 107 | visitEachTmplChild(block.children as TmplAstNode[], this); 108 | } 109 | } 110 | 111 | override visitForLoopBlock(block: TmplAstForLoopBlock): void { 112 | const ctx: StructuralDirectiveContext = { 113 | kind: 'for', 114 | expression: (block as any).expression?.source ?? undefined, 115 | alias: (block as any).item?.name ?? undefined, 116 | }; 117 | this.directiveStack.push(ctx); 118 | this.visitBlockWithChildren(block); 119 | block.empty?.visit(this); 120 | this.directiveStack.pop(); 121 | } 122 | 123 | override visitIfBlock(block: TmplAstIfBlock): void { 124 | const outerCtx: StructuralDirectiveContext = { kind: 'if' }; 125 | this.directiveStack.push(outerCtx); 126 | block.branches.forEach((branch) => branch.visit(this)); 127 | this.directiveStack.pop(); 128 | } 129 | 130 | override visitSwitchBlock(block: TmplAstSwitchBlock): void { 131 | const ctx: StructuralDirectiveContext = { 132 | kind: 'switch', 133 | expression: (block as any).expression?.source ?? undefined, 134 | }; 135 | this.directiveStack.push(ctx); 136 | block.cases.forEach((caseBlock) => caseBlock.visit(this)); 137 | this.directiveStack.pop(); 138 | } 139 | 140 | override visitDeferredBlock(deferred: TmplAstDeferredBlock): void { 141 | const ctx: StructuralDirectiveContext = { kind: 'defer' }; 142 | this.directiveStack.push(ctx); 143 | deferred.visitAll(this); 144 | this.directiveStack.pop(); 145 | } 146 | 147 | /** 148 | * Handle <ng-content> projection points represented in the Angular template AST as TmplAstContent. 149 | * Recognises default, attribute-selector ([slot=foo]) and legacy slot=foo syntaxes. 150 | */ 151 | override visitContent(content: TmplAstContent): void { 152 | const selectValue = content.selector ?? ''; 153 | const slotName = selectValue ? this.parseSlotName(selectValue) : 'default'; 154 | 155 | this.slots[slotName] = { 156 | selector: selectValue 157 | ? `ng-content[select="${selectValue}"]` 158 | : 'ng-content', 159 | }; 160 | } 161 | 162 | private parseSlotName(selectValue: string): string { 163 | // Matches [slot=foo], [slot='foo'], [slot="foo"], slot=foo (case-insensitive) 164 | const match = selectValue.match( 165 | /(?:^\[?)\s*slot\s*=\s*['"]?([^'" \]\]]+)['"]?\]?$/i, 166 | ); 167 | if (match) { 168 | return match[1]; 169 | } 170 | 171 | if (selectValue.startsWith('.')) { 172 | return selectValue.substring(1); 173 | } 174 | 175 | return selectValue || `slot-${this.slotCounter++}`; 176 | } 177 | 178 | private generateSelectorKey(element: TmplAstElement): string { 179 | const currentSelector = this.generateCurrentElementSelector(element); 180 | 181 | return this.pathStack.length > 0 182 | ? `${this.pathStack.join(' > ')} > ${currentSelector}` 183 | : currentSelector; 184 | } 185 | 186 | private generateCurrentElementSelector(element: TmplAstElement): string { 187 | const classes = this.extractClasses(element); 188 | const id = element.attributes.find((attr) => attr.name === 'id')?.value; 189 | 190 | let selector = element.name; 191 | if (id) selector += `#${id}`; 192 | if (classes.length > 0) selector += '.' + classes.join('.'); 193 | 194 | return selector; 195 | } 196 | 197 | private extractClasses(element: TmplAstElement): string[] { 198 | const out = new Set<string>(); 199 | 200 | const classAttr = element.attributes.find((attr) => attr.name === 'class'); 201 | if (classAttr) { 202 | parseClassNames(classAttr.value).forEach((cls) => out.add(cls)); 203 | } 204 | 205 | element.inputs 206 | .filter((input) => input.name.startsWith('class.')) 207 | .forEach((input) => out.add(input.name.substring(6))); 208 | 209 | return [...out]; 210 | } 211 | 212 | /** Legacy structural directives on <ng-template> (e.g., *ngIf, *ngFor). */ 213 | override visitTemplate(template: TmplAstTemplate): void { 214 | const dir = template.templateAttrs?.find?.((a: any) => 215 | a.name?.startsWith?.('ng'), 216 | ); 217 | 218 | if (!dir) { 219 | // Just traverse children when no structural directive is present 220 | this.visitBlockWithChildren(template); 221 | return; 222 | } 223 | 224 | const map: Record<string, StructuralDirectiveContext['kind']> = { 225 | ngIf: 'if', 226 | ngForOf: 'for', 227 | ngSwitch: 'switch', 228 | ngSwitchCase: 'switchCase', 229 | ngSwitchDefault: 'switchDefault', 230 | } as const; 231 | 232 | const kind = map[dir.name as keyof typeof map]; 233 | 234 | // If the directive is not one we're interested in, skip recording 235 | if (!kind) { 236 | this.visitBlockWithChildren(template); 237 | return; 238 | } 239 | 240 | const ctx: StructuralDirectiveContext = { 241 | kind, 242 | expression: (dir as any).value as string | undefined, 243 | }; 244 | 245 | this.directiveStack.push(ctx); 246 | this.visitBlockWithChildren(template); 247 | this.directiveStack.pop(); 248 | } 249 | } 250 | 251 | /** 252 | * Attach simple "just traverse children" visitors dynamically to 253 | * avoid eight nearly identical class methods above. 254 | */ 255 | const SIMPLE_VISIT_METHODS = [ 256 | 'visitForLoopBlockEmpty', 257 | 'visitIfBlockBranch', 258 | 'visitSwitchBlockCase', 259 | 'visitDeferredBlockError', 260 | 'visitDeferredBlockLoading', 261 | 'visitDeferredBlockPlaceholder', 262 | // 'visitTemplate', 263 | ] as const; 264 | 265 | SIMPLE_VISIT_METHODS.forEach((method) => { 266 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 267 | (DomAndSlotExtractionVisitor.prototype as any)[method] = function ( 268 | this: DomAndSlotExtractionVisitor, 269 | block: { children?: TmplAstNode[] }, 270 | ): void { 271 | if (block.children) { 272 | visitEachTmplChild(block.children as TmplAstNode[], this); 273 | } 274 | }; 275 | }); 276 | ``` -------------------------------------------------------------------------------- /packages/shared/utils/ai/FUNCTIONS.md: -------------------------------------------------------------------------------- ```markdown 1 | # Public API — Quick Reference 2 | 3 | | Symbol | Kind | Summary | 4 | | ---------------------- | -------- | -------------------------------------------------------------- | 5 | | `ProcessResult` | type | Process execution result with stdout, stderr, code, and timing | 6 | | `ProcessError` | class | Error class for process execution failures | 7 | | `ProcessConfig` | type | Configuration object for process execution | 8 | | `ProcessObserver` | type | Observer interface for process events | 9 | | `LinePosition` | type | Position information for text matches within a line | 10 | | `SourcePosition` | type | Position information with line and column details | 11 | | `SourceLocation` | type | File location with position information | 12 | | `accessContent` | function | Generator function to iterate over file content lines | 13 | | `calcDuration` | function | Calculate duration between performance timestamps | 14 | | `executeProcess` | function | Execute a child process with observer pattern | 15 | | `fileResolverCache` | constant | Map cache for file resolution operations | 16 | | `findAllFiles` | function | Async generator to find files matching a predicate | 17 | | `findFilesWithPattern` | function | Find TypeScript files containing a search pattern | 18 | | `findInFile` | function | Find pattern matches within a specific file | 19 | | `formatCommandLog` | function | Format command strings with ANSI colors and directory context | 20 | | `getLineHits` | function | Get all pattern matches within a text line | 21 | | `isExcludedDirectory` | function | Check if a directory should be excluded from searches | 22 | | `isVerbose` | function | Check if verbose logging is enabled via environment variable | 23 | | `loadDefaultExport` | function | Dynamically import ES modules and extract default export | 24 | | `objectToCliArgs` | function | Convert object properties to command-line arguments | 25 | | `resolveFile` | function | Read file content directly without caching | 26 | | `resolveFileCached` | function | Read file content with caching for performance | 27 | 28 | ## Types 29 | 30 | ### `ProcessResult` 31 | 32 | ```ts 33 | type ProcessResult = { 34 | stdout: string; 35 | stderr: string; 36 | code: number | null; 37 | date: string; 38 | duration: number; 39 | }; 40 | ``` 41 | 42 | Represents the result of a process execution with output streams, exit code, and timing information. 43 | 44 | ### `ProcessConfig` 45 | 46 | ```ts 47 | type ProcessConfig = Omit< 48 | SpawnOptionsWithStdioTuple<StdioPipe, StdioPipe, StdioPipe>, 49 | 'stdio' 50 | > & { 51 | command: string; 52 | args?: string[]; 53 | observer?: ProcessObserver; 54 | ignoreExitCode?: boolean; 55 | }; 56 | ``` 57 | 58 | Configuration object for process execution, extending Node.js spawn options. 59 | 60 | ### `ProcessObserver` 61 | 62 | ```ts 63 | type ProcessObserver = { 64 | onStdout?: (stdout: string, sourceProcess?: ChildProcess) => void; 65 | onStderr?: (stderr: string, sourceProcess?: ChildProcess) => void; 66 | onError?: (error: ProcessError) => void; 67 | onComplete?: () => void; 68 | }; 69 | ``` 70 | 71 | Observer interface for handling process events during execution. 72 | 73 | ### `LinePosition` 74 | 75 | ```ts 76 | type LinePosition = { 77 | startColumn: number; 78 | endColumn?: number; 79 | }; 80 | ``` 81 | 82 | Position information for text matches within a single line. 83 | 84 | ### `SourcePosition` 85 | 86 | ```ts 87 | type SourcePosition = { 88 | startLine: number; 89 | endLine?: number; 90 | } & LinePosition; 91 | ``` 92 | 93 | Extended position information including line numbers. 94 | 95 | ### `SourceLocation` 96 | 97 | ```ts 98 | type SourceLocation = { 99 | file: string; 100 | position: SourcePosition; 101 | }; 102 | ``` 103 | 104 | Complete location information with file path and position details. 105 | 106 | ## Classes 107 | 108 | ### `ProcessError extends Error` 109 | 110 | Error class for process execution failures, containing additional process result information. 111 | 112 | **Properties:** 113 | 114 | - `code: number | null` - Process exit code 115 | - `stderr: string` - Process error output 116 | - `stdout: string` - Process standard output 117 | 118 | ## Functions 119 | 120 | ### `executeProcess(cfg: ProcessConfig): Promise<ProcessResult>` 121 | 122 | Executes a child process with comprehensive error handling and observer pattern support. 123 | 124 | **Parameters:** 125 | 126 | - `cfg` - Process configuration object 127 | 128 | **Returns:** Promise resolving to process result 129 | 130 | ### `findFilesWithPattern(baseDir: string, searchPattern: string): Promise<string[]>` 131 | 132 | Searches for TypeScript files containing the specified pattern. 133 | 134 | **Parameters:** 135 | 136 | - `baseDir` - Directory to search (absolute or resolved by caller) 137 | - `searchPattern` - Pattern to match in file contents 138 | 139 | **Returns:** Promise resolving to array of file paths 140 | 141 | ### `findAllFiles(baseDir: string, predicate?: (file: string) => boolean): AsyncGenerator<string>` 142 | 143 | Async generator that finds all files matching a predicate function. 144 | 145 | **Parameters:** 146 | 147 | - `baseDir` - Base directory to search 148 | - `predicate` - Optional file filter function (defaults to `.ts` files) 149 | 150 | **Returns:** Async generator yielding file paths 151 | 152 | ### `findInFile(file: string, searchPattern: string, bail?: boolean): Promise<SourceLocation[]>` 153 | 154 | Finds all occurrences of a pattern within a specific file. 155 | 156 | **Parameters:** 157 | 158 | - `file` - File path to search 159 | - `searchPattern` - Pattern to find 160 | - `bail` - Optional flag to stop after first match 161 | 162 | **Returns:** Promise resolving to array of source locations 163 | 164 | ### `resolveFileCached(filePath: string): Promise<string>` 165 | 166 | Resolves file content with caching to avoid reading the same file multiple times. 167 | 168 | **Parameters:** 169 | 170 | - `filePath` - Path to the file to read 171 | 172 | **Returns:** Promise resolving to file content 173 | 174 | ### `resolveFile(filePath: string): Promise<string>` 175 | 176 | Resolves file content directly without caching. 177 | 178 | **Parameters:** 179 | 180 | - `filePath` - Path to the file to read 181 | 182 | **Returns:** Promise resolving to file content 183 | 184 | ### `formatCommandLog(command: string, args?: string[], cwd?: string): string` 185 | 186 | Formats a command string with ANSI colors and optional directory context. 187 | 188 | **Parameters:** 189 | 190 | - `command` - Command to execute 191 | - `args` - Command arguments (optional) 192 | - `cwd` - Current working directory (optional) 193 | 194 | **Returns:** ANSI-colored formatted command string 195 | 196 | ### `objectToCliArgs<T extends object>(params?: CliArgsObject<T>): string[]` 197 | 198 | Converts an object with different value types into command-line arguments array. 199 | 200 | **Parameters:** 201 | 202 | - `params` - Object with CLI parameters 203 | 204 | **Returns:** Array of formatted CLI arguments 205 | 206 | ### `calcDuration(start: number, stop?: number): number` 207 | 208 | Calculates duration between performance timestamps. 209 | 210 | **Parameters:** 211 | 212 | - `start` - Start timestamp from `performance.now()` 213 | - `stop` - Optional end timestamp (defaults to current time) 214 | 215 | **Returns:** Duration in milliseconds 216 | 217 | ### `getLineHits(content: string, pattern: string, bail?: boolean): LinePosition[]` 218 | 219 | Gets all pattern matches within a text line. 220 | 221 | **Parameters:** 222 | 223 | - `content` - Text content to search 224 | - `pattern` - Pattern to find 225 | - `bail` - Optional flag to stop after first match 226 | 227 | **Returns:** Array of line positions 228 | 229 | ### `accessContent(content: string): Generator<string>` 230 | 231 | Generator function to iterate over file content lines. 232 | 233 | **Parameters:** 234 | 235 | - `content` - File content string 236 | 237 | **Returns:** Generator yielding individual lines 238 | 239 | ### `isExcludedDirectory(fileName: string): boolean` 240 | 241 | Checks if a directory should be excluded from file searches. 242 | 243 | **Parameters:** 244 | 245 | - `fileName` - Directory name to check 246 | 247 | **Returns:** `true` if directory should be excluded 248 | 249 | ### `isVerbose(): boolean` 250 | 251 | Checks if verbose logging is enabled via the `NG_MCP_VERBOSE` environment variable. 252 | 253 | **Returns:** `true` if verbose logging is enabled 254 | 255 | ### `loadDefaultExport<T = unknown>(filePath: string): Promise<T>` 256 | 257 | Dynamically imports an ES Module and extracts the default export. Uses proper file URL conversion for cross-platform compatibility. 258 | 259 | **Parameters:** 260 | 261 | - `filePath` - Absolute path to the ES module file to import 262 | 263 | **Returns:** Promise resolving to the default export from the module 264 | 265 | **Throws:** Error if the module cannot be loaded or has no default export 266 | 267 | **Example:** 268 | 269 | ```typescript 270 | const config = await loadDefaultExport('/path/to/config.js'); 271 | const data = await loadDefaultExport<MyDataType>('/path/to/data.mjs'); 272 | ``` 273 | 274 | ## Constants 275 | 276 | ### `fileResolverCache: Map<string, Promise<string>>` 277 | 278 | Map cache used by `resolveFileCached` to store file reading promises and avoid duplicate file operations. 279 | ``` -------------------------------------------------------------------------------- /packages/minimal-repo/packages/application/src/app/components/refactoring-tests/complex-components/third-case/product-card.component.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | ViewEncapsulation, 5 | computed, 6 | input, 7 | output, 8 | signal, 9 | booleanAttribute, 10 | OnInit, 11 | } from '@angular/core'; 12 | import { CommonModule } from '@angular/common'; 13 | import { FormsModule } from '@angular/forms'; 14 | import { DsBadge } from '@frontend/ui/badge'; 15 | 16 | export interface Product { 17 | id: string; 18 | name: string; 19 | price: number; 20 | originalPrice?: number; 21 | category: string; 22 | rating: number; 23 | reviewCount: number; 24 | inStock: boolean; 25 | imageUrl: string; 26 | tags: string[]; 27 | } 28 | 29 | export const PRODUCT_BADGE_TYPES = ['sale', 'new', 'bestseller', 'limited'] as const; 30 | export type ProductBadgeType = (typeof PRODUCT_BADGE_TYPES)[number]; 31 | 32 | @Component({ 33 | selector: 'app-product-card', 34 | standalone: true, 35 | imports: [CommonModule, FormsModule, DsBadge], 36 | template: ` 37 | <div class="product-card" [class.product-card-selected]="selected()"> 38 | <!-- Product Image with Badge Overlay --> 39 | <div class="product-image-container"> 40 | <img 41 | [src]="product().imageUrl" 42 | [alt]="product().name" 43 | class="product-image" 44 | (error)="onImageError($event)"> 45 | 46 | <!-- DsBadge Implementation --> 47 | @if (showBadge()) { 48 | <div class="badge-overlay"> 49 | <ds-badge 50 | [size]="compact() ? 'xsmall' : 'medium'" 51 | [variant]="getBadgeVariant()"> 52 | 53 | <!-- Icon slot (start) --> 54 | <span slot="start"> 55 | @switch (badgeType()) { 56 | @case ('sale') { 57 | 🔥 58 | } 59 | @case ('new') { 60 | ✨ 61 | } 62 | @case ('bestseller') { 63 | ⭐ 64 | } 65 | @case ('limited') { 66 | ⏰ 67 | } 68 | } 69 | </span> 70 | 71 | <!-- Main badge text --> 72 | {{ getBadgeText() }} 73 | 74 | <!-- Optional percentage for sale badges (end slot) --> 75 | @if (badgeType() === 'sale' && product().originalPrice) { 76 | <span slot="end"> 77 | -{{ getSalePercentage() }}% 78 | </span> 79 | } 80 | </ds-badge> 81 | </div> 82 | } 83 | 84 | <!-- Stock status indicator --> 85 | @if (!product().inStock) { 86 | <div class="stock-overlay"> 87 | <span class="stock-badge">Out of Stock</span> 88 | </div> 89 | } 90 | </div> 91 | 92 | <!-- Product Content --> 93 | <div class="product-content"> 94 | <div class="product-header"> 95 | <h3 class="product-name">{{ product().name }}</h3> 96 | <button 97 | class="favorite-button" 98 | [class.favorite-active]="favorited()" 99 | (click)="toggleFavorite()" 100 | [attr.aria-label]="favorited() ? 'Remove from favorites' : 'Add to favorites'"> 101 | <svg width="20" height="20" viewBox="0 0 20 20"> 102 | <path d="M10 15l-5.5 3 1-6L1 7.5l6-.5L10 1l3 6 6 .5-4.5 4.5 1 6z" 103 | [attr.fill]="favorited() ? '#ef4444' : 'none'" 104 | [attr.stroke]="favorited() ? '#ef4444' : 'currentColor'" 105 | stroke-width="1.5"/> 106 | </svg> 107 | </button> 108 | </div> 109 | 110 | <div class="product-category">{{ product().category }}</div> 111 | 112 | <!-- Price section with conditional styling --> 113 | <div class="product-pricing"> 114 | @if (product().originalPrice && product().originalPrice > product().price) { 115 | <span class="original-price">\${{ product().originalPrice.toFixed(2) }}</span> 116 | } 117 | <span class="current-price">\${{ product().price.toFixed(2) }}</span> 118 | </div> 119 | 120 | <!-- Rating and reviews --> 121 | <div class="product-rating"> 122 | <div class="rating-stars"> 123 | @for (star of getStarArray(); track $index) { 124 | <span class="star" [class.star-filled]="star">★</span> 125 | } 126 | </div> 127 | <span class="rating-text">{{ product().rating.toFixed(1) }} ({{ product().reviewCount }})</span> 128 | </div> 129 | 130 | <!-- Product tags --> 131 | @if (product().tags.length > 0) { 132 | <div class="product-tags"> 133 | @for (tag of product().tags.slice(0, 3); track tag) { 134 | <span class="product-tag">{{ tag }}</span> 135 | } 136 | @if (product().tags.length > 3) { 137 | <span class="tag-more">+{{ product().tags.length - 3 }} more</span> 138 | } 139 | </div> 140 | } 141 | </div> 142 | 143 | <!-- Product Actions --> 144 | <div class="product-actions"> 145 | <button 146 | class="action-button add-to-cart" 147 | [disabled]="!product().inStock" 148 | (click)="addToCart()" 149 | [attr.aria-label]="'Add ' + product().name + ' to cart'"> 150 | @if (product().inStock) { 151 | Add to Cart 152 | } @else { 153 | Notify When Available 154 | } 155 | </button> 156 | 157 | <button 158 | class="action-button quick-view" 159 | (click)="quickView()" 160 | [attr.aria-label]="'Quick view ' + product().name"> 161 | Quick View 162 | </button> 163 | </div> 164 | </div> 165 | `, 166 | styleUrls: ['./product-card.component.scss'], 167 | encapsulation: ViewEncapsulation.None, 168 | changeDetection: ChangeDetectionStrategy.OnPush, 169 | }) 170 | export class ProductCardComponent implements OnInit { 171 | // Product data 172 | product = input.required<Product>(); 173 | 174 | // Badge configuration 175 | badgeType = input<ProductBadgeType>('sale'); 176 | showBadge = input(true, { transform: booleanAttribute }); 177 | animated = input(true, { transform: booleanAttribute }); 178 | compact = input(false, { transform: booleanAttribute }); 179 | 180 | // Card state 181 | selected = input(false, { transform: booleanAttribute }); 182 | favorited = signal(false); 183 | 184 | // Outputs 185 | productSelected = output<Product>(); 186 | favoriteToggled = output<{product: Product, favorited: boolean}>(); 187 | addToCartClicked = output<Product>(); 188 | quickViewClicked = output<Product>(); 189 | 190 | ngOnInit() { 191 | // Initialize favorited state from localStorage or API 192 | const savedFavorites = localStorage.getItem('favoriteProducts'); 193 | if (savedFavorites) { 194 | const favorites = JSON.parse(savedFavorites) as string[]; 195 | this.favorited.set(favorites.includes(this.product().id)); 196 | } 197 | } 198 | 199 | getBadgeText(): string { 200 | const typeMap: Record<ProductBadgeType, string> = { 201 | 'sale': 'Sale', 202 | 'new': 'New', 203 | 'bestseller': 'Best Seller', 204 | 'limited': 'Limited Time' 205 | }; 206 | return typeMap[this.badgeType()]; 207 | } 208 | 209 | getBadgeVariant() { 210 | const variantMap: Record<ProductBadgeType, string> = { 211 | 'sale': 'red-strong', 212 | 'new': 'green-strong', 213 | 'bestseller': 'yellow-strong', 214 | 'limited': 'purple-strong' 215 | }; 216 | return variantMap[this.badgeType()]; 217 | } 218 | 219 | getBadgeAriaLabel(): string { 220 | const text = this.getBadgeText(); 221 | if (this.badgeType() === 'sale' && this.product().originalPrice) { 222 | return `${text} badge: ${this.getSalePercentage()}% off`; 223 | } 224 | return `${text} badge`; 225 | } 226 | 227 | getSalePercentage(): number { 228 | const original = this.product().originalPrice; 229 | const current = this.product().price; 230 | if (!original || original <= current) return 0; 231 | return Math.round(((original - current) / original) * 100); 232 | } 233 | 234 | getStarArray(): boolean[] { 235 | const rating = this.product().rating; 236 | const stars: boolean[] = []; 237 | for (let i = 1; i <= 5; i++) { 238 | stars.push(i <= rating); 239 | } 240 | return stars; 241 | } 242 | 243 | toggleFavorite() { 244 | const newFavorited = !this.favorited(); 245 | this.favorited.set(newFavorited); 246 | 247 | // Update localStorage 248 | const savedFavorites = localStorage.getItem('favoriteProducts'); 249 | const favorites = savedFavorites ? JSON.parse(savedFavorites) as string[] : []; 250 | 251 | if (newFavorited) { 252 | if (!favorites.includes(this.product().id)) { 253 | favorites.push(this.product().id); 254 | } 255 | } else { 256 | const index = favorites.indexOf(this.product().id); 257 | if (index > -1) { 258 | favorites.splice(index, 1); 259 | } 260 | } 261 | 262 | localStorage.setItem('favoriteProducts', JSON.stringify(favorites)); 263 | this.favoriteToggled.emit({product: this.product(), favorited: newFavorited}); 264 | } 265 | 266 | addToCart() { 267 | if (this.product().inStock) { 268 | this.addToCartClicked.emit(this.product()); 269 | } 270 | } 271 | 272 | quickView() { 273 | this.quickViewClicked.emit(this.product()); 274 | } 275 | 276 | onImageError(event: Event) { 277 | const img = event.target as HTMLImageElement; 278 | img.src = 'https://via.placeholder.com/300x200?text=No+Image'; 279 | } 280 | } ``` -------------------------------------------------------------------------------- /packages/shared/angular-ast-utils/src/lib/template/utils.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | AST, 3 | ASTWithSource, 4 | ParsedTemplate, 5 | ParseSourceSpan, 6 | } from '@angular/compiler' with { 'resolution-mode': 'import' }; 7 | 8 | import { Issue } from '@code-pushup/models'; 9 | import { Asset, ParsedComponent } from '../types.js'; 10 | 11 | /** 12 | * Convert a TmplAstElement to an Issue source object and adjust its position based on startLine. 13 | * It creates a "linkable" source object for the issue. 14 | * By default, the source location is 0 indexed, so we add 1 to the startLine to make it work in file links. 15 | * 16 | * @param element The element to convert. 17 | * @param startLine The baseline number to adjust positions. 18 | */ 19 | export function tmplAstElementToSource( 20 | { 21 | startSourceSpan, 22 | sourceSpan, 23 | endSourceSpan, 24 | }: { 25 | sourceSpan: ParseSourceSpan; 26 | startSourceSpan: ParseSourceSpan; 27 | endSourceSpan: ParseSourceSpan | null; 28 | }, 29 | startLine = 0, 30 | ): Issue['source'] { 31 | const offset = startLine; // TS Ast is 0 indexed so is work in 0 based index out of the box 32 | return { 33 | file: sourceSpan.start.file.url, 34 | position: { 35 | startLine: (startSourceSpan?.start.line ?? 0) + offset + 1, 36 | ...(startSourceSpan?.start.col && { 37 | startColumn: startSourceSpan?.start.col, 38 | }), 39 | ...(endSourceSpan?.end.line !== undefined && { 40 | endLine: endSourceSpan?.end.line + offset + 1, 41 | }), 42 | ...(endSourceSpan?.end.col && { 43 | endColumn: endSourceSpan?.end.col, 44 | }), 45 | }, 46 | }; 47 | } 48 | 49 | export function parseClassNames(classString: string): string[] { 50 | return classString.trim().split(/\s+/).filter(Boolean); 51 | } 52 | 53 | export async function visitComponentTemplate<T>( 54 | component: ParsedComponent, 55 | visitorArgument: T, 56 | getIssues: ( 57 | tokenReplacement: T, 58 | asset: Asset<ParsedTemplate>, 59 | ) => Promise<Issue[]>, 60 | ): Promise<Issue[]> { 61 | const { templateUrl, template } = component; 62 | 63 | if (templateUrl == null && template == null) { 64 | return []; 65 | } 66 | const componentTemplate = templateUrl ?? template; 67 | 68 | return getIssues(visitorArgument, componentTemplate); 69 | } 70 | 71 | /** 72 | * AST-based ngClass parser that properly detects class usage in Angular expressions 73 | * Handles arrays, objects, and ternary expressions to find actual class usage 74 | */ 75 | export function extractClassNamesFromNgClassAST( 76 | ast: AST, 77 | targetClassNames: string[], 78 | ): string[] { 79 | const foundClasses: string[] = []; 80 | const targetSet = new Set(targetClassNames); 81 | 82 | function visitAST(node: AST): void { 83 | if (!node) return; 84 | 85 | // Use duck typing instead of instanceof for better compatibility 86 | const nodeType = node.constructor.name; 87 | 88 | // Handle array literals: ['class1', 'class2', variable] 89 | if (nodeType === 'LiteralArray' && 'expressions' in node) { 90 | const arrayNode = node as any; 91 | arrayNode.expressions.forEach((expr: any) => { 92 | if ( 93 | expr.constructor.name === 'LiteralPrimitive' && 94 | typeof expr.value === 'string' 95 | ) { 96 | const classNames = parseClassNames(expr.value); 97 | classNames.forEach((className: string) => { 98 | if (targetSet.has(className)) { 99 | foundClasses.push(className); 100 | } 101 | }); 102 | } 103 | visitAST(expr); 104 | }); 105 | } 106 | // Handle object literals: { 'class1': true, 'class2': condition } 107 | else if (nodeType === 'LiteralMap' && 'keys' in node && 'values' in node) { 108 | const mapNode = node as any; 109 | mapNode.keys.forEach((key: any, index: number) => { 110 | // Handle the key structure: { key: "className", quoted: true } 111 | if (key && typeof key.key === 'string') { 112 | const classNames = parseClassNames(key.key); 113 | classNames.forEach((className: string) => { 114 | if (targetSet.has(className)) { 115 | foundClasses.push(className); 116 | } 117 | }); 118 | } 119 | // Visit the value expression but don't extract classes from it 120 | // (e.g., in { 'card': option?.logo?.toLowerCase() === 'card' }) 121 | // we don't want to extract 'card' from the comparison 122 | visitAST(mapNode.values[index]); 123 | }); 124 | } 125 | // Handle string literals: 'class1 class2' 126 | else if ( 127 | nodeType === 'LiteralPrimitive' && 128 | 'value' in node && 129 | typeof (node as any).value === 'string' 130 | ) { 131 | const primitiveNode = node as any; 132 | const classNames = parseClassNames(primitiveNode.value); 133 | classNames.forEach((className: string) => { 134 | if (targetSet.has(className)) { 135 | foundClasses.push(className); 136 | } 137 | }); 138 | } 139 | // Handle interpolation: "static {{ dynamic }} static" 140 | else if ( 141 | nodeType === 'Interpolation' && 142 | 'strings' in node && 143 | 'expressions' in node 144 | ) { 145 | const interpolationNode = node as any; 146 | // Extract class names from static string parts only 147 | // Don't process the expressions to avoid false positives 148 | interpolationNode.strings.forEach((str: string) => { 149 | if (str && str.trim()) { 150 | const classNames = parseClassNames(str); 151 | classNames.forEach((className: string) => { 152 | if (targetSet.has(className)) { 153 | foundClasses.push(className); 154 | } 155 | }); 156 | } 157 | }); 158 | // Note: We intentionally don't visit the expressions to avoid false positives 159 | // from dynamic expressions like {{ someCondition ? 'card' : 'other' }} 160 | } 161 | // Handle ternary expressions: condition ? 'class1' : 'class2' 162 | else if ( 163 | nodeType === 'Conditional' && 164 | 'trueExp' in node && 165 | 'falseExp' in node 166 | ) { 167 | const conditionalNode = node as any; 168 | // Don't visit the condition (to avoid false positives from comparisons) 169 | visitAST(conditionalNode.trueExp); 170 | visitAST(conditionalNode.falseExp); 171 | } 172 | // Handle binary expressions (avoid extracting from comparisons) 173 | else if (nodeType === 'Binary') { 174 | // For binary expressions like comparisons, we generally don't want to extract 175 | // class names from them to avoid false positives like 'card' in "option?.logo === 'card'" 176 | return; 177 | } 178 | // Handle property access: object.property 179 | else if ( 180 | (nodeType === 'PropertyRead' || nodeType === 'SafePropertyRead') && 181 | 'receiver' in node 182 | ) { 183 | const propertyNode = node as any; 184 | visitAST(propertyNode.receiver); 185 | // Don't extract from property names 186 | } 187 | // Handle keyed access: object[key] 188 | else if ( 189 | (nodeType === 'KeyedRead' || nodeType === 'SafeKeyedRead') && 190 | 'receiver' in node && 191 | 'key' in node 192 | ) { 193 | const keyedNode = node as any; 194 | visitAST(keyedNode.receiver); 195 | visitAST(keyedNode.key); 196 | } 197 | // Handle function calls: func(args) 198 | else if ( 199 | (nodeType === 'Call' || nodeType === 'SafeCall') && 200 | 'receiver' in node && 201 | 'args' in node 202 | ) { 203 | const callNode = node as any; 204 | visitAST(callNode.receiver); 205 | callNode.args.forEach((arg: any) => visitAST(arg)); 206 | } 207 | // Handle prefix not: !expression 208 | else if (nodeType === 'PrefixNot' && 'expression' in node) { 209 | const prefixNode = node as any; 210 | visitAST(prefixNode.expression); 211 | } else { 212 | const anyNode = node as any; 213 | if (anyNode.expressions && Array.isArray(anyNode.expressions)) { 214 | anyNode.expressions.forEach((expr: any) => visitAST(expr)); 215 | } 216 | if (anyNode.receiver) { 217 | visitAST(anyNode.receiver); 218 | } 219 | if (anyNode.args && Array.isArray(anyNode.args)) { 220 | anyNode.args.forEach((arg: any) => visitAST(arg)); 221 | } 222 | if (anyNode.left) { 223 | visitAST(anyNode.left); 224 | } 225 | if (anyNode.right) { 226 | visitAST(anyNode.right); 227 | } 228 | } 229 | } 230 | 231 | visitAST(ast); 232 | return Array.from(new Set(foundClasses)); 233 | } 234 | 235 | export function ngClassContainsClass( 236 | astWithSource: ASTWithSource, 237 | className: string, 238 | ): boolean { 239 | const foundClasses = extractClassNamesFromNgClassAST(astWithSource.ast, [ 240 | className, 241 | ]); 242 | return foundClasses.includes(className); 243 | } 244 | 245 | /** 246 | * Check if a class name exists in an ngClass expression string 247 | * This is a simplified regex-based implementation for backward compatibility 248 | * For more accurate AST-based parsing, use extractClassNamesFromNgClassAST directly 249 | * 250 | * @param source The ngClass expression source string 251 | * @param className The class name to search for 252 | * @returns true if the class name is found in the expression 253 | */ 254 | export function ngClassesIncludeClassName( 255 | source: string, 256 | className: string, 257 | ): boolean { 258 | const escaped = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 259 | const boundary = '[\\w$-]'; 260 | const regex = new RegExp(`(?<!${boundary})${escaped}(?!${boundary})`); 261 | 262 | return regex.test(source); 263 | } 264 | ``` -------------------------------------------------------------------------------- /packages/minimal-repo/packages/application/src/app/components/refactoring-tests/complex-components/first-case/dashboard-header.component.scss: -------------------------------------------------------------------------------- ```scss 1 | // Dashboard Header Component Styles 2 | .dashboard-header { 3 | display: flex; 4 | align-items: center; 5 | justify-content: space-between; 6 | padding: 0.75rem 1.5rem; 7 | background: #ffffff; 8 | border-bottom: 1px solid #e5e7eb; 9 | box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1); 10 | position: relative; 11 | z-index: 100; 12 | min-height: 4rem; 13 | } 14 | 15 | // Header Brand Section 16 | .header-brand { 17 | display: flex; 18 | align-items: center; 19 | gap: 1rem; 20 | flex-shrink: 0; 21 | } 22 | 23 | .brand-logo { 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | } 28 | 29 | .logo-icon { 30 | width: 2rem; 31 | height: 2rem; 32 | border-radius: 0.375rem; 33 | } 34 | 35 | .brand-title { 36 | font-size: 1.25rem; 37 | font-weight: 700; 38 | color: #1f2937; 39 | margin: 0; 40 | line-height: 1.2; 41 | } 42 | 43 | // DsBadge Component Styling 44 | .offer-badge-default-icon { 45 | font-size: 1em; 46 | line-height: 1; 47 | } 48 | 49 | .offer-badge-dismiss { 50 | background: none; 51 | border: none; 52 | color: currentColor; 53 | cursor: pointer; 54 | padding: 0; 55 | margin-left: 0.25rem; 56 | font-size: 1.125rem; 57 | line-height: 1; 58 | opacity: 0.7; 59 | transition: opacity 0.2s ease; 60 | 61 | &:hover { 62 | opacity: 1; 63 | } 64 | } 65 | 66 | // Header Search Section 67 | .header-search { 68 | flex: 1; 69 | max-width: 32rem; 70 | margin: 0 2rem; 71 | position: relative; 72 | } 73 | 74 | .search-container { 75 | position: relative; 76 | display: flex; 77 | align-items: center; 78 | background: #f9fafb; 79 | border: 1px solid #d1d5db; 80 | border-radius: 0.5rem; 81 | padding: 0 0.75rem; 82 | transition: all 0.2s ease; 83 | 84 | &.search-focused { 85 | border-color: #3b82f6; 86 | box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); 87 | background: white; 88 | } 89 | } 90 | 91 | .search-icon { 92 | color: #6b7280; 93 | flex-shrink: 0; 94 | margin-right: 0.5rem; 95 | } 96 | 97 | .search-input { 98 | flex: 1; 99 | border: none; 100 | background: transparent; 101 | padding: 0.75rem 0; 102 | font-size: 0.875rem; 103 | color: #1f2937; 104 | outline: none; 105 | 106 | &::placeholder { 107 | color: #9ca3af; 108 | } 109 | 110 | &:disabled { 111 | opacity: 0.5; 112 | cursor: not-allowed; 113 | } 114 | } 115 | 116 | .search-clear { 117 | background: none; 118 | border: none; 119 | color: #6b7280; 120 | cursor: pointer; 121 | padding: 0.25rem; 122 | margin-left: 0.5rem; 123 | font-size: 1.125rem; 124 | line-height: 1; 125 | border-radius: 0.25rem; 126 | transition: all 0.2s ease; 127 | 128 | &:hover { 129 | background: #f3f4f6; 130 | color: #374151; 131 | } 132 | } 133 | 134 | .search-suggestions { 135 | position: absolute; 136 | top: 100%; 137 | left: 0; 138 | right: 0; 139 | background: white; 140 | border: 1px solid #d1d5db; 141 | border-top: none; 142 | border-radius: 0 0 0.5rem 0.5rem; 143 | box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); 144 | z-index: 50; 145 | max-height: 12rem; 146 | overflow-y: auto; 147 | } 148 | 149 | .suggestion-item { 150 | display: block; 151 | width: 100%; 152 | padding: 0.75rem; 153 | border: none; 154 | background: none; 155 | text-align: left; 156 | font-size: 0.875rem; 157 | color: #374151; 158 | cursor: pointer; 159 | transition: background-color 0.2s ease; 160 | 161 | &:hover { 162 | background: #f9fafb; 163 | } 164 | 165 | &:not(:last-child) { 166 | border-bottom: 1px solid #f3f4f6; 167 | } 168 | } 169 | 170 | // Header Actions Section 171 | .header-actions { 172 | display: flex; 173 | align-items: center; 174 | gap: 0.5rem; 175 | flex-shrink: 0; 176 | } 177 | 178 | .action-item { 179 | position: relative; 180 | } 181 | 182 | .action-button { 183 | display: flex; 184 | align-items: center; 185 | gap: 0.5rem; 186 | padding: 0.5rem; 187 | border: none; 188 | background: none; 189 | border-radius: 0.5rem; 190 | cursor: pointer; 191 | transition: all 0.2s ease; 192 | color: #6b7280; 193 | 194 | &:hover { 195 | background: #f3f4f6; 196 | color: #374151; 197 | } 198 | } 199 | 200 | // Notifications 201 | .notification-button { 202 | position: relative; 203 | 204 | &.has-notifications { 205 | color: #3b82f6; 206 | } 207 | } 208 | 209 | .notification-icon { 210 | width: 1.5rem; 211 | height: 1.5rem; 212 | } 213 | 214 | .notification-badge { 215 | position: absolute; 216 | top: -0.25rem; 217 | right: -0.25rem; 218 | background: #ef4444; 219 | color: white; 220 | font-size: 0.625rem; 221 | font-weight: 600; 222 | padding: 0.125rem 0.375rem; 223 | border-radius: 9999px; 224 | min-width: 1.125rem; 225 | text-align: center; 226 | line-height: 1; 227 | } 228 | 229 | .notifications-dropdown { 230 | position: absolute; 231 | top: 100%; 232 | right: 0; 233 | width: 20rem; 234 | background: white; 235 | border: 1px solid #d1d5db; 236 | border-radius: 0.5rem; 237 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); 238 | z-index: 50; 239 | margin-top: 0.5rem; 240 | max-height: 24rem; 241 | overflow: hidden; 242 | } 243 | 244 | .dropdown-header { 245 | display: flex; 246 | align-items: center; 247 | justify-content: space-between; 248 | padding: 1rem; 249 | border-bottom: 1px solid #f3f4f6; 250 | 251 | h3 { 252 | margin: 0; 253 | font-size: 1rem; 254 | font-weight: 600; 255 | color: #1f2937; 256 | } 257 | } 258 | 259 | .mark-all-read { 260 | background: none; 261 | border: none; 262 | color: #3b82f6; 263 | font-size: 0.875rem; 264 | cursor: pointer; 265 | padding: 0.25rem 0.5rem; 266 | border-radius: 0.25rem; 267 | transition: background-color 0.2s ease; 268 | 269 | &:hover { 270 | background: #f3f4f6; 271 | } 272 | } 273 | 274 | .notifications-list { 275 | max-height: 18rem; 276 | overflow-y: auto; 277 | } 278 | 279 | .notification-item { 280 | display: flex; 281 | align-items: flex-start; 282 | padding: 0.75rem 1rem; 283 | border-bottom: 1px solid #f9fafb; 284 | transition: background-color 0.2s ease; 285 | 286 | &:hover { 287 | background: #f9fafb; 288 | } 289 | 290 | &.notification-unread { 291 | background: #eff6ff; 292 | border-left: 3px solid #3b82f6; 293 | } 294 | 295 | &.notification-error { 296 | border-left-color: #ef4444; 297 | } 298 | 299 | &.notification-warning { 300 | border-left-color: #f59e0b; 301 | } 302 | 303 | &.notification-success { 304 | border-left-color: #10b981; 305 | } 306 | } 307 | 308 | .notification-content { 309 | flex: 1; 310 | margin-right: 0.5rem; 311 | } 312 | 313 | .notification-title { 314 | margin: 0 0 0.25rem 0; 315 | font-size: 0.875rem; 316 | font-weight: 600; 317 | color: #1f2937; 318 | line-height: 1.25; 319 | } 320 | 321 | .notification-message { 322 | margin: 0 0 0.5rem 0; 323 | font-size: 0.75rem; 324 | color: #6b7280; 325 | line-height: 1.4; 326 | } 327 | 328 | .notification-time { 329 | font-size: 0.625rem; 330 | color: #9ca3af; 331 | } 332 | 333 | .notification-dismiss { 334 | background: none; 335 | border: none; 336 | color: #9ca3af; 337 | cursor: pointer; 338 | padding: 0.25rem; 339 | font-size: 1rem; 340 | line-height: 1; 341 | border-radius: 0.25rem; 342 | transition: all 0.2s ease; 343 | 344 | &:hover { 345 | background: #f3f4f6; 346 | color: #6b7280; 347 | } 348 | } 349 | 350 | .no-notifications { 351 | padding: 2rem 1rem; 352 | text-align: center; 353 | color: #9ca3af; 354 | font-size: 0.875rem; 355 | 356 | p { 357 | margin: 0; 358 | } 359 | } 360 | 361 | // User Menu 362 | .user-button { 363 | padding: 0.375rem 0.75rem; 364 | gap: 0.5rem; 365 | } 366 | 367 | .user-avatar { 368 | width: 2rem; 369 | height: 2rem; 370 | border-radius: 50%; 371 | object-fit: cover; 372 | } 373 | 374 | .user-avatar-placeholder { 375 | width: 2rem; 376 | height: 2rem; 377 | border-radius: 50%; 378 | background: #3b82f6; 379 | color: white; 380 | display: flex; 381 | align-items: center; 382 | justify-content: center; 383 | font-size: 0.75rem; 384 | font-weight: 600; 385 | } 386 | 387 | .dropdown-arrow { 388 | color: #9ca3af; 389 | transition: transform 0.2s ease; 390 | } 391 | 392 | .user-dropdown { 393 | position: absolute; 394 | top: 100%; 395 | right: 0; 396 | width: 16rem; 397 | background: white; 398 | border: 1px solid #d1d5db; 399 | border-radius: 0.5rem; 400 | box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); 401 | z-index: 50; 402 | margin-top: 0.5rem; 403 | overflow: hidden; 404 | } 405 | 406 | .user-info { 407 | padding: 1rem; 408 | border-bottom: 1px solid #f3f4f6; 409 | } 410 | 411 | .user-details { 412 | h4 { 413 | margin: 0 0 0.25rem 0; 414 | font-size: 0.875rem; 415 | font-weight: 600; 416 | color: #1f2937; 417 | } 418 | 419 | p { 420 | margin: 0 0 0.5rem 0; 421 | font-size: 0.75rem; 422 | color: #6b7280; 423 | } 424 | } 425 | 426 | .user-role { 427 | display: inline-block; 428 | padding: 0.125rem 0.5rem; 429 | background: #f3f4f6; 430 | color: #6b7280; 431 | font-size: 0.625rem; 432 | font-weight: 500; 433 | border-radius: 9999px; 434 | text-transform: uppercase; 435 | letter-spacing: 0.05em; 436 | } 437 | 438 | .user-actions { 439 | padding: 0.5rem 0; 440 | } 441 | 442 | .dropdown-item { 443 | display: block; 444 | width: 100%; 445 | padding: 0.5rem 1rem; 446 | border: none; 447 | background: none; 448 | text-align: left; 449 | font-size: 0.875rem; 450 | color: #374151; 451 | cursor: pointer; 452 | transition: background-color 0.2s ease; 453 | 454 | &:hover { 455 | background: #f9fafb; 456 | } 457 | 458 | &.logout { 459 | color: #ef4444; 460 | 461 | &:hover { 462 | background: #fef2f2; 463 | } 464 | } 465 | } 466 | 467 | .dropdown-divider { 468 | margin: 0.5rem 0; 469 | border: none; 470 | border-top: 1px solid #f3f4f6; 471 | } 472 | 473 | 474 | 475 | // Dark mode support 476 | @media (prefers-color-scheme: dark) { 477 | .dashboard-header { 478 | background: #1f2937; 479 | border-bottom-color: #374151; 480 | } 481 | 482 | .brand-title { 483 | color: #f9fafb; 484 | } 485 | 486 | .search-container { 487 | background: #374151; 488 | border-color: #4b5563; 489 | 490 | &.search-focused { 491 | background: #4b5563; 492 | border-color: #3b82f6; 493 | } 494 | } 495 | 496 | .search-input { 497 | color: #f9fafb; 498 | 499 | &::placeholder { 500 | color: #9ca3af; 501 | } 502 | } 503 | 504 | .search-suggestions, 505 | .notifications-dropdown, 506 | .user-dropdown { 507 | background: #374151; 508 | border-color: #4b5563; 509 | } 510 | 511 | .dropdown-header h3, 512 | .notification-title, 513 | .user-details h4 { 514 | color: #f9fafb; 515 | } 516 | 517 | .dropdown-item { 518 | color: #d1d5db; 519 | 520 | &:hover { 521 | background: #4b5563; 522 | } 523 | } 524 | } 525 | 526 | // Responsive design 527 | @media (max-width: 768px) { 528 | .dashboard-header { 529 | padding: 0.5rem 1rem; 530 | flex-wrap: wrap; 531 | gap: 0.5rem; 532 | } 533 | 534 | .header-search { 535 | order: 3; 536 | flex-basis: 100%; 537 | margin: 0; 538 | max-width: none; 539 | } 540 | 541 | .brand-title { 542 | font-size: 1rem; 543 | } 544 | 545 | .notifications-dropdown, 546 | .user-dropdown { 547 | width: 16rem; 548 | right: -4rem; 549 | } 550 | } ``` -------------------------------------------------------------------------------- /packages/minimal-repo/packages/application/src/app/styles/extended-deprecated-styles.scss: -------------------------------------------------------------------------------- ```scss 1 | // Extended stylesheet with nested and combined selectors for deprecated classes 2 | // This file demonstrates various ways deprecated classes might be used in real applications 3 | 4 | // Nested selectors for badge-related deprecated classes 5 | .container { 6 | .pill-with-badge { 7 | color: red; 8 | border: 1px solid #ccc; 9 | padding: 5px 10px; 10 | border-radius: 15px; 11 | display: inline-block; 12 | 13 | &:hover { 14 | background-color: #f0f0f0; 15 | } 16 | 17 | &.active { 18 | background-color: #e0e0e0; 19 | } 20 | } 21 | 22 | .pill-with-badge-v2 { 23 | color: blue; 24 | border: 2px solid #aaa; 25 | padding: 6px 12px; 26 | border-radius: 20px; 27 | display: inline-block; 28 | 29 | &.large { 30 | padding: 8px 16px; 31 | } 32 | } 33 | 34 | .sports-pill { 35 | color: green; 36 | background-color: #f0f0f0; 37 | padding: 8px 16px; 38 | border-radius: 25px; 39 | display: inline-block; 40 | 41 | &.highlighted { 42 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); 43 | } 44 | } 45 | 46 | .offer-badge { 47 | color: yellow; 48 | background-color: #333; 49 | padding: 4px 8px; 50 | border-radius: 10px; 51 | display: inline-block; 52 | 53 | &.urgent { 54 | animation: pulse 1s infinite; 55 | } 56 | } 57 | } 58 | 59 | // Combined selectors for navigation deprecated classes 60 | .navigation-wrapper { 61 | .tab-nav, 62 | .nav-tabs { 63 | padding: 10px; 64 | border-bottom: 2px solid #ddd; 65 | 66 | .tab-nav-item { 67 | color: pink; 68 | padding: 10px 15px; 69 | border-radius: 5px; 70 | display: inline-block; 71 | cursor: pointer; 72 | 73 | &:hover { 74 | background-color: #f5f5f5; 75 | } 76 | 77 | &.active { 78 | background-color: #007bff; 79 | color: white; 80 | } 81 | } 82 | } 83 | 84 | .tab-nav { 85 | color: orange; 86 | background-color: #fff; 87 | } 88 | 89 | .nav-tabs { 90 | color: purple; 91 | background-color: #eee; 92 | } 93 | } 94 | 95 | // Nested button selectors 96 | .button-group { 97 | .btn { 98 | color: brown; 99 | background-color: #f5f5f5; 100 | padding: 10px 20px; 101 | border: none; 102 | border-radius: 5px; 103 | cursor: pointer; 104 | 105 | &:hover { 106 | background-color: #e0e0e0; 107 | } 108 | 109 | &:disabled { 110 | opacity: 0.6; 111 | cursor: not-allowed; 112 | } 113 | } 114 | 115 | .btn-primary { 116 | color: cyan; 117 | background-color: #007bff; 118 | padding: 10px 20px; 119 | border: none; 120 | border-radius: 5px; 121 | cursor: pointer; 122 | 123 | &:hover { 124 | background-color: #0056b3; 125 | } 126 | 127 | &.loading { 128 | position: relative; 129 | 130 | &::after { 131 | content: ''; 132 | position: absolute; 133 | width: 16px; 134 | height: 16px; 135 | border: 2px solid transparent; 136 | border-top: 2px solid currentColor; 137 | border-radius: 50%; 138 | animation: spin 1s linear infinite; 139 | } 140 | } 141 | } 142 | 143 | .legacy-button { 144 | color: magenta; 145 | background-color: #f8f9fa; 146 | padding: 10px 20px; 147 | border: 1px solid #ccc; 148 | border-radius: 5px; 149 | cursor: pointer; 150 | 151 | &.deprecated-style { 152 | border-style: dashed; 153 | } 154 | } 155 | } 156 | 157 | // Modal and card combinations 158 | .content-area { 159 | .modal { 160 | color: lime; 161 | background-color: #fff; 162 | padding: 20px; 163 | border-radius: 10px; 164 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); 165 | 166 | .card { 167 | color: olive; 168 | background-color: #f8f9fa; 169 | padding: 15px; 170 | border-radius: 5px; 171 | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); 172 | margin-bottom: 15px; 173 | 174 | &:last-child { 175 | margin-bottom: 0; 176 | } 177 | } 178 | 179 | &.with-loading { 180 | .loading, 181 | .loading-v2, 182 | .loading-v3 { 183 | display: flex; 184 | align-items: center; 185 | justify-content: center; 186 | margin: 20px 0; 187 | } 188 | 189 | .loading { 190 | color: teal; 191 | font-size: 16px; 192 | } 193 | 194 | .loading-v2 { 195 | color: navy; 196 | font-size: 18px; 197 | } 198 | 199 | .loading-v3 { 200 | color: maroon; 201 | font-size: 20px; 202 | } 203 | } 204 | } 205 | } 206 | 207 | // Complex nested form controls 208 | .form-section { 209 | .form-control-tabs-segmented, 210 | .form-control-tabs-segmented-v2, 211 | .form-control-tabs-segmented-v3, 212 | .form-control-tabs-segmented-v4, 213 | .form-control-tabs-segmented-flex, 214 | .form-control-tabs-segmented-v2-dark { 215 | padding: 10px; 216 | border-radius: 5px; 217 | display: flex; 218 | justify-content: space-between; 219 | margin-bottom: 15px; 220 | 221 | &.with-custom-controls { 222 | .custom-control-checkbox, 223 | .custom-control-radio, 224 | .custom-control-switcher { 225 | display: flex; 226 | align-items: center; 227 | margin-right: 15px; 228 | 229 | &:last-child { 230 | margin-right: 0; 231 | } 232 | } 233 | } 234 | } 235 | 236 | .form-control-tabs-segmented { 237 | color: wheat; 238 | background-color: #fff; 239 | } 240 | 241 | .form-control-tabs-segmented-v2 { 242 | color: salmon; 243 | background-color: #fff; 244 | } 245 | 246 | .form-control-tabs-segmented-v3 { 247 | color: turquoise; 248 | background-color: #fff; 249 | } 250 | 251 | .form-control-tabs-segmented-v4 { 252 | color: violet; 253 | background-color: #f8f9fa; 254 | } 255 | 256 | .form-control-tabs-segmented-flex { 257 | color: sienna; 258 | background-color: #f8f9fa; 259 | } 260 | 261 | .form-control-tabs-segmented-v2-dark { 262 | color: tan; 263 | background-color: #333; 264 | } 265 | } 266 | 267 | // Utility classes with nested selectors 268 | .utility-section { 269 | .collapsible-container { 270 | color: silver; 271 | background-color: #f0f0f0; 272 | padding: 10px; 273 | border-radius: 5px; 274 | overflow: hidden; 275 | 276 | &.expanded { 277 | max-height: none; 278 | } 279 | 280 | &.collapsed { 281 | max-height: 50px; 282 | } 283 | 284 | .divider { 285 | color: gray; 286 | border-top: 1px solid #ccc; 287 | margin: 10px 0; 288 | 289 | &.thick { 290 | border-top-width: 2px; 291 | } 292 | } 293 | } 294 | 295 | .count, 296 | .badge-circle { 297 | padding: 5px 10px; 298 | border-radius: 50%; 299 | display: inline-block; 300 | 301 | &.small { 302 | padding: 3px 6px; 303 | font-size: 12px; 304 | } 305 | 306 | &.large { 307 | padding: 8px 16px; 308 | font-size: 18px; 309 | } 310 | } 311 | 312 | .count { 313 | color: gold; 314 | background-color: #333; 315 | } 316 | 317 | .badge-circle { 318 | color: coral; 319 | background-color: #f0f0f0; 320 | } 321 | } 322 | 323 | // Random classes with various combinations 324 | .random-section { 325 | @for $i from 1 through 50 { 326 | .random-class-#{$i} { 327 | background-color: lighten(#000, $i * 2%); 328 | 329 | &:hover { 330 | background-color: lighten(#000, ($i * 2% + 10%)); 331 | } 332 | 333 | &.active { 334 | background-color: darken(#000, $i * 1%); 335 | } 336 | 337 | // Nested combinations 338 | .pill-with-badge, 339 | .offer-badge { 340 | margin: 5px; 341 | 342 | &.inline { 343 | display: inline-block; 344 | } 345 | } 346 | 347 | .btn, 348 | .btn-primary, 349 | .legacy-button { 350 | margin-right: 10px; 351 | 352 | &:last-child { 353 | margin-right: 0; 354 | } 355 | } 356 | } 357 | } 358 | } 359 | 360 | // Complex multi-level nesting 361 | .complex-layout { 362 | .header { 363 | .nav-tabs { 364 | .tab-nav-item { 365 | .pill-with-badge { 366 | font-size: 12px; 367 | 368 | &.notification { 369 | .count { 370 | position: absolute; 371 | top: -5px; 372 | right: -5px; 373 | } 374 | } 375 | } 376 | } 377 | } 378 | } 379 | 380 | .main-content { 381 | .modal { 382 | .card { 383 | .form-control-tabs-segmented { 384 | .custom-control-checkbox { 385 | .loading { 386 | margin-left: 10px; 387 | } 388 | } 389 | } 390 | } 391 | } 392 | } 393 | 394 | .sidebar { 395 | .collapsible-container { 396 | .sports-pill, 397 | .offer-badge { 398 | display: block; 399 | margin-bottom: 5px; 400 | 401 | &:hover { 402 | .badge-circle { 403 | transform: scale(1.1); 404 | } 405 | } 406 | } 407 | } 408 | } 409 | } 410 | 411 | // Media query combinations 412 | @media (max-width: 768px) { 413 | .mobile-specific { 414 | .pill-with-badge, 415 | .pill-with-badge-v2, 416 | .sports-pill, 417 | .offer-badge { 418 | display: block; 419 | width: 100%; 420 | text-align: center; 421 | margin-bottom: 10px; 422 | } 423 | 424 | .btn, 425 | .btn-primary, 426 | .legacy-button { 427 | width: 100%; 428 | margin-bottom: 10px; 429 | } 430 | 431 | .form-control-tabs-segmented, 432 | .form-control-tabs-segmented-v2, 433 | .form-control-tabs-segmented-v3, 434 | .form-control-tabs-segmented-v4 { 435 | flex-direction: column; 436 | 437 | .custom-control-checkbox, 438 | .custom-control-radio, 439 | .custom-control-switcher { 440 | margin-bottom: 10px; 441 | } 442 | } 443 | } 444 | } 445 | 446 | // Keyframe animations for deprecated classes 447 | @keyframes pulse { 448 | 0% { 449 | transform: scale(1); 450 | } 451 | 50% { 452 | transform: scale(1.05); 453 | } 454 | 100% { 455 | transform: scale(1); 456 | } 457 | } 458 | 459 | @keyframes spin { 460 | 0% { 461 | transform: rotate(0deg); 462 | } 463 | 100% { 464 | transform: rotate(360deg); 465 | } 466 | } 467 | 468 | @keyframes fadeIn { 469 | 0% { 470 | opacity: 0; 471 | } 472 | 100% { 473 | opacity: 1; 474 | } 475 | } 476 | 477 | // Pseudo-element combinations 478 | .enhanced-deprecated { 479 | .pill-with-badge::before, 480 | .sports-pill::before, 481 | .offer-badge::before { 482 | content: '⚠️'; 483 | margin-right: 5px; 484 | } 485 | 486 | .btn::after, 487 | .btn-primary::after, 488 | .legacy-button::after { 489 | content: ''; 490 | position: absolute; 491 | bottom: 0; 492 | left: 0; 493 | width: 100%; 494 | height: 2px; 495 | background: linear-gradient(90deg, transparent, currentColor, transparent); 496 | opacity: 0; 497 | transition: opacity 0.3s; 498 | } 499 | 500 | .btn:hover::after, 501 | .btn-primary:hover::after, 502 | .legacy-button:hover::after { 503 | opacity: 1; 504 | } 505 | } 506 | ``` -------------------------------------------------------------------------------- /packages/angular-mcp-server/src/lib/angular-mcp-server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { PROMPTS, PROMPTS_IMPL } from './prompts/prompt-registry.js'; 2 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; 3 | import { 4 | CallToolRequest, 5 | CallToolRequestSchema, 6 | GetPromptRequestSchema, 7 | GetPromptResult, 8 | ListPromptsRequestSchema, 9 | ListPromptsResult, 10 | ListResourcesRequestSchema, 11 | ListResourcesResult, 12 | ListToolsRequestSchema, 13 | } from '@modelcontextprotocol/sdk/types.js'; 14 | import { TOOLS } from './tools/tools.js'; 15 | import { toolNotFound } from './tools/utils.js'; 16 | import * as fs from 'node:fs'; 17 | import * as path from 'node:path'; 18 | import { 19 | AngularMcpServerOptionsSchema, 20 | AngularMcpServerOptions, 21 | } from './validation/angular-mcp-server-options.schema.js'; 22 | import { validateAngularMcpServerFilesExist } from './validation/file-existence.js'; 23 | import { validateDeprecatedCssClassesFile } from './validation/ds-components-file.validation.js'; 24 | 25 | export class AngularMcpServerWrapper { 26 | private readonly mcpServer: McpServer; 27 | private readonly workspaceRoot: string; 28 | private readonly storybookDocsRoot?: string; 29 | private readonly deprecatedCssClassesPath?: string; 30 | private readonly uiRoot: string; 31 | 32 | /** 33 | * Private constructor - use AngularMcpServerWrapper.create() instead. 34 | * Config is already validated when this constructor is called. 35 | */ 36 | private constructor(config: AngularMcpServerOptions) { 37 | // Config is already validated, no need to validate again 38 | const { workspaceRoot, ds } = config; 39 | 40 | this.workspaceRoot = workspaceRoot; 41 | this.storybookDocsRoot = ds.storybookDocsRoot; 42 | this.deprecatedCssClassesPath = ds.deprecatedCssClassesPath; 43 | this.uiRoot = ds.uiRoot; 44 | 45 | this.mcpServer = new McpServer({ 46 | name: 'Angular MCP', 47 | version: '0.0.0', 48 | }); 49 | 50 | this.mcpServer.server.registerCapabilities({ 51 | prompts: {}, 52 | tools: {}, 53 | resources: {}, 54 | }); 55 | this.registerPrompts(); 56 | this.registerTools(); 57 | this.registerResources(); 58 | } 59 | 60 | /** 61 | * Creates and validates an AngularMcpServerWrapper instance. 62 | * This is the recommended way to create an instance as it performs all necessary validations. 63 | * 64 | * @param config - The Angular MCP server configuration options 65 | * @returns A Promise that resolves to a fully configured AngularMcpServerWrapper instance 66 | * @throws {Error} If configuration validation fails or required files don't exist 67 | */ 68 | static async create( 69 | config: AngularMcpServerOptions, 70 | ): Promise<AngularMcpServerWrapper> { 71 | // Validate config using the Zod schema - only once here 72 | const validatedConfig = AngularMcpServerOptionsSchema.parse(config); 73 | 74 | // Validate file existence (optional keys are checked only when provided) 75 | validateAngularMcpServerFilesExist(validatedConfig); 76 | 77 | // Load and validate deprecatedCssClassesPath content only if provided 78 | if (validatedConfig.ds.deprecatedCssClassesPath) { 79 | await validateDeprecatedCssClassesFile(validatedConfig); 80 | } 81 | 82 | return new AngularMcpServerWrapper(validatedConfig); 83 | } 84 | 85 | getMcpServer(): McpServer { 86 | return this.mcpServer; 87 | } 88 | 89 | private registerResources() { 90 | this.mcpServer.server.setRequestHandler( 91 | ListResourcesRequestSchema, 92 | async (): Promise<ListResourcesResult> => { 93 | const resources = []; 94 | 95 | // Try to read the llms.txt file from the package root (optional) 96 | try { 97 | const filePath = path.resolve(__dirname, '../../llms.txt'); 98 | 99 | // Only attempt to read if file exists 100 | if (fs.existsSync(filePath)) { 101 | console.log('Reading llms.txt from:', filePath); 102 | const content = fs.readFileSync(filePath, 'utf-8'); 103 | const lines = content.split('\n'); 104 | 105 | let currentSection = ''; 106 | 107 | for (let i = 0; i < lines.length; i++) { 108 | const line = lines[i].trim(); 109 | 110 | // Skip empty lines and comments that don't start with # 111 | if (!line || (line.startsWith('#') && !line.includes(':'))) { 112 | continue; 113 | } 114 | 115 | // Update section if line starts with # 116 | if (line.startsWith('# ')) { 117 | currentSection = line.substring(2).replace(':', '').trim(); 118 | continue; 119 | } 120 | 121 | // Parse markdown links: [name](url) 122 | const linkMatch = line.match(/- \[(.*?)\]\((.*?)\):(.*)/); 123 | if (linkMatch) { 124 | const [, name, uri, description = ''] = linkMatch; 125 | resources.push({ 126 | uri, 127 | name: name.trim(), 128 | type: currentSection.toLowerCase(), 129 | content: description.trim() || name.trim(), 130 | }); 131 | continue; 132 | } 133 | 134 | // Parse simple links: - [name](url) 135 | const simpleLinkMatch = line.match(/- \[(.*?)\]\((.*?)\)/); 136 | if (simpleLinkMatch) { 137 | const [, name, uri] = simpleLinkMatch; 138 | resources.push({ 139 | uri, 140 | name: name.trim(), 141 | type: currentSection.toLowerCase(), 142 | content: name.trim(), 143 | }); 144 | } 145 | } 146 | } else { 147 | console.log('llms.txt not found at:', filePath, '(skipping)'); 148 | } 149 | } catch (ctx: unknown) { 150 | if (ctx instanceof Error) { 151 | console.error('Error reading llms.txt (non-fatal):', ctx.message); 152 | } 153 | } 154 | 155 | // Scan available design system components to add them as discoverable resources 156 | try { 157 | if (this.storybookDocsRoot) { 158 | const dsUiPath = path.resolve( 159 | process.cwd(), 160 | this.storybookDocsRoot, 161 | ); 162 | if (fs.existsSync(dsUiPath)) { 163 | const componentFolders = fs 164 | .readdirSync(dsUiPath, { withFileTypes: true }) 165 | .filter((dirent) => dirent.isDirectory()) 166 | .map((dirent) => dirent.name); 167 | 168 | for (const folder of componentFolders) { 169 | // Convert kebab-case to PascalCase with 'Ds' prefix 170 | const componentName = 171 | 'Ds' + 172 | folder 173 | .split('-') 174 | .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) 175 | .join(''); 176 | 177 | resources.push({ 178 | uri: `ds-component://${folder}`, 179 | name: componentName, 180 | type: 'design-system-component', 181 | content: `Design System component: ${componentName}`, 182 | }); 183 | } 184 | } 185 | } 186 | } catch (ctx: unknown) { 187 | if (ctx instanceof Error) { 188 | console.error( 189 | 'Error scanning DS components (non-fatal):', 190 | ctx.message, 191 | ); 192 | } 193 | } 194 | 195 | return { 196 | resources, 197 | }; 198 | }, 199 | ); 200 | } 201 | 202 | private registerPrompts() { 203 | this.mcpServer.server.setRequestHandler( 204 | ListPromptsRequestSchema, 205 | async (): Promise<ListPromptsResult> => { 206 | return { 207 | prompts: Object.values(PROMPTS), 208 | }; 209 | }, 210 | ); 211 | 212 | this.mcpServer.server.setRequestHandler( 213 | GetPromptRequestSchema, 214 | async (request): Promise<GetPromptResult> => { 215 | const prompt = PROMPTS[request.params.name]; 216 | if (!prompt) { 217 | throw new Error(`Prompt not found: ${request.params.name}`); 218 | } 219 | 220 | const promptResult = PROMPTS_IMPL[request.params.name]; 221 | // Register all prompts 222 | if (promptResult && promptResult.text) { 223 | return { 224 | messages: [ 225 | { 226 | role: 'user', 227 | content: { 228 | type: 'text', 229 | text: promptResult.text(request.params.arguments ?? {}), 230 | }, 231 | }, 232 | ], 233 | }; 234 | } 235 | throw new Error('Prompt implementation not found'); 236 | }, 237 | ); 238 | } 239 | 240 | private registerTools() { 241 | this.mcpServer.server.setRequestHandler( 242 | ListToolsRequestSchema, 243 | async () => { 244 | return { 245 | tools: TOOLS.map(({ schema }) => schema), 246 | }; 247 | }, 248 | ); 249 | 250 | this.mcpServer.server.setRequestHandler( 251 | CallToolRequestSchema, 252 | async (request: CallToolRequest) => { 253 | const tool = TOOLS.find( 254 | ({ schema }) => request.params.name === schema.name, 255 | ); 256 | 257 | if (tool?.schema && tool.schema.name === request.params.name) { 258 | return await tool.handler({ 259 | ...request, 260 | params: { 261 | ...request.params, 262 | arguments: { 263 | ...request.params.arguments, 264 | storybookDocsRoot: this.storybookDocsRoot, 265 | deprecatedCssClassesPath: this.deprecatedCssClassesPath, 266 | uiRoot: this.uiRoot, 267 | cwd: this.workspaceRoot, 268 | workspaceRoot: this.workspaceRoot, 269 | }, 270 | }, 271 | }); 272 | } 273 | 274 | return { 275 | content: [toolNotFound(request)], 276 | isError: false, 277 | }; 278 | }, 279 | ); 280 | } 281 | } 282 | ``` -------------------------------------------------------------------------------- /packages/angular-mcp-server/src/lib/tools/ds/component-usage-graph/utils/unified-ast-analyzer.ts: -------------------------------------------------------------------------------- ```typescript 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | import * as ts from 'typescript'; 4 | import { toUnixPath } from '@code-pushup/utils'; 5 | 6 | import { 7 | DependencyInfo, 8 | FileInfo, 9 | ComponentMetadata, 10 | FileExtension, 11 | } from '../models/types.js'; 12 | import { 13 | DEPENDENCY_ANALYSIS_CONFIG, 14 | REGEX_PATTERNS, 15 | getCombinedComponentImportRegex, 16 | } from '../models/config.js'; 17 | import { isExternal, resolveDependencyPath } from './path-resolver.js'; 18 | 19 | const DEP_REGEX_TABLE: Array<[RegExp, DependencyInfo['type']]> = [ 20 | [REGEX_PATTERNS.ES6_IMPORT, 'import'], 21 | [REGEX_PATTERNS.COMMONJS_REQUIRE, 'require'], 22 | [REGEX_PATTERNS.DYNAMIC_IMPORT, 'dynamic-import'], 23 | ]; 24 | 25 | const STYLE_REGEX_TABLE: Array< 26 | [RegExp, DependencyInfo['type'], ((p: string) => boolean)?] 27 | > = [ 28 | [REGEX_PATTERNS.CSS_IMPORT, 'css-import'], 29 | [ 30 | REGEX_PATTERNS.CSS_URL, 31 | 'asset', 32 | (u) => !u.startsWith('http') && !u.startsWith('data:'), 33 | ], 34 | ]; 35 | 36 | export interface UnifiedAnalysisResult { 37 | dependencies: DependencyInfo[]; 38 | componentMetadata?: ComponentMetadata; 39 | importedComponentNames: string[]; 40 | isAngularComponent: boolean; 41 | } 42 | 43 | export async function analyzeFileWithUnifiedAST( 44 | filePath: string, 45 | basePath: string, 46 | componentNamesForReverseDeps?: string[], 47 | ): Promise<UnifiedAnalysisResult> { 48 | const content = await fs.promises.readFile(filePath, 'utf-8'); 49 | 50 | try { 51 | return analyzeContentWithUnifiedAST( 52 | content, 53 | filePath, 54 | basePath, 55 | componentNamesForReverseDeps, 56 | ); 57 | } catch { 58 | return analyzeContentWithRegexFallback( 59 | content, 60 | filePath, 61 | basePath, 62 | componentNamesForReverseDeps, 63 | ); 64 | } 65 | } 66 | 67 | function analyzeContentWithUnifiedAST( 68 | content: string, 69 | filePath: string, 70 | basePath: string, 71 | componentNamesForReverseDeps?: string[], 72 | ): UnifiedAnalysisResult { 73 | const sourceFile = ts.createSourceFile( 74 | filePath, 75 | content, 76 | ts.ScriptTarget.Latest, 77 | true, 78 | ); 79 | 80 | const result: UnifiedAnalysisResult = { 81 | dependencies: [], 82 | importedComponentNames: [], 83 | isAngularComponent: false, 84 | }; 85 | 86 | const componentNameSet = componentNamesForReverseDeps 87 | ? new Set(componentNamesForReverseDeps) 88 | : new Set<string>(); 89 | let componentClassName: string | undefined; 90 | 91 | const visit = (node: ts.Node): void => { 92 | if (ts.isImportDeclaration(node) && node.moduleSpecifier) { 93 | if (ts.isStringLiteral(node.moduleSpecifier)) { 94 | const importPath = node.moduleSpecifier.text; 95 | result.dependencies.push( 96 | createDependencyInfo(importPath, 'import', filePath, basePath), 97 | ); 98 | 99 | if ( 100 | componentNamesForReverseDeps && 101 | componentNamesForReverseDeps.length > 0 && 102 | node.importClause 103 | ) { 104 | extractComponentImportsFromImportNode( 105 | node, 106 | componentNameSet, 107 | result.importedComponentNames, 108 | ); 109 | } 110 | } 111 | } else if (ts.isCallExpression(node)) { 112 | if ( 113 | ts.isIdentifier(node.expression) && 114 | node.expression.text === 'require' && 115 | node.arguments.length === 1 && 116 | ts.isStringLiteral(node.arguments[0]) 117 | ) { 118 | const importPath = node.arguments[0].text; 119 | result.dependencies.push( 120 | createDependencyInfo(importPath, 'require', filePath, basePath), 121 | ); 122 | } else if ( 123 | node.expression.kind === ts.SyntaxKind.ImportKeyword && 124 | node.arguments.length === 1 && 125 | ts.isStringLiteral(node.arguments[0]) 126 | ) { 127 | const importPath = node.arguments[0].text; 128 | result.dependencies.push( 129 | createDependencyInfo( 130 | importPath, 131 | 'dynamic-import', 132 | filePath, 133 | basePath, 134 | ), 135 | ); 136 | } 137 | } else if (ts.isClassDeclaration(node) && node.name) { 138 | const hasComponentDecorator = ts 139 | .getDecorators?.(node as ts.HasDecorators) 140 | ?.some((decorator) => { 141 | if (ts.isCallExpression(decorator.expression)) { 142 | return ( 143 | ts.isIdentifier(decorator.expression.expression) && 144 | decorator.expression.expression.text === 'Component' 145 | ); 146 | } 147 | return ( 148 | ts.isIdentifier(decorator.expression) && 149 | decorator.expression.text === 'Component' 150 | ); 151 | }); 152 | 153 | if (hasComponentDecorator) { 154 | result.isAngularComponent = true; 155 | componentClassName = node.name.text; 156 | } 157 | } 158 | 159 | ts.forEachChild(node, visit); 160 | }; 161 | 162 | visit(sourceFile); 163 | 164 | if (result.isAngularComponent && componentClassName) { 165 | result.componentMetadata = { 166 | className: componentClassName, 167 | }; 168 | } 169 | 170 | return result; 171 | } 172 | 173 | function extractComponentImportsFromImportNode( 174 | importNode: ts.ImportDeclaration, 175 | componentNameSet: Set<string>, 176 | foundComponents: string[], 177 | ): void { 178 | const importClause = importNode.importClause; 179 | if (!importClause) return; 180 | 181 | if ( 182 | importClause.namedBindings && 183 | ts.isNamedImports(importClause.namedBindings) 184 | ) { 185 | for (const element of importClause.namedBindings.elements) { 186 | const importName = element.name.text; 187 | if (componentNameSet.has(importName)) { 188 | foundComponents.push(importName); 189 | } 190 | } 191 | } 192 | 193 | if (importClause.name) { 194 | const importName = importClause.name.text; 195 | if (componentNameSet.has(importName)) { 196 | foundComponents.push(importName); 197 | } 198 | } 199 | } 200 | 201 | function analyzeContentWithRegexFallback( 202 | content: string, 203 | filePath: string, 204 | basePath: string, 205 | componentNamesForReverseDeps?: string[], 206 | ): UnifiedAnalysisResult { 207 | const result: UnifiedAnalysisResult = { 208 | dependencies: [], 209 | importedComponentNames: [], 210 | isAngularComponent: false, 211 | }; 212 | 213 | DEP_REGEX_TABLE.forEach(([regex, type]) => { 214 | regex.lastIndex = 0; 215 | let match: RegExpExecArray | null; 216 | while ((match = regex.exec(content))) { 217 | const importPath = match[1] || match[2]; 218 | result.dependencies.push( 219 | createDependencyInfo(importPath, type, filePath, basePath), 220 | ); 221 | } 222 | }); 223 | 224 | result.isAngularComponent = 225 | REGEX_PATTERNS.ANGULAR_COMPONENT_DECORATOR.test(content); 226 | 227 | if (result.isAngularComponent) { 228 | const classMatch = content.match(/export\s+class\s+(\w+)/); 229 | if (classMatch) { 230 | result.componentMetadata = { 231 | className: classMatch[1], 232 | }; 233 | } 234 | } 235 | 236 | if (componentNamesForReverseDeps && componentNamesForReverseDeps.length > 0) { 237 | const combinedImportRegex = getCombinedComponentImportRegex( 238 | componentNamesForReverseDeps, 239 | ); 240 | const matches = Array.from(content.matchAll(combinedImportRegex)); 241 | result.importedComponentNames = matches 242 | .map((match) => match[1]) 243 | .filter(Boolean); 244 | } 245 | 246 | return result; 247 | } 248 | 249 | /** 250 | * Enhanced version of analyzeFileOptimized that uses unified AST analysis 251 | */ 252 | export async function analyzeFileWithUnifiedOptimization( 253 | filePath: string, 254 | basePath: string, 255 | ): Promise<FileInfo> { 256 | const stats = await fs.promises.stat(filePath); 257 | const ext = path.extname(filePath); 258 | 259 | const { stylesExtensions, scriptExtensions } = DEPENDENCY_ANALYSIS_CONFIG; 260 | 261 | let dependencies: DependencyInfo[] = []; 262 | let isAngularComponent = false; 263 | let componentName: string | undefined; 264 | 265 | if (scriptExtensions.includes(ext as any)) { 266 | const unifiedResult = await analyzeFileWithUnifiedAST(filePath, basePath); 267 | dependencies = unifiedResult.dependencies; 268 | isAngularComponent = unifiedResult.isAngularComponent; 269 | componentName = unifiedResult.componentMetadata?.className; 270 | } else if (stylesExtensions.includes(ext as any)) { 271 | dependencies = parseStyleDependencies( 272 | await fs.promises.readFile(filePath, 'utf-8'), 273 | filePath, 274 | basePath, 275 | ); 276 | } 277 | 278 | return { 279 | type: 280 | DEPENDENCY_ANALYSIS_CONFIG.fileTypeMap[ext as FileExtension] || 'unknown', 281 | size: stats.size, 282 | dependencies, 283 | lastModified: stats.mtime.getTime(), 284 | isAngularComponent, 285 | componentName, 286 | }; 287 | } 288 | 289 | export async function extractComponentImportsUnified( 290 | filePath: string, 291 | componentNames: string[], 292 | ): Promise<string[]> { 293 | if (componentNames.length === 0) { 294 | return []; 295 | } 296 | 297 | try { 298 | const content = await fs.promises.readFile(filePath, 'utf-8'); 299 | const result = analyzeContentWithUnifiedAST( 300 | content, 301 | filePath, 302 | '', 303 | componentNames, 304 | ); 305 | return Array.from(new Set(result.importedComponentNames)); 306 | } catch { 307 | return []; 308 | } 309 | } 310 | 311 | function createDependencyInfo( 312 | importPath: string, 313 | type: DependencyInfo['type'], 314 | filePath: string, 315 | basePath: string, 316 | ): DependencyInfo { 317 | if (isExternal(importPath)) { 318 | return { 319 | path: importPath, 320 | type: 'external', 321 | resolved: false, 322 | }; 323 | } 324 | 325 | const resolvedPath = resolveDependencyPath(importPath, filePath, basePath); 326 | 327 | return { 328 | path: importPath, 329 | type, 330 | resolved: resolvedPath !== null, 331 | resolvedPath: resolvedPath ? toUnixPath(resolvedPath) : undefined, 332 | }; 333 | } 334 | 335 | function parseStyleDependencies( 336 | content: string, 337 | filePath: string, 338 | basePath: string, 339 | ): DependencyInfo[] { 340 | const dependencies: DependencyInfo[] = []; 341 | 342 | STYLE_REGEX_TABLE.forEach(([regex, type, filter]) => { 343 | regex.lastIndex = 0; 344 | let match: RegExpExecArray | null; 345 | while ((match = regex.exec(content))) { 346 | const importPath = match[1] || match[2]; 347 | if (!filter || filter(importPath)) { 348 | dependencies.push( 349 | createDependencyInfo(importPath, type, filePath, basePath), 350 | ); 351 | } 352 | } 353 | }); 354 | 355 | return dependencies; 356 | } 357 | ``` -------------------------------------------------------------------------------- /packages/minimal-repo/packages/design-system/storybook-host-app/src/components/segmented-control/segmented-control.component.stories.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { generateStatusBadges } from '@design-system/shared-storybook-utils'; 2 | import { 3 | DsSegmentedControl, 4 | DsSegmentedOption, 5 | } from '@frontend/ui/segmented-control'; 6 | import { Meta, StoryObj, moduleMetadata } from '@storybook/angular'; 7 | import { expect, fireEvent, within } from '@storybook/test'; 8 | 9 | type StoryType = DsSegmentedControl & { 10 | roleType: string; 11 | itemMaxWidth: string; 12 | customLabel: string; 13 | twoLineTruncation: boolean; 14 | }; 15 | 16 | export default { 17 | title: 'Components/Segmented Control', 18 | parameters: { 19 | status: generateStatusBadges('UX-2309', ['a11y', 'integration ready']), 20 | }, 21 | component: DsSegmentedControl, 22 | args: { 23 | fullWidth: false, 24 | inverse: false, 25 | activeOption: '2', 26 | roleType: 'tablist', 27 | itemMaxWidth: 'auto', 28 | customLabel: 'customLabel', 29 | twoLineTruncation: false, 30 | }, 31 | argTypes: { 32 | fullWidth: { 33 | type: 'boolean', 34 | table: { 35 | defaultValue: { summary: 'false' }, 36 | }, 37 | control: { type: 'boolean' }, 38 | description: 39 | 'Whether the segment should take up the full width of the container', 40 | }, 41 | inverse: { 42 | type: 'boolean', 43 | table: { defaultValue: { summary: 'false' } }, 44 | control: { type: 'boolean' }, 45 | description: 'The inverse state of the Segmented Control', 46 | }, 47 | twoLineTruncation: { 48 | type: 'boolean', 49 | table: { defaultValue: { summary: 'false' } }, 50 | control: { type: 'boolean' }, 51 | description: 'Defining if two lines of text should be visible', 52 | }, 53 | activeOption: { 54 | type: 'string', 55 | control: 'text', 56 | description: 'The text/value of name to be active', 57 | }, 58 | roleType: { 59 | control: { type: 'select' }, 60 | table: { defaultValue: { summary: 'tablist' } }, 61 | options: ['radiogroup', 'tablist'], 62 | description: 'Determines the ARIA role applied to the segmented control', 63 | }, 64 | itemMaxWidth: { 65 | type: 'string', 66 | table: { defaultValue: { summary: 'auto' } }, 67 | control: 'text', 68 | description: 'Max width of the item', 69 | }, 70 | customLabel: { 71 | type: 'string', 72 | table: { defaultValue: { summary: 'customLabel' } }, 73 | control: 'text', 74 | description: 'Custom text you can use to check the behavior', 75 | }, 76 | }, 77 | 78 | decorators: [ 79 | moduleMetadata({ 80 | imports: [DsSegmentedControl, DsSegmentedOption], 81 | }), 82 | ], 83 | } as Meta<StoryType>; 84 | 85 | export const Default: StoryObj<StoryType> = { 86 | parameters: { 87 | name: 'Default', 88 | design: { 89 | name: 'Whitelabel', 90 | type: 'figma', 91 | url: 'https://www.figma.com/file/NgrOt8MGJhe0obKFBQgqdT/Component-Tokens-(POC)?type=design&node-id=12596-148225&mode=design&t=fS1qO73SS8lGciLj-4', 92 | }, 93 | }, 94 | render: (args) => ({ 95 | props: args, 96 | template: ` 97 | <div style='width: 650px; text-align: center; display: block;'> 98 | <ds-segmented-control [twoLineTruncation]="${args.twoLineTruncation}" [roleType]="'${args.roleType}'" [fullWidth]="${args.fullWidth}" [inverse]="${args.inverse}" [activeOption]="'${args.activeOption}'" style="--ds-segment-item-text-max-width: ${args.itemMaxWidth};"> 99 | <ds-segmented-option name='0' title="Label1 long text support, label long text support label long text support label long text support" /> 100 | <ds-segmented-option name="1" title="Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s" /> 101 | <ds-segmented-option name='2' title="Label3" /> 102 | <ds-segmented-option name="3" title="${args.customLabel}" /> 103 | </ds-segmented-control> 104 | </div> 105 | `, 106 | }), 107 | play: async ({ canvasElement, step }) => { 108 | await step('check Click event is being called', async () => { 109 | const canvas = within(canvasElement); 110 | const segments = canvas.getAllByRole('tab'); 111 | await fireEvent.click(segments[0]); 112 | await expect(segments[0]).toHaveClass('ds-segment-selected'); 113 | }); 114 | }, 115 | }; 116 | 117 | export const WithImage: StoryObj<StoryType> = { 118 | parameters: { 119 | name: 'Default', 120 | design: { 121 | name: 'Whitelabel', 122 | type: 'figma', 123 | url: 'https://www.figma.com/file/NgrOt8MGJhe0obKFBQgqdT/Component-Tokens-(POC)?type=design&node-id=12596-148323&mode=design&t=fS1qO73SS8lGciLj-4', 124 | }, 125 | }, 126 | argTypes: { 127 | customLabel: { 128 | table: { disable: true }, 129 | }, 130 | itemMaxWidth: { 131 | table: { disable: true }, 132 | }, 133 | twoLineTruncation: { 134 | table: { disable: true }, 135 | }, 136 | }, 137 | render: (args) => ({ 138 | props: args, 139 | template: ` 140 | <div style='width: 450px; text-align: center; display: block;' > 141 | <ds-segmented-control [roleType]="'${args.roleType}'" [fullWidth]="${args.fullWidth}" [inverse]="${args.inverse}" [activeOption]="'${args.activeOption}'"> 142 | <ds-segmented-option name="1" title="image1"> 143 | <ng-template #dsTemplate> 144 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 145 | <g clip-path="url(#clip0_12185_5035)"> 146 | <path d="M1.22907 11.123L0.397165 10.2663C0.140073 10.0015 0.140072 9.5722 0.397165 9.30742L9.2418 0.198579C9.49889 -0.0661931 9.91572 -0.0661931 10.1728 0.198579L10.927 0.975275L1.22907 11.123Z" fill="currentColor"/> 147 | <path fill-rule="evenodd" clip-rule="evenodd" d="M4.02262 14L1.68316 11.5907L11.3811 1.44293L15.3023 5.4813C15.5594 5.74607 15.5594 6.17535 15.3023 6.44013L7.96171 14H4.02262ZM10.6476 6.44004C10.3905 6.17527 10.3905 5.74599 10.6476 5.48122L11.5786 4.52239C11.8357 4.25762 12.2525 4.25762 12.5096 4.52239L13.4406 5.48122C13.6977 5.74599 13.6977 6.17527 13.4406 6.44004L12.5096 7.39887C12.2525 7.66364 11.8357 7.66364 11.5786 7.39887L10.6476 6.44004ZM3.66431 11.7136L7.38836 7.87826L7.85387 8.35767L4.12981 12.193L3.66431 11.7136Z" fill="currentColor"/> 148 | <path d="M9.76108 14.644H13.4364C13.6182 14.644 13.7655 14.7958 13.7655 14.983V15.661C13.7655 15.8483 13.6182 16 13.4364 16H2.24482C2.06303 16 1.91566 15.8483 1.91566 15.661V14.983C1.91566 14.8388 2.00309 14.7157 2.12634 14.6667H9.76108V14.644Z" fill="currentColor"/> 149 | </g> 150 | <defs> 151 | <clipPath id="clip0_12185_5035"> 152 | <rect width="16" height="16" fill="currentColor"/> 153 | </clipPath> 154 | </defs> 155 | </svg> 156 | </ng-template> 157 | </ds-segmented-option> 158 | 159 | <ds-segmented-option name="2" title="image2"> 160 | <ng-template #dsTemplate> 161 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 162 | <g clip-path="url(#clip0_12185_5035)"> 163 | <path d="M1.22907 11.123L0.397165 10.2663C0.140073 10.0015 0.140072 9.5722 0.397165 9.30742L9.2418 0.198579C9.49889 -0.0661931 9.91572 -0.0661931 10.1728 0.198579L10.927 0.975275L1.22907 11.123Z" fill="currentColor"/> 164 | <path fill-rule="evenodd" clip-rule="evenodd" d="M4.02262 14L1.68316 11.5907L11.3811 1.44293L15.3023 5.4813C15.5594 5.74607 15.5594 6.17535 15.3023 6.44013L7.96171 14H4.02262ZM10.6476 6.44004C10.3905 6.17527 10.3905 5.74599 10.6476 5.48122L11.5786 4.52239C11.8357 4.25762 12.2525 4.25762 12.5096 4.52239L13.4406 5.48122C13.6977 5.74599 13.6977 6.17527 13.4406 6.44004L12.5096 7.39887C12.2525 7.66364 11.8357 7.66364 11.5786 7.39887L10.6476 6.44004ZM3.66431 11.7136L7.38836 7.87826L7.85387 8.35767L4.12981 12.193L3.66431 11.7136Z" fill="currentColor"/> 165 | <path d="M9.76108 14.644H13.4364C13.6182 14.644 13.7655 14.7958 13.7655 14.983V15.661C13.7655 15.8483 13.6182 16 13.4364 16H2.24482C2.06303 16 1.91566 15.8483 1.91566 15.661V14.983C1.91566 14.8388 2.00309 14.7157 2.12634 14.6667H9.76108V14.644Z" fill="currentColor"/> 166 | </g> 167 | <defs> 168 | <clipPath id="clip0_12185_5035"> 169 | <rect width="16" height="16" fill="currentColor"/> 170 | </clipPath> 171 | </defs> 172 | </svg> 173 | </ng-template> 174 | </ds-segmented-option> 175 | 176 | <ds-segmented-option name="3" title="image3"> 177 | <ng-template #dsTemplate> 178 | <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 179 | <g clip-path="url(#clip0_12185_5035)"> 180 | <path d="M1.22907 11.123L0.397165 10.2663C0.140073 10.0015 0.140072 9.5722 0.397165 9.30742L9.2418 0.198579C9.49889 -0.0661931 9.91572 -0.0661931 10.1728 0.198579L10.927 0.975275L1.22907 11.123Z" fill="currentColor"/> 181 | <path fill-rule="evenodd" clip-rule="evenodd" d="M4.02262 14L1.68316 11.5907L11.3811 1.44293L15.3023 5.4813C15.5594 5.74607 15.5594 6.17535 15.3023 6.44013L7.96171 14H4.02262ZM10.6476 6.44004C10.3905 6.17527 10.3905 5.74599 10.6476 5.48122L11.5786 4.52239C11.8357 4.25762 12.2525 4.25762 12.5096 4.52239L13.4406 5.48122C13.6977 5.74599 13.6977 6.17527 13.4406 6.44004L12.5096 7.39887C12.2525 7.66364 11.8357 7.66364 11.5786 7.39887L10.6476 6.44004ZM3.66431 11.7136L7.38836 7.87826L7.85387 8.35767L4.12981 12.193L3.66431 11.7136Z" fill="currentColor"/> 182 | <path d="M9.76108 14.644H13.4364C13.6182 14.644 13.7655 14.7958 13.7655 14.983V15.661C13.7655 15.8483 13.6182 16 13.4364 16H2.24482C2.06303 16 1.91566 15.8483 1.91566 15.661V14.983C1.91566 14.8388 2.00309 14.7157 2.12634 14.6667H9.76108V14.644Z" fill="currentColor"/> 183 | </g> 184 | <defs> 185 | <clipPath id="clip0_12185_5035"> 186 | <rect width="16" height="16" fill="currentColor"/> 187 | </clipPath> 188 | </defs> 189 | </svg> 190 | </ng-template> 191 | </ds-segmented-option> 192 | </ds-segmented-control> 193 | </div> 194 | `, 195 | }), 196 | play: async ({ canvasElement, step }) => { 197 | await step('check Click event is being called', async () => { 198 | const canvas = within(canvasElement); 199 | const segments = canvas.getAllByRole('tab'); 200 | await fireEvent.click(segments[0]); 201 | await expect(segments[0]).toHaveClass('ds-segment-selected'); 202 | }); 203 | }, 204 | }; 205 | ```