├── .detective ├── config.json ├── hash └── log ├── .editorconfig ├── .eslintignore ├── .eslintrc.json ├── .github ├── ISSUE_TEMPLATE │ └── bug_report.md ├── pull_request_template.md └── workflows │ └── build.yaml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── apps ├── backend-e2e │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── src │ │ ├── backend │ │ │ └── backend.spec.ts │ │ └── support │ │ │ ├── global-setup.ts │ │ │ ├── global-teardown.ts │ │ │ └── test-setup.ts │ ├── tsconfig.json │ └── tsconfig.spec.json ├── backend │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── package.json │ ├── project.json │ ├── src │ │ ├── assets │ │ │ ├── .gitkeep │ │ │ └── index.html │ │ ├── bin │ │ │ └── main.js │ │ ├── express.ts │ │ ├── infrastructure │ │ │ ├── config.ts │ │ │ ├── deps.ts │ │ │ ├── git.ts │ │ │ ├── log.ts │ │ │ ├── paths.ts │ │ │ ├── tree-hash.ts │ │ │ └── version.ts │ │ ├── main.ts │ │ ├── model │ │ │ ├── config.ts │ │ │ ├── deps.ts │ │ │ └── limits.ts │ │ ├── options │ │ │ ├── options.ts │ │ │ ├── parse-options.ts │ │ │ └── validate-options.ts │ │ ├── services │ │ │ ├── change-coupling.spec.ts │ │ │ ├── change-coupling.ts │ │ │ ├── coupling.spec.ts │ │ │ ├── coupling.ts │ │ │ ├── folders.spec.ts │ │ │ ├── folders.ts │ │ │ ├── hotspot.spec.ts │ │ │ ├── hotspot.ts │ │ │ ├── log-cache.ts │ │ │ ├── module-info.ts │ │ │ ├── team-alignment.spec.ts │ │ │ └── team-alignment.ts │ │ └── utils │ │ │ ├── complexity.ts │ │ │ ├── count-lines.ts │ │ │ ├── date-utils.ts │ │ │ ├── git-parser.spec.ts │ │ │ ├── git-parser.ts │ │ │ ├── matrix.ts │ │ │ ├── normalize-folder.spec.ts │ │ │ ├── normalize-folder.ts │ │ │ ├── open.ts │ │ │ └── to-percent.ts │ ├── tsconfig.app.json │ ├── tsconfig.json │ ├── tsconfig.spec.json │ └── webpack.config.js └── frontend │ ├── .eslintrc.json │ ├── jest.config.ts │ ├── project.json │ ├── proxy.conf.json │ ├── public │ └── favicon.ico │ ├── src │ ├── app │ │ ├── app.component.css │ │ ├── app.component.html │ │ ├── app.component.ts │ │ ├── app.config.ts │ │ ├── app.routes.ts │ │ ├── data │ │ │ ├── cache.service.ts │ │ │ ├── config.service.ts │ │ │ ├── coupling.service.ts │ │ │ ├── folder.service.ts │ │ │ ├── hotspot.service.ts │ │ │ ├── limits.store.ts │ │ │ ├── module.service.ts │ │ │ ├── status.service.ts │ │ │ ├── status.store.ts │ │ │ └── team-alignment.service.ts │ │ ├── features │ │ │ ├── coupling │ │ │ │ ├── coupling.component.css │ │ │ │ ├── coupling.component.html │ │ │ │ ├── coupling.component.ts │ │ │ │ ├── coupling.store.ts │ │ │ │ └── graph.adapter.ts │ │ │ ├── hotspot │ │ │ │ ├── hotspot-adapter.ts │ │ │ │ ├── hotspot-detail │ │ │ │ │ ├── hotspot-detail.component.css │ │ │ │ │ ├── hotspot-detail.component.html │ │ │ │ │ └── hotspot-detail.component.ts │ │ │ │ ├── hotspot.component.css │ │ │ │ ├── hotspot.component.html │ │ │ │ ├── hotspot.component.ts │ │ │ │ └── hotspot.store.ts │ │ │ └── team-alignment │ │ │ │ ├── define-teams │ │ │ │ ├── define-teams.component.css │ │ │ │ ├── define-teams.component.html │ │ │ │ ├── define-teams.component.ts │ │ │ │ ├── define-teams.store.ts │ │ │ │ └── index.ts │ │ │ │ ├── team-alignment-chart-adapter.ts │ │ │ │ ├── team-alignment.component.css │ │ │ │ ├── team-alignment.component.html │ │ │ │ ├── team-alignment.component.ts │ │ │ │ └── team-alignment.store.ts │ │ ├── model │ │ │ ├── cache-status.ts │ │ │ ├── config.ts │ │ │ ├── coupling-result.ts │ │ │ ├── folder.ts │ │ │ ├── graph-type.ts │ │ │ ├── hotspot-result.ts │ │ │ ├── limits.ts │ │ │ ├── module-info.ts │ │ │ ├── status.ts │ │ │ └── team-alignment-result.ts │ │ ├── shell │ │ │ ├── about │ │ │ │ ├── about.component.css │ │ │ │ ├── about.component.html │ │ │ │ └── about.component.ts │ │ │ ├── cache.guard.ts │ │ │ ├── filter-tree │ │ │ │ ├── filter-tree.component.css │ │ │ │ ├── filter-tree.component.html │ │ │ │ └── filter-tree.component.ts │ │ │ └── nav │ │ │ │ ├── nav.component.css │ │ │ │ ├── nav.component.html │ │ │ │ └── nav.component.ts │ │ ├── types.d.ts │ │ ├── ui │ │ │ ├── doughnut │ │ │ │ ├── doughnut.component.css │ │ │ │ ├── doughnut.component.html │ │ │ │ └── doughnut.component.ts │ │ │ ├── graph │ │ │ │ ├── graph.component.css │ │ │ │ ├── graph.component.html │ │ │ │ ├── graph.component.ts │ │ │ │ └── graph.ts │ │ │ ├── limits │ │ │ │ ├── limits.component.css │ │ │ │ ├── limits.component.html │ │ │ │ └── limits.component.ts │ │ │ ├── loading │ │ │ │ ├── loading.component.css │ │ │ │ ├── loading.component.html │ │ │ │ └── loading.component.ts │ │ │ ├── resizer │ │ │ │ ├── resizer.component.css │ │ │ │ ├── resizer.component.html │ │ │ │ ├── resizer.component.spec.ts │ │ │ │ └── resizer.component.ts │ │ │ └── treemap │ │ │ │ ├── treemap.component.css │ │ │ │ ├── treemap.component.html │ │ │ │ └── treemap.component.ts │ │ └── utils │ │ │ ├── debounce.ts │ │ │ ├── error-handler.ts │ │ │ ├── event.service.ts │ │ │ ├── segments.ts │ │ │ └── signal-helpers.ts │ ├── index.html │ ├── main.ts │ ├── styles.scss │ └── test-setup.ts │ ├── tsconfig.app.json │ ├── tsconfig.editor.json │ ├── tsconfig.json │ └── tsconfig.spec.json ├── build.sh ├── commitlint.config.js ├── docs ├── change-coupling.png ├── context-menu.png ├── domains-detail.png ├── hotspot-details.png ├── hotspots-treemap.png ├── hotspots.png └── team-alignment.png ├── jest.config.ts ├── jest.preset.js ├── nx.json ├── package-lock.json ├── package.json ├── publish-local.sh └── tsconfig.base.json /.detective/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "scopes": [ 3 | "apps/backend/src/model", 4 | "apps/backend/src/options", 5 | "apps/backend/src/services", 6 | "apps/backend/src/utils", 7 | "apps/backend/src/infrastructure", 8 | "apps/frontend/src/app/features/coupling", 9 | "apps/frontend/src/app/features/hotspot", 10 | "apps/frontend/src/app/features/team-alignment", 11 | "apps/frontend/src/app/shell/about", 12 | "apps/frontend/src/app/shell/filter-tree", 13 | "apps/frontend/src/app/shell/nav", 14 | "apps/frontend/src/app/model", 15 | "apps/frontend/src/app/ui/doughnut", 16 | "apps/frontend/src/app/ui/graph", 17 | "apps/frontend/src/app/ui/limits", 18 | "apps/frontend/src/app/ui/loading", 19 | "apps/frontend/src/app/ui/resizer", 20 | "apps/frontend/src/app/ui/treemap" 21 | ], 22 | "groups": [ 23 | "apps/backend/src", 24 | "apps/backend", 25 | "apps/frontend/src/app/features", 26 | "apps/frontend/src/app/shell", 27 | "apps/frontend/src/app/ui", 28 | "apps/frontend/src/app", 29 | "apps/frontend/src", 30 | "apps/frontend", 31 | "apps" 32 | ], 33 | "entries": [], 34 | "filter": { 35 | "files": [], 36 | "logs": [] 37 | }, 38 | "teams": { 39 | "example-team-a": ["John Doe", "Jane Doe"], 40 | "example-team-b": ["Max Muster", "Susi Sorglos"] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /.detective/hash: -------------------------------------------------------------------------------- 1 | 503c63bcf7fab325fc9687a40ad048b2b16ca636, v1.1.6 -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | max_line_length = off 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "ignorePatterns": ["**/*"], 4 | "plugins": ["import", "@nx"], 5 | "overrides": [ 6 | { 7 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 8 | "rules": { 9 | "@nx/enforce-module-boundaries": [ 10 | "error", 11 | { 12 | "enforceBuildableLibDependency": true, 13 | "allow": [], 14 | "depConstraints": [ 15 | { 16 | "sourceTag": "*", 17 | "onlyDependOnLibsWithTags": ["*"] 18 | } 19 | ] 20 | } 21 | ], 22 | "@typescript-eslint/no-unused-vars": [ 23 | "error", 24 | { 25 | "argsIgnorePattern": "^_" 26 | } 27 | ], 28 | "import/order": [ 29 | "error", 30 | { 31 | "groups": [ 32 | "builtin", 33 | "external", 34 | "internal", 35 | "parent", 36 | "sibling", 37 | "index" 38 | ], 39 | "newlines-between": "always", 40 | "alphabetize": { 41 | "order": "asc", 42 | "caseInsensitive": true 43 | } 44 | } 45 | ] 46 | } 47 | }, 48 | { 49 | "files": ["*.ts", "*.tsx"], 50 | "extends": ["plugin:@nx/typescript"], 51 | "rules": {} 52 | }, 53 | { 54 | "files": ["*.js", "*.jsx"], 55 | "extends": ["plugin:@nx/javascript"], 56 | "rules": {} 57 | }, 58 | { 59 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 60 | "env": { 61 | "jest": true 62 | }, 63 | "rules": {} 64 | } 65 | ] 66 | } 67 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Issue 3 | about: Report an issue 4 | title: '' 5 | labels: 6 | assignees: 7 | --- 8 | 9 | # Issue 10 | 11 | Please describe the steps necessary to reproduce the error. 12 | 13 | If it is an error with parsing your git log, please also add a link to your gitlog. You can find it in 14 | 15 | ``` 16 | .detective/log 17 | ``` 18 | 19 | To prevent disclosing sensitive information, you can obfuscate it. For instance, this commend replaces each letter with an `x`: 20 | 21 | ```bash 22 | necessarysed 's/[a-zA-Z]/x/g' .detective/log > masked-log.txt 23 | ``` 24 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ## Checklist for PR 2 | 3 | - [ ] PR is only about one and only one concern 4 | - [ ] Description contains a short title, an optional description, and the sections BEFORE and AFTER 5 | - [ ] (A) new short test(s) show(s) that the PR is working 6 | 7 | ## Some more infos 8 | 9 | - Short PR: adding two features and formatting code in other features is too much 10 | 11 | - BEFARE and AFTER sections: 12 | 13 | ``` 14 | BEFORE: 15 | feature did this. 16 | 17 | AFTER: 18 | now, the features does that. 19 | ``` 20 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Lint, Test, and Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build_job: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Set up Node.js to LTS version 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: 'lts/*' 23 | 24 | - name: Install dependencies 25 | run: npm install 26 | 27 | - name: Run Linter 28 | run: npm run lint 29 | 30 | - name: Run Tests 31 | run: npm test 32 | 33 | - name: Build 34 | run: npm run build 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | dist 5 | tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db 40 | 41 | .nx/cache 42 | .nx/workspace-data 43 | 44 | .angular 45 | apps/backend/package-lock.json 46 | 47 | .detective -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx --no-install commitlint --edit "$1" 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | nx format:write --all 3 | git add . 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | /dist 3 | /coverage 4 | /.nx/cache 5 | /.nx/workspace-data 6 | .angular 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.preferences.quoteStyle": "single", 3 | "javascript.preferences.quoteStyle": "single" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Softarc Consulting GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Detective 2 | 3 | Visualize and Analyze your TypeScript-based Architecture! 4 | 5 | Detective leverages forensic code analysis at the architectural level to uncover hidden patterns in your codebase. 6 | 7 | ## Features 8 | 9 | ### Visualize Your Project Structure 10 | 11 | Gain an overview of your modules, domains, and layers: 12 | 13 | ![Visualize your project structure](./docs/domains-detail.png) 14 | 15 | ### Analyze Change Coupling 16 | 17 | The Change Coupling analysis reveals which modules have often been changed together, indicating a non-obvious type of coupling. 18 | 19 | ![Change Coupling](./docs/change-coupling.png) 20 | 21 | ### Analyze Team/Code Alignment 22 | 23 | The Team Alignment Analysis shows whether your team structure and module/domain boundaries are aligned: 24 | 25 | ![Team Alignment](./docs/team-alignment.png) 26 | 27 | ### Analyze Hotspots 28 | 29 | A Hotspot is a complex file with that was previously changed quite often and hence comes with a higher risk for bugs. 30 | 31 | ![Hotspots](./docs/hotspots-treemap.png) 32 | 33 | For each region, Detective calculates a hotspot score which is the product of the amount of changes and the complexity. You can see it as an sort index. Please keep in mind that this score is local to your analysis and hence cannot be compared with scores resulting from other analyses. 34 | 35 | The slider on the top left defines when a region is identified as a hotspot. For instance, 33% defines that each region having 33% or more of the maximum hotspot score is a hotspot. For a better overview, these hotspots are seperated into two equal areas: the lower half is displayed yellow and the upper half is red. 36 | 37 | When clicking on an region, the files in this region are displayed: 38 | 39 | ![Details of a Hotspot](./docs/hotspot-details.png) 40 | 41 | ## Tree 42 | 43 | The tree can be resized horizontally. Also, each entry has a context menu that allows to focus the current entry (making the current entry the root entry) and selecting all children: 44 | 45 | 46 | 47 | ## Using 48 | 49 | You can try it out quickly by running Detective in your project's **root** directory: 50 | 51 | ```shell 52 | npm i @softarc/detective -D 53 | npx detective 54 | ``` 55 | 56 | ## Defining aliases 57 | 58 | In case users have used multiple names, as appearing in the `git log`, use the `aliases` option in the file `.detective/config.json` created the first time detective runs: 59 | 60 | ```json 61 | { 62 | [...] 63 | "aliases": { 64 | "jdoe": "John Doe", 65 | "janedoe": "Jane Doe" 66 | } 67 | [...] 68 | } 69 | ``` 70 | 71 | ## Defining Teams 72 | 73 | For the Team Alignment Analysis, you need to map team names to the names of your team members as found in `git log`. This is done in the file `.detective/config.json` created the first time detective runs: 74 | 75 | ```json5 76 | { 77 | [...] 78 | "teams": { 79 | "alpha": ["John Doe", "Jane Doe"], 80 | "beta": ["Max Muster", "Susi Sorglos"] 81 | } 82 | [...] 83 | } 84 | ``` 85 | 86 | ## Defining Entrypoints 87 | 88 | Detective probes a set of default entry points by looking at files with the names `index.ts` and `main.ts` in several directories. If your project structure is different, you can add this entry with respective globs to your `.detectice/config.json`: 89 | 90 | ```json5 91 | { 92 | [...] 93 | "entries": [ 94 | "packages/*/index.ts" 95 | ], 96 | [...] 97 | } 98 | ``` 99 | 100 | ## Filtering the Git Log 101 | 102 | By default, Detective uses all the entries in the git log and analyzes all `.ts` files. You can change this by filtering log entries out that contain a given string and by defining globs pointing to the files you want to analyze. 103 | 104 | In the following example, commit messages containing the substring `prettier formatting` will be skipped and also files ending with `*.spec.ts` are not looked at. 105 | 106 | ```json5 107 | { 108 | [...] 109 | "filter": { 110 | "logs": [ 111 | "prettier formatting" 112 | ], 113 | "files": [ 114 | "**/*.ts", 115 | "!**/*.spec.ts" 116 | ] 117 | }, 118 | [...] 119 | } 120 | ``` 121 | 122 | Please note that excluding tests can help to regarding to your goals. However, there are situations where you also want to analze the coupling between tests and the tested code. 123 | 124 | To keep the git log cache small, only the first line of git commit messages, the user name and their email address, the commit hash and the date is respected by the filter. 125 | 126 | ## Nx Support 127 | 128 | Detective works with all TypeScript projects. If it's executed within an [Nx](https://nx.dev/) project, it will use typical Nx patterns to retrieve the entry points into your apps and libs. 129 | 130 | ## Credits 131 | 132 | Detective stand on the shoulders of giants: 133 | 134 | - Inspired by the [Nx Dependency Graph](https://nx.dev/). 135 | - Inspired by [Adam Tornhill's](https://x.com/AdamTornhill) book [Your Code as a Crime Scene, Second Edition](https://pragprog.com/titles/atcrime2/your-code-as-a-crime-scene-second-edition/) 136 | - Powered by [Rainer Hahnekamp's](https://x.com/rainerhahnekamp) awesome and high-quality work on our open source project [Sheriff](https://softarc-consulting.github.io/sheriff/) 137 | 138 |

More on Architecture

139 |
140 |

Free eBook: Enterprise Angular

141 |

142 | 145 | 150 | 151 |

152 |

153 | Download Here! 157 |

158 |
159 |
160 |

Angular Architecture Workshop

161 |

162 | 165 | 170 | 171 |

172 |

173 | All Details & Tickets! 177 |

178 |
179 | 180 | ## More 181 | 182 | If you like the idea of forensic code analysis, you'll love [Adam Tornhill's](https://x.com/AdamTornhill) product [Code Scene](https://codescene.com/) that goes far beyond the scope of Detective. 183 | -------------------------------------------------------------------------------- /apps/backend-e2e/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend-e2e/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'backend-e2e', 4 | preset: '../../jest.preset.js', 5 | globalSetup: '/src/support/global-setup.ts', 6 | globalTeardown: '/src/support/global-teardown.ts', 7 | setupFiles: ['/src/support/test-setup.ts'], 8 | testEnvironment: 'node', 9 | transform: { 10 | '^.+\\.[tj]s$': [ 11 | 'ts-jest', 12 | { 13 | tsconfig: '/tsconfig.spec.json', 14 | }, 15 | ], 16 | }, 17 | moduleFileExtensions: ['ts', 'js', 'html'], 18 | coverageDirectory: '../../coverage/backend-e2e', 19 | }; 20 | -------------------------------------------------------------------------------- /apps/backend-e2e/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend-e2e", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "implicitDependencies": ["backend"], 6 | "targets": { 7 | "e2e": { 8 | "executor": "@nx/jest:jest", 9 | "outputs": ["{workspaceRoot}/coverage/{e2eProjectRoot}"], 10 | "options": { 11 | "jestConfig": "apps/backend-e2e/jest.config.ts", 12 | "passWithNoTests": true 13 | }, 14 | "dependsOn": ["backend:build"] 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /apps/backend-e2e/src/backend/backend.spec.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | describe('GET /', () => { 4 | it('should return a message', async () => { 5 | const res = await axios.get(`/`); 6 | 7 | expect(res.status).toBe(200); 8 | expect(res.data).toEqual({ message: 'Hello API' }); 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /apps/backend-e2e/src/support/global-setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | var __TEARDOWN_MESSAGE__: string; 3 | 4 | module.exports = async function () { 5 | // Start services that that the app needs to run (e.g. database, docker-compose, etc.). 6 | console.log('\nSetting up...\n'); 7 | 8 | // Hint: Use `globalThis` to pass variables to global teardown. 9 | globalThis.__TEARDOWN_MESSAGE__ = '\nTearing down...\n'; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/backend-e2e/src/support/global-teardown.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | module.exports = async function () { 4 | // Put clean up logic here (e.g. stopping services, docker-compose, etc.). 5 | // Hint: `globalThis` is shared between setup and teardown. 6 | console.log(globalThis.__TEARDOWN_MESSAGE__); 7 | }; 8 | -------------------------------------------------------------------------------- /apps/backend-e2e/src/support/test-setup.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | import axios from 'axios'; 4 | 5 | module.exports = async function () { 6 | // Configure axios for tests to use. 7 | const host = process.env.HOST ?? 'localhost'; 8 | const port = process.env.PORT ?? '3000'; 9 | axios.defaults.baseURL = `http://${host}:${port}`; 10 | }; 11 | -------------------------------------------------------------------------------- /apps/backend-e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.spec.json" 8 | } 9 | ], 10 | "compilerOptions": { 11 | "esModuleInterop": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend-e2e/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": ["jest.config.ts", "src/**/*.ts"] 9 | } 10 | -------------------------------------------------------------------------------- /apps/backend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": {} 8 | }, 9 | { 10 | "files": ["*.ts", "*.tsx"], 11 | "rules": {} 12 | }, 13 | { 14 | "files": ["*.js", "*.jsx"], 15 | "rules": {} 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'backend', 4 | preset: '../../jest.preset.js', 5 | testEnvironment: 'node', 6 | transform: { 7 | '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], 8 | }, 9 | moduleFileExtensions: ['ts', 'js', 'html'], 10 | coverageDirectory: '../../coverage/apps/backend', 11 | }; 12 | -------------------------------------------------------------------------------- /apps/backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@softarc/detective", 3 | "version": "1.3.0", 4 | "bin": { 5 | "detective": "./bin/main.js", 6 | "@softarc/detective": "./bin/main.js" 7 | }, 8 | "dependencies": { 9 | "tslib": "^2.0.0" 10 | }, 11 | "author": "Manfred Steyer", 12 | "license": "MIT", 13 | "description": "Visualize your TypeScript project", 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/angular-architects/detective" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "sourceRoot": "apps/backend/src", 5 | "projectType": "application", 6 | "tags": [], 7 | "targets": { 8 | "serve": { 9 | "executor": "@nx/js:node", 10 | "defaultConfiguration": "development", 11 | "dependsOn": ["build"], 12 | "options": { 13 | "buildTarget": "backend:build", 14 | "runBuildTargetDependencies": false, 15 | "args": [ 16 | "--path", 17 | "/Users/manfredsteyer/projects/public/standalone-example-cli", 18 | "--open", 19 | "false" 20 | ] 21 | }, 22 | "configurations": { 23 | "development": { 24 | "buildTarget": "backend:build:development" 25 | }, 26 | "production": { 27 | "buildTarget": "backend:build:production" 28 | } 29 | } 30 | }, 31 | "lint": { 32 | "executor": "@nx/linter:eslint", 33 | "options": { 34 | "lintFilePatterns": ["apps/backend/**/*.ts"] 35 | } 36 | }, 37 | "test": { 38 | "executor": "@nx/jest:jest", 39 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 40 | "options": { 41 | "jestConfig": "apps/backend/jest.config.ts" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/backend/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/apps/backend/src/assets/.gitkeep -------------------------------------------------------------------------------- /apps/backend/src/assets/index.html: -------------------------------------------------------------------------------- 1 | Hallo Welt! 2 | -------------------------------------------------------------------------------- /apps/backend/src/bin/main.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../main.js'); 3 | -------------------------------------------------------------------------------- /apps/backend/src/express.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { cwd } from 'process'; 4 | 5 | import express from 'express'; 6 | 7 | import { getCommitCount } from './infrastructure/git'; 8 | import { Limits } from './model/limits'; 9 | import { Options } from './options/options'; 10 | import { calcChangeCoupling } from './services/change-coupling'; 11 | import { calcCoupling } from './services/coupling'; 12 | import { inferFolders } from './services/folders'; 13 | import { 14 | aggregateHotspots, 15 | ComplexityMetric, 16 | findHotspotFiles, 17 | HotspotCriteria, 18 | } from './services/hotspot'; 19 | import { isStale, updateLogCache } from './services/log-cache'; 20 | import { calcModuleInfo } from './services/module-info'; 21 | import { calcTeamAlignment } from './services/team-alignment'; 22 | 23 | export function setupExpress(options: Options) { 24 | const app = express(); 25 | 26 | app.use(express.json()); 27 | 28 | app.get('/api/config', (req, res) => { 29 | res.sendFile(path.join(cwd(), options.config)); 30 | }); 31 | 32 | app.post('/api/config', (req, res) => { 33 | const newConfig = req.body; 34 | const configPath = path.join(cwd(), options.config); 35 | 36 | fs.writeFile( 37 | configPath, 38 | JSON.stringify(newConfig, null, 2), 39 | 'utf8', 40 | (error) => { 41 | if (error) { 42 | return res.status(500).json({ error }); 43 | } 44 | res.json({}); 45 | } 46 | ); 47 | }); 48 | 49 | app.get('/api/status', (req, res) => { 50 | try { 51 | const commits = getCommitCount(); 52 | res.json({ commits }); 53 | } catch (e: unknown) { 54 | handleError(e, res); 55 | } 56 | }); 57 | 58 | app.get('/api/cache/log', (req, res) => { 59 | try { 60 | const stale = isStale(); 61 | res.json({ isStale: stale }); 62 | } catch (e: unknown) { 63 | handleError(e, res); 64 | } 65 | }); 66 | 67 | app.all('/api/cache/log/update', async (req, res) => { 68 | try { 69 | console.log('Updating cache ...'); 70 | await updateLogCache(); 71 | console.log('Done.'); 72 | res.json({}); 73 | } catch (e: unknown) { 74 | handleError(e, res); 75 | } 76 | }); 77 | 78 | app.get('/api/modules', (req, res) => { 79 | try { 80 | const result = calcModuleInfo(options); 81 | res.json(result); 82 | } catch (e: unknown) { 83 | handleError(e, res); 84 | } 85 | }); 86 | 87 | app.get('/api/folders', (req, res) => { 88 | try { 89 | const result = inferFolders(options); 90 | res.json(result); 91 | } catch (e: unknown) { 92 | handleError(e, res); 93 | } 94 | }); 95 | 96 | app.get('/api/coupling', (req, res) => { 97 | try { 98 | const result = calcCoupling(options); 99 | res.json(result); 100 | } catch (e: unknown) { 101 | handleError(e, res); 102 | } 103 | }); 104 | 105 | app.get('/api/change-coupling', async (req, res) => { 106 | const limits = getLimits(req); 107 | 108 | try { 109 | const result = await calcChangeCoupling(limits, options); 110 | res.json(result); 111 | } catch (e: unknown) { 112 | handleError(e, res); 113 | } 114 | }); 115 | 116 | app.get('/api/team-alignment', async (req, res) => { 117 | const byUser = req.query.byUser === 'true'; 118 | const limits = getLimits(req); 119 | 120 | try { 121 | const result = await calcTeamAlignment(byUser, limits, options); 122 | res.json(result); 123 | } catch (e: unknown) { 124 | handleError(e, res); 125 | } 126 | }); 127 | 128 | app.get('/api/hotspots/aggregated', async (req, res) => { 129 | const minScore = Number(req.query.minScore) || -1; 130 | const metric = (req.query.metric?.toString() || 131 | 'McCabe') as ComplexityMetric; 132 | const criteria: HotspotCriteria = { minScore, module: '', metric }; 133 | const limits = getLimits(req); 134 | 135 | try { 136 | const result = await aggregateHotspots(criteria, limits, options); 137 | res.json(result); 138 | } catch (e: unknown) { 139 | handleError(e, res); 140 | } 141 | }); 142 | 143 | app.get('/api/hotspots', async (req, res) => { 144 | const minScore = Number(req.query.minScore) || -1; 145 | const module = req.query.module ? String(req.query.module) : ''; 146 | const metric = (req.query.metric?.toString() || 147 | 'McCabe') as ComplexityMetric; 148 | const criteria = { minScore, module, metric }; 149 | 150 | const limits = getLimits(req); 151 | 152 | try { 153 | const result = await findHotspotFiles(criteria, limits, options); 154 | res.json(result); 155 | } catch (e: unknown) { 156 | handleError(e, res); 157 | } 158 | }); 159 | 160 | app.use(express.static(path.join(__dirname, 'assets'))); 161 | 162 | app.get('*', (req, res) => { 163 | res.sendFile(path.join(__dirname, 'assets', 'index.html')); 164 | }); 165 | return app; 166 | } 167 | 168 | function handleError(e: unknown, res) { 169 | console.log('error', e); 170 | const message = typeof e === 'object' && 'message' in e ? e.message : '' + e; 171 | res.status(500).json({ message }); 172 | } 173 | 174 | function getLimits(req: express.Request): Limits { 175 | return { 176 | limitCommits: parseInt('' + req.query.limitCommits) || null, 177 | limitMonths: parseInt('' + req.query.limitMonths) || null, 178 | }; 179 | } 180 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/config.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { cwd } from 'process'; 4 | 5 | import { Config } from '../model/config'; 6 | import { Options } from '../options/options'; 7 | 8 | import { DETECTIVE_DIR } from './paths'; 9 | 10 | const initConfig: Config = { 11 | scopes: [], 12 | groups: [], 13 | entries: [], 14 | filter: { 15 | files: [], 16 | logs: [], 17 | }, 18 | aliases: {}, 19 | teams: { 20 | 'example-team-a': ['John Doe', 'Jane Doe'], 21 | 'example-team-b': ['Max Muster', 'Susi Sorglos'], 22 | }, 23 | }; 24 | 25 | export function loadConfig(options: Options): Config { 26 | const configPath = path.join(cwd(), options.config); 27 | const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Config; 28 | config.scopes.sort(); 29 | return config; 30 | } 31 | 32 | export function ensureConfig(options: Options): void { 33 | const configPath = path.join(cwd(), options.config); 34 | ensureDetectiveDir(); 35 | if (!fs.existsSync(configPath)) { 36 | fs.writeFileSync(configPath, JSON.stringify(initConfig, null, 2), 'utf-8'); 37 | } 38 | } 39 | 40 | function ensureDetectiveDir() { 41 | if (!fs.existsSync(DETECTIVE_DIR)) { 42 | fs.mkdirSync(DETECTIVE_DIR); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/deps.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { cwd } from 'process'; 3 | 4 | import { getProjectData } from '@softarc/sheriff-core'; 5 | import { globSync } from 'fast-glob'; 6 | 7 | import { Deps } from '../model/deps'; 8 | import { Options } from '../options/options'; 9 | 10 | import { loadConfig } from './config'; 11 | 12 | const DEFAULT_ENTRIES = [ 13 | 'src/main.ts', 14 | 'main.ts', 15 | 'src/index.ts', 16 | 'index.ts', 17 | 'projects/*/src/main.ts', 18 | 'projects/*/src/index.ts', 19 | 'packages/*/src/main.ts', 20 | 'packages/*/src/index.ts', 21 | ]; 22 | 23 | const DEFAULT_NX_ENTRIES = [ 24 | 'apps/**/src/main.ts', 25 | 'libs/**/src/index.ts', 26 | 'packages/**/src/main.ts', 27 | ]; 28 | 29 | let deps: Deps; 30 | 31 | export function loadDeps(_options: Options): Deps { 32 | if (!deps) { 33 | throw new Error('no dependencies loaded!'); 34 | } 35 | return deps; 36 | } 37 | 38 | export function inferDeps(options: Options): boolean { 39 | const entryGlobs = getEntryGlobs(options); 40 | const entries = globSync(entryGlobs); 41 | 42 | if (entries.length === 0) { 43 | return false; 44 | } 45 | 46 | const dir = cwd(); 47 | 48 | const sheriffDump = entries 49 | .map((e) => getProjectData(e, dir)) 50 | .reduce((acc, curr) => ({ ...acc, ...curr })); 51 | 52 | deps = normalizeObject(sheriffDump); 53 | 54 | return true; 55 | } 56 | 57 | export function getEntryGlobs(options: Options) { 58 | const config = loadConfig(options); 59 | 60 | let entryGlobs = DEFAULT_ENTRIES; 61 | if (config.entries?.length > 0) { 62 | entryGlobs = config.entries; 63 | } else if (fs.existsSync('nx.json')) { 64 | entryGlobs = DEFAULT_NX_ENTRIES; 65 | } 66 | return entryGlobs; 67 | } 68 | 69 | function normalizeObject(obj: T): T { 70 | const normalized = JSON.stringify(obj).replace(/\\\\/g, '/'); 71 | return JSON.parse(normalized); 72 | } 73 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/git.ts: -------------------------------------------------------------------------------- 1 | import { spawn, spawnSync } from 'child_process'; 2 | import * as fs from 'fs'; 3 | 4 | import { noLimits } from '../model/limits'; 5 | 6 | export function isRepo(): boolean { 7 | return fs.existsSync('.git'); 8 | } 9 | 10 | export function calcTreeHash(): string { 11 | const result = spawnSync('git', ['rev-parse', 'HEAD^{tree}'], { 12 | encoding: 'utf-8', 13 | }); 14 | 15 | if (result.error) { 16 | throw new Error('Creating Git Tree Hash failed: ' + result.error.message); 17 | } 18 | 19 | if (result.status !== 0) { 20 | throw new Error( 21 | 'Creating Git Tree Hash failed with exit code: ' + 22 | result.status + 23 | '\n' + 24 | result.stderr 25 | ); 26 | } 27 | 28 | return result.stdout.trim(); 29 | } 30 | 31 | export function getCommitCount(): string { 32 | const result = spawnSync('git', ['rev-list', '--count', 'HEAD'], { 33 | encoding: 'utf-8', 34 | }); 35 | 36 | if (result.error) { 37 | throw new Error('Creating Git Tree Hash failed: ' + result.error.message); 38 | } 39 | 40 | if (result.status !== 0) { 41 | throw new Error( 42 | 'Creating Git Tree Hash failed with exit code: ' + 43 | result.status + 44 | '\n' + 45 | result.stderr 46 | ); 47 | } 48 | 49 | return result.stdout.trim(); 50 | } 51 | 52 | export function getGitLog(limits = noLimits): Promise { 53 | return new Promise((resolve, reject) => { 54 | try { 55 | const args = [ 56 | 'log', 57 | '--numstat', 58 | '--pretty=format:"%an <%ae>,%ad%x09%H,%s"', 59 | ]; 60 | 61 | if (limits.limitCommits) { 62 | args.push('-n ' + limits.limitCommits); 63 | } 64 | 65 | if (limits.limitMonths) { 66 | args.push(`--since="${limits.limitMonths} months ago"`); 67 | } 68 | 69 | const subprocess = spawn('git', args); 70 | 71 | let text = ''; 72 | let error = ''; 73 | 74 | subprocess.stdout.on('data', (data: string) => { 75 | text += data; 76 | }); 77 | 78 | subprocess.stderr.on('data', (data: string) => { 79 | error += data; 80 | }); 81 | 82 | subprocess.on('exit', (code: string) => { 83 | if (code || error) { 84 | reject(new Error('[Error running Git] ' + error)); 85 | } else { 86 | resolve(text); 87 | } 88 | }); 89 | } catch (e) { 90 | reject(new Error('Error running git: ' + e)); 91 | } 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/log.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | import { DETECTIVE_DIR, LOG_FILE } from './paths'; 5 | 6 | const logFile = path.join(DETECTIVE_DIR, LOG_FILE); 7 | 8 | export function loadCachedLog(): string { 9 | if (!fs.existsSync(logFile)) { 10 | return ''; 11 | } 12 | 13 | return fs.readFileSync(logFile, 'utf-8'); 14 | } 15 | 16 | export function saveCachedLog(log: string): void { 17 | fs.writeFileSync(logFile, log, 'utf-8'); 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/paths.ts: -------------------------------------------------------------------------------- 1 | export const DETECTIVE_DIR = '.detective'; 2 | export const HASH_FILE = 'hash'; 3 | export const LOG_FILE = 'log'; 4 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/tree-hash.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | import * as path from 'path'; 3 | 4 | import { DETECTIVE_DIR, HASH_FILE } from './paths'; 5 | 6 | const hashFile = path.join(DETECTIVE_DIR, HASH_FILE); 7 | 8 | export function loadTreeHash(): string | null { 9 | if (!fs.existsSync(hashFile)) { 10 | return null; 11 | } 12 | 13 | return fs.readFileSync(hashFile, 'utf-8'); 14 | } 15 | 16 | export function saveTreeHash(hash: string): void { 17 | fs.writeFileSync(hashFile, hash, 'utf-8'); 18 | } 19 | -------------------------------------------------------------------------------- /apps/backend/src/infrastructure/version.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | export const DETECTIVE_VERSION = require('../../package.json').version; 3 | -------------------------------------------------------------------------------- /apps/backend/src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { setupExpress } from './express'; 4 | import { ensureConfig } from './infrastructure/config'; 5 | import { getEntryGlobs, inferDeps } from './infrastructure/deps'; 6 | import { isRepo } from './infrastructure/git'; 7 | import { DETECTIVE_VERSION } from './infrastructure/version'; 8 | import { parseOptions } from './options/parse-options'; 9 | import { openSync } from './utils/open'; 10 | 11 | const options = parseOptions(process.argv.slice(2)); 12 | 13 | if (options.path) { 14 | process.chdir(options.path); 15 | } 16 | 17 | ensureConfig(options); 18 | 19 | if (!inferDeps(options)) { 20 | console.error( 21 | 'No entry points found. Tried:', 22 | getEntryGlobs(options).join(', ') 23 | ); 24 | console.error( 25 | '\nPlease configured your entry points in .detective/config.json' 26 | ); 27 | process.exit(1); 28 | } 29 | 30 | if (!isRepo()) { 31 | console.warn('This does not seem to be a git repository.'); 32 | console.warn('Most diagrams provided by detective do not work without git!'); 33 | } 34 | 35 | const app = setupExpress(options); 36 | 37 | app.listen(options.port, () => { 38 | const url = `http://localhost:${options.port}`; 39 | console.log(`Detective v${DETECTIVE_VERSION} runs at ${url}`); 40 | if (options.open) { 41 | openSync(url); 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /apps/backend/src/model/config.ts: -------------------------------------------------------------------------------- 1 | export type Filter = { 2 | logs?: string[]; 3 | files?: string[]; 4 | }; 5 | 6 | export type Config = { 7 | scopes: string[]; 8 | groups: string[]; 9 | filter: Filter; 10 | teams: Record; 11 | aliases: Record; 12 | entries: []; 13 | }; 14 | 15 | export const emptyConfig: Config = { 16 | scopes: [], 17 | groups: [], 18 | teams: {}, 19 | aliases: {}, 20 | entries: [], 21 | filter: { 22 | logs: [], 23 | files: [], 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /apps/backend/src/model/deps.ts: -------------------------------------------------------------------------------- 1 | export type Deps = { 2 | [files: string]: { 3 | module: string; 4 | tags: string[]; 5 | imports: string[]; 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /apps/backend/src/model/limits.ts: -------------------------------------------------------------------------------- 1 | export type Limits = { 2 | limitCommits: number | null; 3 | limitMonths: number | null; 4 | }; 5 | 6 | export const noLimits: Limits = { 7 | limitCommits: 0, 8 | limitMonths: 0, 9 | }; 10 | -------------------------------------------------------------------------------- /apps/backend/src/options/options.ts: -------------------------------------------------------------------------------- 1 | export type Options = { 2 | config: string; 3 | sheriffDump: string; 4 | path: string; 5 | port: number; 6 | demoMode: boolean; 7 | open: boolean; 8 | }; 9 | 10 | export const defaultOptions: Options = { 11 | sheriffDump: '.detective/deps.json', 12 | config: '.detective/config.json', 13 | path: '', 14 | port: 3334, 15 | demoMode: false, 16 | open: true, 17 | }; 18 | -------------------------------------------------------------------------------- /apps/backend/src/options/parse-options.ts: -------------------------------------------------------------------------------- 1 | import { defaultOptions, Options } from './options'; 2 | 3 | export function parseOptions(args: string[]): Options { 4 | let state: 'port' | 'config' | 'path' | 'none' | 'open' = 'none'; 5 | const parsed: Partial = {}; 6 | 7 | for (const arg of args) { 8 | if (state === 'none') { 9 | if (arg === '--port') { 10 | state = 'port'; 11 | } else if (arg === '--config') { 12 | state = 'config'; 13 | } else if (arg === '--path') { 14 | state = 'path'; 15 | } else if (arg === '--open') { 16 | state = 'open'; 17 | } else if (arg === '--demo') { 18 | parsed.demoMode = true; 19 | } else if (!parsed.sheriffDump) { 20 | parsed.sheriffDump = arg; 21 | } 22 | } else if (state === 'port') { 23 | parsed.port = parseInt(arg); 24 | state = 'none'; 25 | } else if (state === 'path') { 26 | parsed.path = arg; 27 | state = 'none'; 28 | } else if (state === 'config') { 29 | parsed.config = arg; 30 | state = 'none'; 31 | } else if (state === 'open') { 32 | parsed.open = arg !== 'false' && Boolean(arg); 33 | state = 'none'; 34 | } 35 | } 36 | 37 | return { ...defaultOptions, ...parsed }; 38 | } 39 | -------------------------------------------------------------------------------- /apps/backend/src/options/validate-options.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { Options } from './options'; 4 | 5 | export function validateOptions(options: Options) { 6 | try { 7 | if (!fs.existsSync(options.sheriffDump)) { 8 | console.error('Sheriff export does not exist: ', options.sheriffDump); 9 | return false; 10 | } 11 | if (!options.port) { 12 | return false; 13 | } 14 | } catch (e) { 15 | console.error(e); 16 | return false; 17 | } 18 | return true; 19 | } 20 | -------------------------------------------------------------------------------- /apps/backend/src/services/change-coupling.spec.ts: -------------------------------------------------------------------------------- 1 | import { emptyConfig } from '../model/config'; 2 | import { Limits } from '../model/limits'; 3 | import { defaultOptions } from '../options/options'; 4 | 5 | import { calcChangeCoupling } from './change-coupling'; 6 | 7 | const now = new Date(); 8 | 9 | const scopes = ['/booking', '/checkin', '/shared']; 10 | 11 | jest.mock('../infrastructure/config', () => ({ 12 | loadConfig: jest.fn(() => ({ 13 | ...emptyConfig, 14 | scopes, 15 | })), 16 | })); 17 | 18 | jest.mock('../infrastructure/log', () => ({ 19 | loadCachedLog: jest.fn( 20 | () => `"John Doe ,${now.toISOString()}" 21 | 1\t0\t/booking/feature-manage/my.component.ts 22 | 1\t0\t/booking/feature-manage/my-other.component.ts 23 | 0\t1\t/checkin/feature-checkin/my.component.ts 24 | 0\t1\t/shared/feature-checkin/my.component.ts 25 | 26 | "Jane Doe ,${now.toISOString()}" 27 | 10\t0\t/booking/feature-manage/my.component.ts 28 | 0\t1\t/checkin/feature-checkin/my.component.ts 29 | 30 | "John Doe ,${now.toISOString()}" 31 | 10\t0\t/booking/feature-manage/my.component.ts 32 | 0\t1\t/shared/feature-checkin/my.component.ts 33 | 34 | "Jane Doe ,${now.toISOString()}" 35 | 10\t0\t/shell/my.component.ts 36 | 0\t1\t/shell/my-other.component.ts 37 | ` 38 | ), 39 | })); 40 | 41 | describe('change coupling service', () => { 42 | it('calculates commits per module alongside change coupling', async () => { 43 | const limits: Limits = { 44 | limitCommits: 3, 45 | limitMonths: 0, 46 | }; 47 | 48 | const result = await calcChangeCoupling(limits, defaultOptions); 49 | 50 | expect(result.dimensions).toEqual(scopes); 51 | 52 | expect(result.matrix).toEqual([ 53 | [0, 2, 2], 54 | [0, 0, 1], 55 | [0, 0, 0], 56 | ]); 57 | 58 | expect(result.sumOfCoupling).toEqual([4, 3, 3]); 59 | expect(result.fileCount).toEqual([3, 2, 2]); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /apps/backend/src/services/change-coupling.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from '../infrastructure/config'; 2 | import { Limits } from '../model/limits'; 3 | import { Options } from '../options/options'; 4 | import { parseGitLog, ParseOptions } from '../utils/git-parser'; 5 | import { getEmptyMatrix } from '../utils/matrix'; 6 | import { normalizeFolder } from '../utils/normalize-folder'; 7 | 8 | export type ChangeCouplingResult = { 9 | matrix: number[][]; 10 | dimensions: string[]; 11 | groups: string[]; 12 | sumOfCoupling: number[]; 13 | 14 | fileCount: number[]; 15 | cohesion: number[]; 16 | }; 17 | 18 | export async function calcChangeCoupling( 19 | limits: Limits, 20 | options: Options 21 | ): Promise { 22 | const config = loadConfig(options); 23 | 24 | const displayModules = config.scopes; 25 | const modules = displayModules.map((m) => normalizeFolder(m)); 26 | 27 | const matrix = getEmptyMatrix(modules.length); 28 | 29 | const commitsPerModule: number[] = new Array(matrix.length).fill(0); 30 | const sumOfCoupling: number[] = new Array(matrix.length).fill(0); 31 | 32 | const parseOptions: ParseOptions = { 33 | limits, 34 | filter: config.filter, 35 | }; 36 | 37 | await parseGitLog((entry) => { 38 | const touchedModules = new Set(); 39 | for (const change of entry.body) { 40 | for (let i = 0; i < modules.length; i++) { 41 | const module = modules[i]; 42 | if (change.path.startsWith(module) && !touchedModules.has(i)) { 43 | commitsPerModule[i]++; 44 | touchedModules.add(i); 45 | } 46 | } 47 | } 48 | 49 | updateSumOfCoupling(touchedModules, sumOfCoupling); 50 | addToMatrix(touchedModules, matrix); 51 | }, parseOptions); 52 | 53 | return { 54 | matrix, 55 | dimensions: displayModules, 56 | groups: config.groups, 57 | sumOfCoupling, 58 | fileCount: commitsPerModule, 59 | cohesion: new Array(matrix.length).fill(-1), 60 | }; 61 | } 62 | 63 | function updateSumOfCoupling( 64 | touchedModules: Set, 65 | sumOfCoupling: number[] 66 | ) { 67 | const count = touchedModules.size; 68 | if (count > 1) { 69 | const otherModules = count - 1; 70 | for (const module of touchedModules) { 71 | sumOfCoupling[module] += otherModules; 72 | } 73 | } 74 | } 75 | 76 | function addToMatrix(touchedModules: Set, matrix: number[][]) { 77 | const touchedArray = Array.from(touchedModules); 78 | for (const module1 of touchedArray) { 79 | for (const module2 of touchedArray) { 80 | if (module1 < module2) { 81 | matrix[module1][module2]++; 82 | } 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /apps/backend/src/services/coupling.spec.ts: -------------------------------------------------------------------------------- 1 | import { emptyConfig } from '../model/config'; 2 | import { Deps } from '../model/deps'; 3 | import { defaultOptions } from '../options/options'; 4 | 5 | import { calcCoupling } from './coupling'; 6 | 7 | const scopes = ['/booking', '/checkin', '/shared']; 8 | 9 | jest.mock('../infrastructure/config', () => ({ 10 | loadConfig: jest.fn(() => ({ 11 | ...emptyConfig, 12 | scopes, 13 | })), 14 | })); 15 | 16 | jest.mock('../infrastructure/deps', () => ({ 17 | loadDeps: jest.fn( 18 | () => 19 | ({ 20 | '/booking/b1.ts': { 21 | module: '', 22 | tags: [], 23 | imports: ['/booking/b2.ts', '/checkin/c1.ts', '/shared/s1.ts'], 24 | }, 25 | '/booking/b2.ts': { 26 | module: '', 27 | tags: [], 28 | imports: ['/shared/s1.ts'], 29 | }, 30 | '/checkin/c1.ts': { 31 | module: '', 32 | tags: [], 33 | imports: ['/shared/s1.ts'], 34 | }, 35 | '/shared/s1.ts': { 36 | module: '', 37 | tags: [], 38 | imports: ['/shared/s2.ts'], 39 | }, 40 | '/shared/s2.ts': { 41 | module: '', 42 | tags: [], 43 | imports: [], 44 | }, 45 | '/shared/s3.ts': { 46 | module: '', 47 | tags: [], 48 | imports: [], 49 | }, 50 | } as Deps) 51 | ), 52 | })); 53 | 54 | describe('coupling service', () => { 55 | it('infers coupling from dependencies', async () => { 56 | const result = await calcCoupling(defaultOptions); 57 | 58 | expect(result.matrix).toEqual([ 59 | [1, 1, 2], 60 | [0, 0, 1], 61 | [0, 0, 1], 62 | ]); 63 | expect(result.dimensions).toEqual(scopes); 64 | expect(result.fileCount).toEqual([2, 1, 3]); 65 | expect(result.cohesion).toEqual([100, 100, 33]); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /apps/backend/src/services/coupling.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from '../infrastructure/config'; 2 | import { loadDeps } from '../infrastructure/deps'; 3 | import { Deps } from '../model/deps'; 4 | import { Options } from '../options/options'; 5 | import { getEmptyMatrix } from '../utils/matrix'; 6 | import { normalizeFolder } from '../utils/normalize-folder'; 7 | import { toPercent } from '../utils/to-percent'; 8 | 9 | import { calcModuleInfo, ModuleInfo } from './module-info'; 10 | 11 | export type CouplingResult = { 12 | groups: string[]; 13 | dimensions: string[]; 14 | fileCount: number[]; 15 | cohesion: number[]; 16 | matrix: number[][]; 17 | }; 18 | 19 | export function calcCoupling(options: Options): CouplingResult { 20 | const config = loadConfig(options); 21 | const deps = loadDeps(options); 22 | 23 | const files = Object.keys(deps); 24 | const modules = config.scopes.map((m) => normalizeFolder(m)); 25 | 26 | const scopeMap = calcScopeMap(modules); 27 | const matrixSize = modules.length; 28 | const matrix: number[][] = getEmptyMatrix(matrixSize); 29 | 30 | for (const row of modules) { 31 | for (const col of modules) { 32 | const count = calcCell(files, deps, row, col); 33 | 34 | const i = scopeMap.get(row); 35 | const j = scopeMap.get(col); 36 | 37 | if (typeof i === 'undefined' || typeof j === 'undefined') { 38 | throw new Error(`undefined matrix position ${i}, ${j}`); 39 | } 40 | 41 | matrix[i][j] = count; 42 | } 43 | } 44 | 45 | const moduleInfo = calcModuleInfo(options); 46 | const cohesion = calcCohesion(moduleInfo, matrix); 47 | 48 | return { 49 | groups: config.groups, 50 | dimensions: config.scopes, 51 | fileCount: moduleInfo.fileCount, 52 | cohesion, 53 | matrix, 54 | }; 55 | } 56 | 57 | function calcCohesion(moduleInfo: ModuleInfo, matrix: number[][]) { 58 | return moduleInfo.fileCount.map((count, index) => { 59 | const edges = matrix[index][index]; 60 | const maxEdges = (count * (count - 1)) / 2; 61 | const factor = maxEdges > 0 ? edges / maxEdges : 1; 62 | return toPercent(factor); 63 | }); 64 | } 65 | 66 | function calcCell(files: string[], deps: Deps, row: string, col: string) { 67 | let count = 0; 68 | for (const file of files) { 69 | if (file.startsWith(row)) { 70 | count += sumUpImports(deps, file, col); 71 | } 72 | } 73 | return count; 74 | } 75 | 76 | function sumUpImports(deps: Deps, file: string, col: string) { 77 | let count = 0; 78 | for (const importPath of deps[file].imports) { 79 | if (importPath.startsWith(col)) { 80 | count++; 81 | } 82 | } 83 | return count; 84 | } 85 | 86 | function calcScopeMap(modules: string[]) { 87 | const scopeMap = new Map(); 88 | for (let i = 0; i < modules.length; i++) { 89 | scopeMap.set(modules[i], i); 90 | } 91 | return scopeMap; 92 | } 93 | -------------------------------------------------------------------------------- /apps/backend/src/services/folders.spec.ts: -------------------------------------------------------------------------------- 1 | import { emptyConfig } from '../model/config'; 2 | import { Deps } from '../model/deps'; 3 | import { defaultOptions } from '../options/options'; 4 | 5 | import { inferFolders } from './folders'; 6 | 7 | const scopes = ['/booking', '/checkin', '/shared']; 8 | 9 | jest.mock('../infrastructure/config', () => ({ 10 | loadConfig: jest.fn(() => ({ 11 | ...emptyConfig, 12 | scopes, 13 | })), 14 | })); 15 | 16 | jest.mock('../infrastructure/deps', () => ({ 17 | loadDeps: jest.fn( 18 | () => 19 | ({ 20 | 'booking/feature-a/b1.ts': { 21 | module: '', 22 | tags: [], 23 | imports: ['booking/b2.ts', 'checkin/c1.ts', 'shared/s1.ts'], 24 | }, 25 | 'booking/utils/b2.ts': { 26 | module: '', 27 | tags: [], 28 | imports: ['shared/s1.ts'], 29 | }, 30 | 'checkin/feature-a/c1.ts': { 31 | module: '', 32 | tags: [], 33 | imports: ['shared/s1.ts'], 34 | }, 35 | 'shared/util-a/s1.ts': { 36 | module: '', 37 | tags: [], 38 | imports: ['shared/s2.ts'], 39 | }, 40 | 'shared/util-a/s2.ts': { 41 | module: '', 42 | tags: [], 43 | imports: [], 44 | }, 45 | 'shared/util-b/s3.ts': { 46 | module: '', 47 | tags: [], 48 | imports: [], 49 | }, 50 | } as Deps) 51 | ), 52 | })); 53 | 54 | describe('folders service', () => { 55 | it('infers folder structure from dependencies', async () => { 56 | const result = await inferFolders(defaultOptions); 57 | 58 | expect(result).toEqual([ 59 | { 60 | name: 'booking', 61 | path: 'booking', 62 | folders: [ 63 | { 64 | name: 'feature-a', 65 | path: 'booking/feature-a', 66 | folders: [], 67 | }, 68 | { 69 | name: 'utils', 70 | path: 'booking/utils', 71 | folders: [], 72 | }, 73 | ], 74 | }, 75 | { 76 | name: 'checkin', 77 | path: 'checkin', 78 | folders: [ 79 | { 80 | name: 'feature-a', 81 | path: 'checkin/feature-a', 82 | folders: [], 83 | }, 84 | ], 85 | }, 86 | { 87 | name: 'shared', 88 | path: 'shared', 89 | folders: [ 90 | { 91 | name: 'util-a', 92 | path: 'shared/util-a', 93 | folders: [], 94 | }, 95 | { 96 | name: 'util-b', 97 | path: 'shared/util-b', 98 | folders: [], 99 | }, 100 | ], 101 | }, 102 | ]); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /apps/backend/src/services/folders.ts: -------------------------------------------------------------------------------- 1 | import { loadDeps } from '../infrastructure/deps'; 2 | import { Options } from '../options/options'; 3 | 4 | export type Folder = { 5 | name: string; 6 | path: string; 7 | folders: Folder[]; 8 | }; 9 | 10 | export type InternalFolder = { 11 | name: string; 12 | path: string; 13 | folders: Record; 14 | }; 15 | 16 | export function toFolder(folder: InternalFolder): Folder { 17 | const converted: Folder = { 18 | name: folder.name, 19 | path: folder.path, 20 | folders: Object.keys(folder.folders) 21 | .sort() 22 | .map((f) => toFolder(folder.folders[f])), 23 | }; 24 | return converted; 25 | } 26 | 27 | export function inferFolders(options: Options): Folder[] { 28 | const deps = loadDeps(options); 29 | 30 | const root: InternalFolder = { name: '/', path: '/', folders: {} }; 31 | 32 | for (const key of Object.keys(deps)) { 33 | const parts = key.split('/'); 34 | const folders = parts.slice(0, parts.length - 1); 35 | 36 | let current = root; 37 | const history: string[] = []; 38 | 39 | for (const folder of folders) { 40 | history.push(folder); 41 | const path = history.join('/'); 42 | 43 | let next = current.folders[folder]; 44 | 45 | if (!next) { 46 | next = { name: folder, path, folders: {} }; 47 | current.folders[folder] = next; 48 | } 49 | current = next; 50 | } 51 | } 52 | 53 | let converted = toFolder(root); 54 | while ( 55 | converted.folders.length === 1 && 56 | converted.folders[0].folders.length === 1 57 | ) 58 | converted = converted.folders[0]; 59 | return converted.folders; 60 | } 61 | -------------------------------------------------------------------------------- /apps/backend/src/services/hotspot.spec.ts: -------------------------------------------------------------------------------- 1 | import { emptyConfig } from '../model/config'; 2 | import { Limits } from '../model/limits'; 3 | import { defaultOptions } from '../options/options'; 4 | 5 | import { 6 | aggregateHotspots, 7 | findHotspotFiles, 8 | HotspotCriteria, 9 | } from './hotspot'; 10 | 11 | const now = new Date(); 12 | 13 | const scopes = ['/booking', '/checkin', '/shared']; 14 | 15 | jest.mock('../utils/complexity', () => ({ 16 | calcCyclomaticComplexity: jest.fn(() => 30), 17 | })); 18 | 19 | jest.mock('../utils/count-lines', () => ({ 20 | countLinesInFile: jest.fn(() => 100), 21 | })); 22 | 23 | jest.mock('../infrastructure/config', () => ({ 24 | loadConfig: jest.fn(() => ({ 25 | ...emptyConfig, 26 | scopes, 27 | })), 28 | })); 29 | 30 | jest.mock('fs', () => ({ 31 | existsSync: jest.fn(() => true), 32 | })); 33 | 34 | jest.mock('../infrastructure/log', () => ({ 35 | loadCachedLog: jest.fn( 36 | () => `"John Doe ,${now.toISOString()}" 37 | 1\t0\t/booking/feature-manage/my.component.ts 38 | 1\t0\t/booking/feature-manage/my-other.component.ts 39 | 0\t1\t/checkin/feature-checkin/my.component.ts 40 | 0\t1\t/shared/feature-checkin/my.component.ts 41 | 42 | "Jane Doe ,${now.toISOString()}" 43 | 10\t0\t/booking/feature-manage/my.component.ts 44 | 0\t1\t/checkin/feature-checkin/my.component.ts 45 | 46 | "John Doe ,${now.toISOString()}" 47 | 10\t0\t/booking/feature-manage/my.component.ts 48 | 0\t1\t/shared/feature-checkin/my.component.ts 49 | 50 | "Jane Doe ,${now.toISOString()}" 51 | 10\t0\t/shell/my.component.ts 52 | 0\t1\t/shell/my-other.component.ts 53 | ` 54 | ), 55 | })); 56 | 57 | describe('hotspot service', () => { 58 | it('finds modules with number of files exceeding given threshold', async () => { 59 | const limits: Limits = { 60 | limitCommits: 3, 61 | limitMonths: 0, 62 | }; 63 | 64 | const criteria: HotspotCriteria = { 65 | metric: 'Length', 66 | minScore: 50, 67 | module: '', 68 | }; 69 | 70 | const result = await aggregateHotspots(criteria, limits, defaultOptions); 71 | 72 | expect(result.aggregated).toEqual([ 73 | { 74 | parent: '/', 75 | module: '/booking', 76 | count: 1, 77 | countHotspot: 1, 78 | countOk: 1, 79 | countWarning: 0, 80 | }, 81 | { 82 | parent: '/', 83 | module: '/checkin', 84 | count: 0, 85 | countHotspot: 0, 86 | countOk: 0, 87 | countWarning: 1, 88 | }, 89 | { 90 | parent: '/', 91 | module: '/shared', 92 | count: 0, 93 | countHotspot: 0, 94 | countOk: 0, 95 | countWarning: 1, 96 | }, 97 | ]); 98 | }); 99 | 100 | it('finds modules with number of files exceeding given threshold with boundary criteria', async () => { 101 | const limits: Limits = { 102 | limitCommits: 3, 103 | limitMonths: 0, 104 | }; 105 | 106 | const criteria: HotspotCriteria = { 107 | metric: 'Length', 108 | minScore: 34, 109 | module: '', 110 | }; 111 | 112 | const result = await aggregateHotspots(criteria, limits, defaultOptions); 113 | 114 | expect(result.aggregated).toEqual([ 115 | { 116 | parent: '/', 117 | module: '/booking', 118 | count: 1, 119 | countHotspot: 1, 120 | countOk: 1, 121 | countWarning: 0, 122 | }, 123 | { 124 | parent: '/', 125 | module: '/checkin', 126 | count: 0, 127 | countHotspot: 0, 128 | countOk: 0, 129 | countWarning: 1, 130 | }, 131 | { 132 | parent: '/', 133 | module: '/shared', 134 | count: 0, 135 | countHotspot: 0, 136 | countOk: 0, 137 | countWarning: 1, 138 | }, 139 | ]); 140 | }); 141 | 142 | it('finds hotspots exceeding given threshold using Length metric', async () => { 143 | const limits: Limits = { 144 | limitCommits: 3, 145 | limitMonths: 0, 146 | }; 147 | 148 | const criteria: HotspotCriteria = { 149 | metric: 'Length', 150 | minScore: 101, 151 | module: '/booking', 152 | }; 153 | 154 | const result = await findHotspotFiles(criteria, limits, defaultOptions); 155 | 156 | expect(result.hotspots).toEqual([ 157 | { 158 | fileName: '/booking/feature-manage/my.component.ts', 159 | commits: 3, 160 | changedLines: 21, 161 | complexity: 100, 162 | score: 300, 163 | }, 164 | ]); 165 | }); 166 | 167 | it('finds hotspots exceeding given threshold using McCabe', async () => { 168 | const limits: Limits = { 169 | limitCommits: 3, 170 | limitMonths: 0, 171 | }; 172 | 173 | const criteria: HotspotCriteria = { 174 | metric: 'McCabe', 175 | minScore: 31, 176 | module: '/booking', 177 | }; 178 | 179 | const result = await findHotspotFiles(criteria, limits, defaultOptions); 180 | 181 | expect(result.hotspots).toEqual([ 182 | { 183 | fileName: '/booking/feature-manage/my.component.ts', 184 | commits: 3, 185 | changedLines: 21, 186 | complexity: 30, 187 | score: 90, 188 | }, 189 | ]); 190 | }); 191 | }); 192 | -------------------------------------------------------------------------------- /apps/backend/src/services/log-cache.ts: -------------------------------------------------------------------------------- 1 | import { calcTreeHash, getGitLog } from '../infrastructure/git'; 2 | import { saveCachedLog } from '../infrastructure/log'; 3 | import { loadTreeHash, saveTreeHash } from '../infrastructure/tree-hash'; 4 | import { DETECTIVE_VERSION } from '../infrastructure/version'; 5 | 6 | export function isStale(): boolean { 7 | const lastHash = loadTreeHash(); 8 | 9 | if (!lastHash) { 10 | return true; 11 | } 12 | 13 | const currentHash = calcVersionedHash(); 14 | return currentHash !== lastHash; 15 | } 16 | 17 | export async function updateLogCache(): Promise { 18 | const log = await getGitLog(); 19 | saveCachedLog(log); 20 | 21 | const hash = calcVersionedHash(); 22 | saveTreeHash(hash); 23 | } 24 | 25 | function calcVersionedHash() { 26 | return calcTreeHash() + ', v' + DETECTIVE_VERSION; 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/services/module-info.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from '../infrastructure/config'; 2 | import { loadDeps } from '../infrastructure/deps'; 3 | import { Options } from '../options/options'; 4 | 5 | export type ModuleInfo = { 6 | fileCount: number[]; 7 | }; 8 | 9 | export function calcModuleInfo(options: Options): ModuleInfo { 10 | const config = loadConfig(options); 11 | const deps = loadDeps(options); 12 | 13 | const fileCount = new Array(config.scopes.length).fill(0); 14 | 15 | for (const dep of Object.keys(deps)) { 16 | for (let i = 0; i < config.scopes.length; i++) { 17 | const scope = config.scopes[i]; 18 | if (dep.startsWith(scope)) { 19 | fileCount[i]++; 20 | } 21 | } 22 | } 23 | 24 | return { 25 | fileCount, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /apps/backend/src/services/team-alignment.spec.ts: -------------------------------------------------------------------------------- 1 | import { emptyConfig } from '../model/config'; 2 | import { Limits } from '../model/limits'; 3 | import { defaultOptions, Options } from '../options/options'; 4 | 5 | import { calcTeamAlignment } from './team-alignment'; 6 | 7 | const now = new Date(); 8 | 9 | const scopes = ['/booking', '/checkin', '/shared']; 10 | 11 | jest.mock('../utils/complexity', () => ({ 12 | calcCyclomaticComplexity: jest.fn(() => 30), 13 | })); 14 | 15 | jest.mock('../utils/count-lines', () => ({ 16 | countLinesInFile: jest.fn(() => 100), 17 | })); 18 | 19 | jest.mock('../infrastructure/config', () => ({ 20 | loadConfig: jest.fn(() => ({ 21 | ...emptyConfig, 22 | teams: { 23 | alpha: ['John Doe'], 24 | beta: ['Jane Doe', 'Max Muster'], 25 | }, 26 | scopes, 27 | })), 28 | })); 29 | 30 | jest.mock('fs', () => ({ 31 | existsSync: jest.fn(() => true), 32 | })); 33 | 34 | jest.mock('../infrastructure/log', () => ({ 35 | loadCachedLog: jest.fn( 36 | () => `"John Doe ,${now.toISOString()}" 37 | 1\t0\t/booking/feature-manage/my.component.ts 38 | 1\t0\t/booking/feature-manage/my-other.component.ts 39 | 0\t20\t/checkin/feature-checkin/my.component.ts 40 | 0\t1\t/shared/feature-checkin/my.component.ts 41 | 42 | "Jane Doe ,${now.toISOString()}" 43 | 10\t0\t/booking/feature-manage/my.component.ts 44 | 0\t1\t/checkin/feature-checkin/my.component.ts 45 | 46 | "Max Muster ,${now.toISOString()}" 47 | 10\t0\t/booking/feature-manage/my.component.ts 48 | 0\t1\t/shared/feature-checkin/my.component.ts 49 | 50 | "Maria Muster ,${now.toISOString()}" 51 | 0\t20\t/checkin/feature-checkin/my.component.ts 52 | 0\t1\t/shared/feature-checkin/my.component.ts 53 | 54 | "Jane Doe ,${now.toISOString()}" 55 | 20\t0\t/shell/my.component.ts 56 | 0\t1\t/shell/my-other.component.ts 57 | ` 58 | ), 59 | })); 60 | 61 | describe('team alignment service', () => { 62 | it('derives team alignment from git logs', async () => { 63 | const limits: Limits = { 64 | limitCommits: 4, 65 | limitMonths: 0, 66 | }; 67 | 68 | const result = await calcTeamAlignment(false, limits, defaultOptions); 69 | 70 | expect(result.modules).toEqual({ 71 | '/booking': { 72 | changes: { 73 | alpha: 2, 74 | beta: 20, 75 | }, 76 | }, 77 | '/checkin': { 78 | changes: { 79 | alpha: 20, 80 | beta: 1, 81 | unknown: 20, 82 | }, 83 | }, 84 | '/shared': { 85 | changes: { 86 | alpha: 1, 87 | beta: 1, 88 | unknown: 1, 89 | }, 90 | }, 91 | }); 92 | 93 | expect(result.teams).toEqual(['alpha', 'beta', 'unknown']); 94 | }); 95 | 96 | it('breaks down team alignment to user level', async () => { 97 | const limits: Limits = { 98 | limitCommits: 4, 99 | limitMonths: 0, 100 | }; 101 | 102 | const result = await calcTeamAlignment(true, limits, defaultOptions); 103 | 104 | expect(result.modules).toEqual({ 105 | '/booking': { 106 | changes: { 'John Doe': 2, 'Jane Doe': 10, 'Max Muster': 10 }, 107 | }, 108 | '/checkin': { 109 | changes: { 'John Doe': 20, 'Jane Doe': 1, 'Maria Muster': 20 }, 110 | }, 111 | '/shared': { 112 | changes: { 'John Doe': 1, 'Max Muster': 1, 'Maria Muster': 1 }, 113 | }, 114 | }); 115 | 116 | expect(result.teams).toEqual([ 117 | 'Jane Doe', 118 | 'John Doe', 119 | 'Maria Muster', 120 | 'Max Muster', 121 | ]); 122 | }); 123 | 124 | it('uses dummy users in demo mode', async () => { 125 | const limits: Limits = { 126 | limitCommits: 4, 127 | limitMonths: 0, 128 | }; 129 | 130 | const options: Options = { 131 | ...defaultOptions, 132 | demoMode: true, 133 | }; 134 | 135 | const result = await calcTeamAlignment(true, limits, options); 136 | 137 | expect(result.modules).toEqual({ 138 | '/booking': { 139 | changes: { 140 | 'Max Muster': 2, 141 | 'John Doe': 10, 142 | 'Jane Doe': 10, 143 | }, 144 | }, 145 | '/checkin': { 146 | changes: { 147 | 'Max Muster': 20, 148 | 'John Doe': 1, 149 | 'Maria Muster': 20, 150 | }, 151 | }, 152 | '/shared': { 153 | changes: { 154 | 'Max Muster': 1, 155 | 'Jane Doe': 1, 156 | 'Maria Muster': 1, 157 | }, 158 | }, 159 | }); 160 | }); 161 | }); 162 | -------------------------------------------------------------------------------- /apps/backend/src/services/team-alignment.ts: -------------------------------------------------------------------------------- 1 | import { loadConfig } from '../infrastructure/config'; 2 | import { Limits } from '../model/limits'; 3 | import { Options } from '../options/options'; 4 | import { parseGitLog, ParseOptions } from '../utils/git-parser'; 5 | import { normalizeFolder } from '../utils/normalize-folder'; 6 | 7 | const UNKNOWN_TEAM = 'unknown'; 8 | 9 | export type ModuleDetails = { 10 | changes: Record; 11 | }; 12 | 13 | export type TeamAlignmentResult = { 14 | modules: Record; 15 | teams: string[]; 16 | }; 17 | 18 | export async function calcTeamAlignment( 19 | byUser = false, 20 | limits: Limits, 21 | options: Options 22 | ): Promise { 23 | const config = loadConfig(options); 24 | const displayModules = config.scopes; 25 | const modules = displayModules.map((m) => normalizeFolder(m)); 26 | const teams = config.teams || {}; 27 | 28 | const userToTeam = initUserToTeam(teams); 29 | const result = initResult(displayModules, Object.keys(teams)); 30 | 31 | const actualTeams = new Set(); 32 | 33 | const parseOptions: ParseOptions = { 34 | limits, 35 | filter: config.filter, 36 | }; 37 | 38 | let count = 0; 39 | await parseGitLog((entry) => { 40 | let userName = entry.header.userName; 41 | 42 | if (options.demoMode) { 43 | count++; 44 | if (count % 4 === 1) { 45 | userName = 'Max Muster'; 46 | } else if (count % 4 === 2) { 47 | userName = 'John Doe'; 48 | } else if (count % 4 == 3) { 49 | userName = 'Jane Doe'; 50 | } 51 | } 52 | 53 | userName = config.aliases?.[userName] || userName; 54 | 55 | const key = calcKey(byUser, userName, userToTeam); 56 | actualTeams.add(key); 57 | 58 | for (const change of entry.body) { 59 | for (let i = 0; i < modules.length; i++) { 60 | const module = modules[i]; 61 | const display = displayModules[i]; 62 | 63 | if (change.path.startsWith(module)) { 64 | const changes = result.modules[display].changes; 65 | const current = changes[key] || 0; 66 | changes[key] = current + change.linesAdded + change.linesRemoved; 67 | // changes[key] = current + 1; 68 | break; 69 | } 70 | } 71 | } 72 | }, parseOptions); 73 | 74 | result.teams = Array.from(actualTeams).sort(); 75 | 76 | return result; 77 | } 78 | 79 | function calcKey( 80 | byUser: boolean, 81 | userName: string, 82 | userToTeam: Record 83 | ) { 84 | if (byUser) { 85 | return userName; 86 | } else { 87 | return userToTeam[userName] || UNKNOWN_TEAM; 88 | } 89 | } 90 | 91 | function initUserToTeam(teams: Record) { 92 | const userToTeam: Record = {}; 93 | 94 | for (const teamName of Object.keys(teams)) { 95 | const team = teams[teamName]; 96 | for (const user of team) { 97 | userToTeam[user] = teamName; 98 | } 99 | } 100 | return userToTeam; 101 | } 102 | 103 | function initResult(modules: string[], teams: string[]) { 104 | const sorted = [...teams].sort(); 105 | const result: TeamAlignmentResult = { 106 | modules: {}, 107 | teams: [...sorted, UNKNOWN_TEAM], 108 | }; 109 | for (const module of modules) { 110 | result.modules[module] = { changes: {} }; 111 | } 112 | return result; 113 | } 114 | -------------------------------------------------------------------------------- /apps/backend/src/utils/complexity.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | import * as ts from 'typescript'; 4 | 5 | export function calcCyclomaticComplexity(fileName: string): number { 6 | const code = fs.readFileSync(fileName, 'utf-8'); 7 | return calcComplexityForCode(code); 8 | } 9 | 10 | function calcComplexityForCode(sourceCode: string): number { 11 | const sourceFile = ts.createSourceFile( 12 | 'temp.ts', 13 | sourceCode, 14 | ts.ScriptTarget.Latest, 15 | true 16 | ); 17 | 18 | let complexity = 1; 19 | 20 | function visit(node: ts.Node) { 21 | switch (node.kind) { 22 | case ts.SyntaxKind.IfStatement: 23 | case ts.SyntaxKind.ConditionalExpression: 24 | case ts.SyntaxKind.ForStatement: 25 | case ts.SyntaxKind.ForInStatement: 26 | case ts.SyntaxKind.ForOfStatement: 27 | case ts.SyntaxKind.WhileStatement: 28 | case ts.SyntaxKind.DoStatement: 29 | case ts.SyntaxKind.CaseClause: 30 | case ts.SyntaxKind.FunctionDeclaration: 31 | case ts.SyntaxKind.MethodDeclaration: 32 | case ts.SyntaxKind.Constructor: 33 | case ts.SyntaxKind.ArrowFunction: 34 | case ts.SyntaxKind.FunctionExpression: 35 | case ts.SyntaxKind.CatchClause: 36 | complexity++; 37 | break; 38 | } 39 | ts.forEachChild(node, visit); 40 | } 41 | 42 | visit(sourceFile); 43 | return complexity; 44 | } 45 | -------------------------------------------------------------------------------- /apps/backend/src/utils/count-lines.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs'; 2 | 3 | export function countLinesInFile(filePath: string) { 4 | const data = fs.readFileSync(filePath, 'utf8'); 5 | return data.split(/\n/).length; 6 | } 7 | -------------------------------------------------------------------------------- /apps/backend/src/utils/date-utils.ts: -------------------------------------------------------------------------------- 1 | export function subtractMonths(date: Date, months: number): Date { 2 | const modified = new Date(date); 3 | modified.setMonth(modified.getMonth() - months); 4 | return modified; 5 | } 6 | 7 | export function subtractSeconds(date: Date, seconds: number): Date { 8 | const modified = new Date(date); 9 | modified.setHours(modified.getHours() - seconds); 10 | return modified; 11 | } 12 | 13 | export function getToday(): Date { 14 | const today = new Date(); 15 | today.setHours(0, 0, 0, 0); 16 | return today; 17 | } 18 | -------------------------------------------------------------------------------- /apps/backend/src/utils/git-parser.ts: -------------------------------------------------------------------------------- 1 | import microMatch from 'micromatch'; 2 | 3 | import { loadCachedLog } from '../infrastructure/log'; 4 | import { Filter } from '../model/config'; 5 | import { Limits, noLimits } from '../model/limits'; 6 | import { getToday, subtractMonths } from '../utils/date-utils'; 7 | 8 | type State = 'header' | 'body' | 'skip'; 9 | 10 | export type LogHeader = { 11 | userName: string; 12 | email: string; 13 | date: Date; 14 | }; 15 | 16 | export type LogBodyEntry = { 17 | linesAdded: number; 18 | linesRemoved: number; 19 | path: string; 20 | }; 21 | 22 | export type LogEntry = { 23 | header: LogHeader; 24 | body: LogBodyEntry[]; 25 | }; 26 | 11; 27 | export type ParserCallback = (entry: LogEntry) => void; 28 | 29 | const initHeader: LogHeader = { 30 | userName: '', 31 | email: '', 32 | date: new Date(0), 33 | }; 34 | 35 | export type ParseOptions = { 36 | limits: Limits; 37 | filter?: Filter; 38 | }; 39 | 40 | export const defaultParseOptions: ParseOptions = { 41 | limits: noLimits, 42 | filter: { 43 | files: [], 44 | logs: [], 45 | }, 46 | }; 47 | 48 | export async function parseGitLog( 49 | callback: ParserCallback, 50 | options = defaultParseOptions 51 | ) { 52 | const limits = options.limits; 53 | const fileFilter = options.filter?.files?.length 54 | ? options.filter.files 55 | : ['**/*.ts']; 56 | 57 | let pos = 0; 58 | const log = loadCachedLog(); 59 | 60 | const today = getToday(); 61 | const dateLimit = limits.limitMonths 62 | ? subtractMonths(today, limits.limitMonths) 63 | : new Date(0); 64 | 65 | let header = initHeader; 66 | let body: LogBodyEntry[] = []; 67 | 68 | const renameMap = new Map(); 69 | 70 | let state: State = 'header'; 71 | 72 | let count = 0; 73 | 74 | while (pos < log.length) { 75 | const [line, next] = getNextLine(log, pos); 76 | pos = next; 77 | 78 | if (checkExclude(line, options?.filter?.logs)) { 79 | state = 'skip'; 80 | } else if (state === 'header') { 81 | count++; 82 | 83 | if (limits.limitCommits && count > limits.limitCommits) { 84 | return; 85 | } 86 | 87 | header = parseHeader(line); 88 | 89 | if (header.date.getTime() < dateLimit.getTime()) { 90 | state = 'skip'; 91 | } else { 92 | state = 'body'; 93 | } 94 | } else if (state === 'body') { 95 | if (!line.trim()) { 96 | callback({ header, body }); 97 | body = []; 98 | state = 'header'; 99 | } else if (line.split('\t').length < 3) { 100 | header = parseHeader(line); 101 | } else { 102 | const bodyEntry = parseBodyEntry(line, renameMap); 103 | if (microMatch.match([bodyEntry.path], fileFilter).length > 0) { 104 | body.push(bodyEntry); 105 | } 106 | } 107 | } else if (state === 'skip') { 108 | if (!line.trim()) { 109 | body = []; 110 | state = 'header'; 111 | } 112 | } 113 | } 114 | 115 | if (body.length > 0) { 116 | callback({ header, body }); 117 | } 118 | } 119 | 120 | function checkExclude(line: string, filter: string[] | undefined): boolean { 121 | if (!filter) return false; 122 | for (const entry of filter) { 123 | if (line.includes(entry)) { 124 | return true; 125 | } 126 | } 127 | return false; 128 | } 129 | 130 | function parseBodyEntry( 131 | line: string, 132 | renameMap: Map 133 | ): LogBodyEntry { 134 | const parts = line.split('\t'); 135 | const linesAdded = parseInt(parts[0]); 136 | const linesRemoved = parseInt(parts[1]); 137 | let filePath = parts[2]; 138 | 139 | filePath = handleRenames(filePath, renameMap); 140 | 141 | const bodyEntry: LogBodyEntry = { 142 | linesAdded: linesAdded || 0, 143 | linesRemoved: linesRemoved || 0, 144 | path: filePath || '', 145 | }; 146 | return bodyEntry; 147 | } 148 | 149 | // path.join replacement that does not depend on OS, and normalizes separators as used in a git log 150 | function pathJoin(...args: string[]) { 151 | return args 152 | .join('/') 153 | .replace(/\/{2,}/g, '/') 154 | .replace(/\/$/g, ''); 155 | } 156 | 157 | function handleRenames(filePath: string, renameMap: Map) { 158 | const result = filePath.match(/(.*?)\{(.*?) => (.*?)\}(.*)/); 159 | 160 | if (result) { 161 | const start = result[1]; 162 | const before = result[2]; 163 | const after = result[3]; 164 | const end = result[4]; 165 | 166 | const from = pathJoin(start, before, end); 167 | const to = pathJoin(start, after, end); 168 | 169 | renameMap.set(from, renameMap.get(to) || to); 170 | filePath = to; 171 | } 172 | 173 | filePath = renameMap.get(filePath) || filePath; 174 | return filePath; 175 | } 176 | 177 | function parseHeader(line: string): LogHeader { 178 | const overviewAndDetail = line.split('\t'); 179 | const info = overviewAndDetail[0]; 180 | const parts = info.split(','); 181 | const isoString = parts.pop() as string; 182 | const date = toDate(isoString); 183 | const fullUserName = parts.join(','); 184 | const userParts = fullUserName.split('<'); 185 | const userName = cleanUserName(userParts[0]); 186 | const email = cleanEmail(userParts); 187 | return { userName, email, date }; 188 | } 189 | 190 | function toDate(isoString: string): Date { 191 | if (isoString.endsWith('"')) { 192 | isoString = isoString.substring(0, isoString.length - 1); 193 | } 194 | const date = new Date(isoString); 195 | return date; 196 | } 197 | 198 | function cleanEmail(userParts: string[]) { 199 | return userParts[1].substring(0, userParts[1].length - 1); 200 | } 201 | 202 | function cleanUserName(userName: string) { 203 | userName = userName.trim(); 204 | if (userName.startsWith('"')) { 205 | userName = userName.substring(1); 206 | } 207 | return userName; 208 | } 209 | 210 | function getNextLine(text: string, start: number): [line: string, end: number] { 211 | let line = ''; 212 | let current = ''; 213 | let pos = start; 214 | 215 | while (pos < text.length && current !== '\n') { 216 | current = text[pos]; 217 | if (current !== '\n' && current !== '\r') { 218 | line += current; 219 | } 220 | pos++; 221 | } 222 | 223 | return [line, pos]; 224 | } 225 | -------------------------------------------------------------------------------- /apps/backend/src/utils/matrix.ts: -------------------------------------------------------------------------------- 1 | export function getEmptyMatrix(size: number): number[][] { 2 | return Array.from({ length: size }, () => new Array(size).fill(0)); 3 | } 4 | -------------------------------------------------------------------------------- /apps/backend/src/utils/normalize-folder.spec.ts: -------------------------------------------------------------------------------- 1 | import { normalizeFolder, toDisplayFolder } from './normalize-folder'; 2 | 3 | describe('normalize-folder', () => { 4 | it('normalizeFolder adds trailing slash', () => { 5 | const path = 'x/y/z'; 6 | const result = normalizeFolder(path); 7 | expect(result).toBe('x/y/z/'); 8 | }); 9 | 10 | it('normalizeFolder does not add 2nd trailing slash', () => { 11 | const path = 'x/y/z/'; 12 | const result = normalizeFolder(path); 13 | expect(result).toBe('x/y/z/'); 14 | }); 15 | 16 | it('toDisplayFolder removes trailing slash', () => { 17 | const path = 'x/y/z/'; 18 | const result = toDisplayFolder(path); 19 | expect(result).toBe('x/y/z'); 20 | }); 21 | 22 | it('toDisplayFolder does not change path without trailing slash', () => { 23 | const path = 'x/y/z'; 24 | const result = toDisplayFolder(path); 25 | expect(result).toBe('x/y/z'); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /apps/backend/src/utils/normalize-folder.ts: -------------------------------------------------------------------------------- 1 | export function normalizeFolder(folder: string): string { 2 | if (!folder.endsWith('/')) { 3 | return folder + '/'; 4 | } 5 | return folder; 6 | } 7 | 8 | export function toDisplayFolder(folder: string): string { 9 | if (folder?.endsWith('/')) { 10 | return folder.substring(0, folder.length - 1); 11 | } 12 | return folder; 13 | } 14 | -------------------------------------------------------------------------------- /apps/backend/src/utils/open.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | 3 | export function openSync(url: string): void { 4 | let command: string; 5 | 6 | switch (process.platform) { 7 | case 'darwin': // macOS 8 | command = `open "${url}"`; 9 | break; 10 | case 'win32': // Windows 11 | command = `start "" "${url}"`; 12 | break; 13 | case 'linux': // Linux 14 | command = `xdg-open "${url}"`; 15 | break; 16 | default: 17 | console.error( 18 | `Cannot open the URL automatically on this platform: ${process.platform}` 19 | ); 20 | return; 21 | } 22 | 23 | try { 24 | execSync(command); 25 | } catch (err) { 26 | console.error( 27 | `Failed to automatically open this URL in your browser: ${ 28 | (err as Error).message 29 | }` 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /apps/backend/src/utils/to-percent.ts: -------------------------------------------------------------------------------- 1 | export function toPercent(n: number) { 2 | return Math.round(n * 100); 3 | } 4 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["node", "express"] 7 | }, 8 | "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"], 9 | "include": ["src/**/*.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.base.json", 3 | "files": [], 4 | "include": [], 5 | "references": [ 6 | { 7 | "path": "./tsconfig.app.json" 8 | }, 9 | { 10 | "path": "./tsconfig.spec.json" 11 | } 12 | ], 13 | "compilerOptions": { 14 | "esModuleInterop": true 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/backend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "types": ["jest", "node"] 7 | }, 8 | "include": [ 9 | "jest.config.ts", 10 | "src/**/*.test.ts", 11 | "src/**/*.spec.ts", 12 | "src/**/*.d.ts" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /apps/backend/webpack.config.js: -------------------------------------------------------------------------------- 1 | const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); 2 | const { join } = require('path'); 3 | 4 | module.exports = { 5 | output: { 6 | path: join(__dirname, '../../dist/apps/backend'), 7 | }, 8 | plugins: [ 9 | new NxAppWebpackPlugin({ 10 | target: 'node', 11 | compiler: 'tsc', 12 | main: './src/main.ts', 13 | tsConfig: './tsconfig.app.json', 14 | assets: ['./src/assets', './src/bin'], 15 | optimization: false, 16 | outputHashing: 'none', 17 | generatePackageJson: true, 18 | }), 19 | ], 20 | }; 21 | -------------------------------------------------------------------------------- /apps/frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["../../.eslintrc.json"], 3 | "ignorePatterns": ["!**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts"], 7 | "extends": [ 8 | "plugin:@nx/angular", 9 | "plugin:@angular-eslint/template/process-inline-templates" 10 | ], 11 | "rules": { 12 | "@angular-eslint/directive-selector": [ 13 | "error", 14 | { 15 | "type": "attribute", 16 | "prefix": "app", 17 | "style": "camelCase" 18 | } 19 | ], 20 | "@angular-eslint/component-selector": [ 21 | "error", 22 | { 23 | "type": "element", 24 | "prefix": "app", 25 | "style": "kebab-case" 26 | } 27 | ] 28 | } 29 | }, 30 | { 31 | "files": ["*.html"], 32 | "extends": ["plugin:@nx/angular-template"], 33 | "rules": {} 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /apps/frontend/jest.config.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | export default { 3 | displayName: 'frontend', 4 | preset: '../../jest.preset.js', 5 | setupFilesAfterEnv: ['/src/test-setup.ts'], 6 | coverageDirectory: '../../coverage/apps/frontend', 7 | transform: { 8 | '^.+\\.(ts|mjs|js|html)$': [ 9 | 'jest-preset-angular', 10 | { 11 | tsconfig: '/tsconfig.spec.json', 12 | stringifyContentPathRegex: '\\.(html|svg)$', 13 | }, 14 | ], 15 | }, 16 | transformIgnorePatterns: ['node_modules/(?!.*\\.mjs$)'], 17 | snapshotSerializers: [ 18 | 'jest-preset-angular/build/serializers/no-ng-attributes', 19 | 'jest-preset-angular/build/serializers/ng-snapshot', 20 | 'jest-preset-angular/build/serializers/html-comment', 21 | ], 22 | }; 23 | -------------------------------------------------------------------------------- /apps/frontend/project.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "$schema": "../../node_modules/nx/schemas/project-schema.json", 4 | "projectType": "application", 5 | "prefix": "app", 6 | "sourceRoot": "apps/frontend/src", 7 | "tags": [], 8 | "targets": { 9 | "build": { 10 | "executor": "@angular-devkit/build-angular:application", 11 | "outputs": ["{options.outputPath}"], 12 | "options": { 13 | "outputPath": "dist/apps/frontend", 14 | "index": "apps/frontend/src/index.html", 15 | "browser": "apps/frontend/src/main.ts", 16 | "polyfills": ["zone.js"], 17 | "tsConfig": "apps/frontend/tsconfig.app.json", 18 | "assets": [ 19 | { 20 | "glob": "**/*", 21 | "input": "apps/frontend/public" 22 | } 23 | ], 24 | "styles": [ 25 | "@angular/material/prebuilt-themes/indigo-pink.css", 26 | "apps/frontend/src/styles.scss", 27 | "qtip2/dist/jquery.qtip.min.css" 28 | ], 29 | "scripts": [], 30 | "allowedCommonJsDependencies": [ 31 | "cytoscape-cola", 32 | "cytoscape-qtip", 33 | "cytoscape-dagre" 34 | ] 35 | }, 36 | "configurations": { 37 | "production": { 38 | "outputHashing": "all" 39 | }, 40 | "development": { 41 | "optimization": false, 42 | "extractLicenses": false, 43 | "sourceMap": true 44 | } 45 | }, 46 | "defaultConfiguration": "production" 47 | }, 48 | "serve": { 49 | "executor": "@angular-devkit/build-angular:dev-server", 50 | "options": { 51 | "proxyConfig": "apps/frontend/proxy.conf.json" 52 | }, 53 | "configurations": { 54 | "production": { 55 | "buildTarget": "frontend:build:production" 56 | }, 57 | "development": { 58 | "buildTarget": "frontend:build:development" 59 | } 60 | }, 61 | "defaultConfiguration": "development" 62 | }, 63 | "extract-i18n": { 64 | "executor": "@angular-devkit/build-angular:extract-i18n", 65 | "options": { 66 | "buildTarget": "frontend:build" 67 | } 68 | }, 69 | "lint": { 70 | "executor": "@nx/eslint:lint" 71 | }, 72 | "test": { 73 | "executor": "@nx/jest:jest", 74 | "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], 75 | "options": { 76 | "jestConfig": "apps/frontend/jest.config.ts" 77 | } 78 | }, 79 | "serve-static": { 80 | "executor": "@nx/web:file-server", 81 | "options": { 82 | "buildTarget": "frontend:build", 83 | "port": 4200, 84 | "staticFilePath": "dist/apps/frontend/browser", 85 | "spa": true 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /apps/frontend/proxy.conf.json: -------------------------------------------------------------------------------- 1 | { 2 | "/api": { 3 | "target": "http://localhost:3334", 4 | "secure": false, 5 | "changeOrigin": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/apps/frontend/public/favicon.ico -------------------------------------------------------------------------------- /apps/frontend/src/app/app.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/apps/frontend/src/app/app.component.css -------------------------------------------------------------------------------- /apps/frontend/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /apps/frontend/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, inject, OnInit } from '@angular/core'; 2 | import { RouterOutlet } from '@angular/router'; 3 | 4 | import { StatusStore } from './data/status.store'; 5 | import { NavComponent } from './shell/nav/nav.component'; 6 | 7 | @Component({ 8 | selector: 'app-root', 9 | standalone: true, 10 | imports: [RouterOutlet, NavComponent], 11 | templateUrl: './app.component.html', 12 | styleUrl: './app.component.css', 13 | }) 14 | export class AppComponent implements OnInit { 15 | statusStore = inject(StatusStore); 16 | 17 | ngOnInit(): void { 18 | this.statusStore.load(); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/frontend/src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import { ClipboardModule } from '@angular/cdk/clipboard'; 2 | import { provideHttpClient } from '@angular/common/http'; 3 | import { 4 | ApplicationConfig, 5 | importProvidersFrom, 6 | provideZoneChangeDetection, 7 | } from '@angular/core'; 8 | import { MatDialogModule } from '@angular/material/dialog'; 9 | import { MatSnackBarModule } from '@angular/material/snack-bar'; 10 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; 11 | import { provideRouter, withComponentInputBinding } from '@angular/router'; 12 | 13 | import { routes } from './app.routes'; 14 | import { AppErrorHandler, provideErrorHandler } from './utils/error-handler'; 15 | 16 | export const appConfig: ApplicationConfig = { 17 | providers: [ 18 | provideZoneChangeDetection({ eventCoalescing: true }), 19 | provideHttpClient(), 20 | provideRouter(routes, withComponentInputBinding()), 21 | provideErrorHandler(AppErrorHandler), 22 | provideAnimationsAsync(), 23 | importProvidersFrom(MatDialogModule), 24 | importProvidersFrom(MatSnackBarModule), 25 | importProvidersFrom(ClipboardModule), 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /apps/frontend/src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { Routes } from '@angular/router'; 2 | 3 | import { CouplingComponent } from './features/coupling/coupling.component'; 4 | import { HotspotComponent } from './features/hotspot/hotspot.component'; 5 | import { TeamAlignmentComponent } from './features/team-alignment/team-alignment.component'; 6 | import { GraphTypeData } from './model/graph-type'; 7 | import { AboutComponent } from './shell/about/about.component'; 8 | import { ensureCache } from './shell/cache.guard'; 9 | 10 | export const routes: Routes = [ 11 | { 12 | path: '', 13 | pathMatch: 'full', 14 | redirectTo: 'graph', 15 | }, 16 | { 17 | path: 'graph', 18 | component: CouplingComponent, 19 | data: { 20 | type: 'structure', 21 | } as GraphTypeData, 22 | }, 23 | { 24 | path: 'about', 25 | component: AboutComponent, 26 | }, 27 | { 28 | path: '', 29 | canActivate: [ensureCache], 30 | children: [ 31 | { 32 | path: 'team-alignment', 33 | component: TeamAlignmentComponent, 34 | }, 35 | { 36 | path: 'hotspots', 37 | component: HotspotComponent, 38 | }, 39 | { 40 | path: 'change-coupling', 41 | component: CouplingComponent, 42 | data: { 43 | type: 'changes', 44 | } as GraphTypeData, 45 | }, 46 | ], 47 | }, 48 | ]; 49 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { CacheStatus } from '../model/cache-status'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class CacheService { 9 | private http = inject(HttpClient); 10 | 11 | loadLogCacheStatus(): Observable { 12 | const url = `/api/cache/log`; 13 | return this.http.get(url); 14 | } 15 | 16 | updateLogCache(): Observable { 17 | const url = `/api/cache/log/update`; 18 | return this.http.post(url, null); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/config.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Config } from '../model/config'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class ConfigService { 9 | private http = inject(HttpClient); 10 | 11 | load(): Observable { 12 | return this.http.get('/api/config'); 13 | } 14 | 15 | save(config: Config): Observable { 16 | return this.http.post('/api/config', config); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/coupling.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { CouplingResult } from '../model/coupling-result'; 6 | import { GraphType } from '../model/graph-type'; 7 | import { initLimits } from '../model/limits'; 8 | 9 | @Injectable({ providedIn: 'root' }) 10 | export class CouplingService { 11 | private http = inject(HttpClient); 12 | 13 | load( 14 | type: GraphType = 'structure', 15 | limits = initLimits 16 | ): Observable { 17 | if (type === 'changes') { 18 | const params = { ...limits }; 19 | return this.http.get('/api/change-coupling', { 20 | params, 21 | responseType: 'json', 22 | }); 23 | } else { 24 | return this.http.get('/api/coupling'); 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/folder.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Folder } from '../model/folder'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class FolderService { 9 | private http = inject(HttpClient); 10 | 11 | load(): Observable { 12 | return this.http.get('/api/folders'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/hotspot.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { 6 | AggregatedHotspotsResult, 7 | HotspotCriteria, 8 | HotspotResult, 9 | } from '../model/hotspot-result'; 10 | import { initLimits } from '../model/limits'; 11 | 12 | @Injectable({ providedIn: 'root' }) 13 | export class HotspotService { 14 | private http = inject(HttpClient); 15 | 16 | load( 17 | criteria: HotspotCriteria, 18 | limits = initLimits 19 | ): Observable { 20 | const url = `/api/hotspots`; 21 | const params = { ...criteria, ...limits }; 22 | return this.http.get(url, { params }); 23 | } 24 | 25 | loadAggregated( 26 | criteria: HotspotCriteria, 27 | limits = initLimits 28 | ): Observable { 29 | const url = `/api/hotspots/aggregated`; 30 | const params = { ...criteria, ...limits }; 31 | return this.http.get(url, { params }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/limits.store.ts: -------------------------------------------------------------------------------- 1 | import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; 2 | 3 | import { initLimits, Limits } from '../model/limits'; 4 | 5 | export const LimitsStore = signalStore( 6 | { providedIn: 'root' }, 7 | withState({ 8 | limits: initLimits, 9 | }), 10 | withMethods((store) => ({ 11 | updateLimits(limits: Limits) { 12 | patchState(store, { limits }); 13 | }, 14 | })) 15 | ); 16 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/module.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { ModuleInfo } from '../model/module-info'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class ModuleService { 9 | private http = inject(HttpClient); 10 | 11 | load(): Observable { 12 | return this.http.get('/api/modules'); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/status.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Status } from '../model/status'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class StatusService { 9 | private http = inject(HttpClient); 10 | 11 | loadStatus(): Observable { 12 | const url = `/api/status`; 13 | return this.http.get(url); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/status.store.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject, Injectable, signal } from '@angular/core'; 2 | 3 | import { initStatus } from '../model/status'; 4 | 5 | import { StatusService } from './status.service'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class StatusStore { 9 | private statusService = inject(StatusService); 10 | 11 | private status = signal(initStatus); 12 | readonly commits = computed(() => this.status().commits); 13 | 14 | load(): void { 15 | this.statusService.loadStatus().subscribe((status) => { 16 | this.status.set(status); 17 | }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/src/app/data/team-alignment.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import { Observable } from 'rxjs'; 4 | 5 | import { Limits } from '../model/limits'; 6 | import { TeamAlignmentResult } from '../model/team-alignment-result'; 7 | 8 | @Injectable({ providedIn: 'root' }) 9 | export class TeamAlignmentService { 10 | private http = inject(HttpClient); 11 | 12 | load(byUser: boolean, limits: Limits): Observable { 13 | const params = { byUser, ...limits }; 14 | return this.http.get('/api/team-alignment', { 15 | params, 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/coupling/coupling.component.css: -------------------------------------------------------------------------------- 1 | body, 2 | html { 3 | margin: 0; 4 | padding: 0; 5 | width: 100%; 6 | height: 100%; 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | } 11 | 12 | svg { 13 | width: 100%; 14 | height: 100%; 15 | } 16 | 17 | .qtip-bootstrap { 18 | max-width: 700px; 19 | } 20 | 21 | .tooltip { 22 | position: absolute; 23 | text-align: center; 24 | padding: 8px; 25 | font: 12px sans-serif; 26 | background: lightsteelblue; 27 | border: 1px solid #ddd; 28 | border-radius: 8px; 29 | pointer-events: none; 30 | opacity: 0; 31 | width: auto; 32 | max-width: 700px; 33 | } 34 | 35 | #network { 36 | height: 100%; 37 | margin: auto; 38 | cursor: pointer; 39 | } 40 | 41 | #cy { 42 | width: 100%; 43 | height: 100vh; 44 | display: block; 45 | } 46 | 47 | .filter { 48 | display: flex; 49 | align-items: baseline; 50 | max-width: 350px; 51 | padding-top: 20px; 52 | } 53 | 54 | .mr30 { 55 | margin-right: 30px; 56 | } 57 | 58 | .connections { 59 | width: 150px; 60 | } 61 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/coupling/coupling.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Group by folder 9 | 10 | 11 | Min. Connections 12 | 19 | 20 | 21 | 26 | help_outline 27 | 28 |
29 | @if (type() === 'changes') { 30 | 35 | 36 | } 37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/coupling/coupling.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject, input } from '@angular/core'; 2 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatCheckboxModule } from '@angular/material/checkbox'; 5 | import { MatFormFieldModule } from '@angular/material/form-field'; 6 | import { MatIconModule } from '@angular/material/icon'; 7 | import { MatInputModule } from '@angular/material/input'; 8 | import { MatTooltipModule } from '@angular/material/tooltip'; 9 | import { combineLatest, startWith } from 'rxjs'; 10 | 11 | import { LimitsStore } from '../../data/limits.store'; 12 | import { StatusStore } from '../../data/status.store'; 13 | import { CouplingResult } from '../../model/coupling-result'; 14 | import { GraphType } from '../../model/graph-type'; 15 | import { Limits } from '../../model/limits'; 16 | import { Graph, CouplingNodeDefinition } from '../../ui/graph/graph'; 17 | import { GraphComponent } from '../../ui/graph/graph.component'; 18 | import { LimitsComponent } from '../../ui/limits/limits.component'; 19 | import { debounceTimeSkipFirst } from '../../utils/debounce'; 20 | import { EventService } from '../../utils/event.service'; 21 | 22 | import { CouplingFilter, CouplingStore } from './coupling.store'; 23 | import { createGroups, createNodes, createEdges } from './graph.adapter'; 24 | 25 | const STRUCTURE_TIP = 26 | 'Select the modules in the tree on the left to visualize them and the depencencies of their files.'; 27 | const COUPLING_TIP = 28 | 'Change Coupling shows which files and modules have been changed together, indicating a non-obvious type of coupling.'; 29 | 30 | @Component({ 31 | selector: 'app-coupling', 32 | standalone: true, 33 | imports: [ 34 | MatCheckboxModule, 35 | FormsModule, 36 | MatFormFieldModule, 37 | MatInputModule, 38 | LimitsComponent, 39 | MatIconModule, 40 | MatTooltipModule, 41 | GraphComponent, 42 | ], 43 | templateUrl: './coupling.component.html', 44 | styleUrl: './coupling.component.css', 45 | }) 46 | export class CouplingComponent { 47 | private statusStore = inject(StatusStore); 48 | private limitsStore = inject(LimitsStore); 49 | private couplingStore = inject(CouplingStore); 50 | 51 | private eventService = inject(EventService); 52 | 53 | type = input('structure'); 54 | 55 | totalCommits = this.statusStore.commits; 56 | limits = this.limitsStore.limits; 57 | 58 | groupByFolder = this.couplingStore.filter.groupByFolder; 59 | minConnections = this.couplingStore.filter.minConnections; 60 | 61 | couplingResult = this.couplingStore.couplingResult; 62 | graph = computed(() => this.toGraph(this.couplingResult())); 63 | 64 | toolTip = computed(() => 65 | this.type() === 'structure' ? STRUCTURE_TIP : COUPLING_TIP 66 | ); 67 | 68 | loadOptions$ = combineLatest({ 69 | limits: toObservable(this.limits).pipe(debounceTimeSkipFirst(300)), 70 | filterChanged: this.eventService.filterChanged.pipe(startWith(null)), 71 | type: toObservable(this.type), 72 | }).pipe(takeUntilDestroyed()); 73 | 74 | constructor() { 75 | this.couplingStore.rxLoad(this.loadOptions$); 76 | } 77 | 78 | updateFilter(filter: Partial) { 79 | this.couplingStore.updateFilter(filter); 80 | } 81 | 82 | updateLimits(limits: Limits): void { 83 | this.limitsStore.updateLimits(limits); 84 | } 85 | 86 | toGraph(result: CouplingResult): Graph { 87 | result.matrix = clearSelfLinks(result.matrix); 88 | 89 | const groupNodes: CouplingNodeDefinition[] = this.groupByFolder() 90 | ? createGroups(result.dimensions) 91 | : []; 92 | 93 | const leafNodes = createNodes(result, groupNodes, this.type()); 94 | const edges = createEdges(result, this.type(), this.minConnections()); 95 | const directed = this.type() === 'structure'; 96 | 97 | const graph: Graph = { 98 | nodes: [...groupNodes, ...leafNodes], 99 | edges, 100 | directed, 101 | groupByFolder: this.groupByFolder(), 102 | }; 103 | 104 | return graph; 105 | } 106 | } 107 | 108 | function clearSelfLinks(matrix: number[][]): number[][] { 109 | for (let i = 0; i < matrix.length; i++) { 110 | matrix[i][i] = 0; 111 | } 112 | return matrix; 113 | } 114 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/coupling/coupling.store.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; 3 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 4 | import { catchError, Observable, of, pipe, switchMap, tap } from 'rxjs'; 5 | 6 | import { CouplingService } from '../../data/coupling.service'; 7 | import { 8 | CouplingResult, 9 | initCouplingResult, 10 | } from '../../model/coupling-result'; 11 | import { GraphType } from '../../model/graph-type'; 12 | import { Limits } from '../../model/limits'; 13 | import { injectShowError } from '../../utils/error-handler'; 14 | 15 | export type CouplingFilter = { 16 | groupByFolder: boolean; 17 | minConnections: number; 18 | }; 19 | 20 | export type LoadOptions = { 21 | type: GraphType; 22 | limits: Limits; 23 | }; 24 | 25 | export const CouplingStore = signalStore( 26 | { providedIn: 'root' }, 27 | withState({ 28 | filter: { 29 | groupByFolder: false, 30 | minConnections: 1, 31 | } as CouplingFilter, 32 | couplingResult: initCouplingResult, 33 | }), 34 | withMethods( 35 | ( 36 | _store, 37 | couplingService = inject(CouplingService), 38 | showError = injectShowError() 39 | ) => ({ 40 | _loadCoupling(options: LoadOptions): Observable { 41 | return couplingService.load(options.type, options.limits).pipe( 42 | catchError((err) => { 43 | showError(err); 44 | return of(initCouplingResult); 45 | }) 46 | ); 47 | }, 48 | }) 49 | ), 50 | withMethods((store) => ({ 51 | updateFilter(filter: Partial) { 52 | patchState(store, (state) => ({ 53 | filter: { 54 | ...state.filter, 55 | ...filter, 56 | }, 57 | })); 58 | }, 59 | rxLoad: rxMethod( 60 | pipe( 61 | switchMap((options) => store._loadCoupling(options)), 62 | tap((couplingResult) => patchState(store, { couplingResult })) 63 | ) 64 | ), 65 | })) 66 | ); 67 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/coupling/graph.adapter.ts: -------------------------------------------------------------------------------- 1 | import { EdgeDefinition } from 'cytoscape'; 2 | 3 | import { CouplingResult } from '../../model/coupling-result'; 4 | import { GraphType } from '../../model/graph-type'; 5 | import { CouplingNodeDefinition } from '../../ui/graph/graph'; 6 | 7 | export function createEdges( 8 | result: CouplingResult, 9 | type: GraphType, 10 | minConnections: number 11 | ): EdgeDefinition[] { 12 | const edges: EdgeDefinition[] = []; 13 | const delimiter = type === 'structure' ? '→' : '↔'; 14 | for (let i = 0; i < result.matrix.length; i++) { 15 | for (let j = 0; j < result.matrix.length; j++) { 16 | if (result.matrix[i][j] >= minConnections) { 17 | edges.push({ 18 | data: { 19 | source: '' + i, 20 | target: '' + j, 21 | weight: result.matrix[i][j], 22 | tooltip: `${result.dimensions[i] 23 | .split('/') 24 | .at(-1)} ${delimiter} ${result.dimensions[j] 25 | .split('/') 26 | .at(-1)}

${result.matrix[i][j]} connections`, 27 | }, 28 | }); 29 | } 30 | } 31 | } 32 | return edges; 33 | } 34 | 35 | export function createGroups(dimensions: string[]): CouplingNodeDefinition[] { 36 | const groupNodes: CouplingNodeDefinition[] = []; 37 | const groups = findGroups(dimensions); 38 | 39 | groups.sort(); 40 | 41 | for (let i = 0; i < groups.length; i++) { 42 | const label = groups[i]; 43 | 44 | const node: CouplingNodeDefinition = { 45 | data: { 46 | id: 'G' + i, 47 | label: label.split('/').at(-1) || '', 48 | tooltip: label, 49 | dimension: label, 50 | }, 51 | classes: 'group', 52 | }; 53 | 54 | const parent = findParent(groupNodes, label); 55 | 56 | if (parent) { 57 | node.data.parent = parent.data.id; 58 | } 59 | 60 | groupNodes.push(node); 61 | } 62 | return groupNodes; 63 | } 64 | 65 | export function createNodes( 66 | result: CouplingResult, 67 | groups: CouplingNodeDefinition[], 68 | type: GraphType 69 | ): CouplingNodeDefinition[] { 70 | const nodes: CouplingNodeDefinition[] = []; 71 | 72 | for (let i = 0; i < result.dimensions.length; i++) { 73 | const label = result.dimensions[i]; 74 | 75 | const soc = result.sumOfCoupling ? result.sumOfCoupling[i] : -1; 76 | const relSoc = soc !== null ? soc / result.fileCount[i] : -1; 77 | 78 | const node: CouplingNodeDefinition = { 79 | data: { 80 | id: '' + i, 81 | label: label.split('/').at(-1) || '', 82 | tooltip: 83 | type === 'structure' 84 | ? `${label} 85 |

${result.fileCount[i]} source files 86 |
Cohesion: ${result.cohesion[i]}% 87 |
Outgoing Deps: ${sumRow(result.matrix, i)} 88 |
Incoming Deps: ${sumCol(result.matrix, i)} 89 | ` 90 | : `${label} 91 |

${result.fileCount[i]} commits 92 |
Sum of Coupling (SoC): ${soc} 93 |
SoC per Commit: ${Math.round(relSoc * 100)}% 94 | `, 95 | dimension: label, 96 | }, 97 | }; 98 | 99 | const parent = findParent(groups, label); 100 | 101 | if (parent) { 102 | node.data.parent = parent.data.id; 103 | } 104 | 105 | nodes.push(node); 106 | } 107 | return nodes; 108 | } 109 | 110 | function sumRow(matrix: number[][], nodeIndex: number): number { 111 | let sum = 0; 112 | for (let i = 0; i < matrix.length; i++) { 113 | if (i !== nodeIndex) { 114 | sum += matrix[nodeIndex][i]; 115 | } 116 | } 117 | return sum; 118 | } 119 | 120 | function sumCol(matrix: number[][], nodeIndex: number): number { 121 | let sum = 0; 122 | for (let i = 0; i < matrix.length; i++) { 123 | if (i !== nodeIndex) { 124 | sum += matrix[i][nodeIndex]; 125 | } 126 | } 127 | return sum; 128 | } 129 | 130 | export function findParent(groups: CouplingNodeDefinition[], label: string) { 131 | let parent = null; 132 | 133 | const candParents = groups.filter((cp) => 134 | label.startsWith(cp.data.dimension + '/') 135 | ); 136 | if (candParents.length > 0) { 137 | parent = candParents.reduce( 138 | (prev, curr) => 139 | curr.data.dimension.length > prev.data.dimension.length ? curr : prev, 140 | candParents[0] 141 | ); 142 | } 143 | return parent; 144 | } 145 | 146 | function findGroups(labels: string[]): string[] { 147 | const groups = new Set(); 148 | for (const label of labels) { 149 | const parts = label.split('/'); 150 | const group = parts.slice(0, parts.length - 1).join('/'); 151 | groups.add(group); 152 | } 153 | return Array.from(groups); 154 | } 155 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot-adapter.ts: -------------------------------------------------------------------------------- 1 | import { ChartEvent, InteractionItem } from 'chart.js'; 2 | 3 | import { AggregatedHotspot } from '../../model/hotspot-result'; 4 | import { TreeMapChartConfig } from '../../ui/treemap/treemap.component'; 5 | import { lastSegments } from '../../utils/segments'; 6 | 7 | export type ScoreType = 'hotspot' | 'warning' | 'fine'; 8 | export type AggregatedHotspotVM = AggregatedHotspot & { 9 | type: ScoreType; 10 | displayParent: string; 11 | }; 12 | 13 | export function toTreeMapConfig( 14 | aggregated: AggregatedHotspot[] 15 | ): TreeMapChartConfig { 16 | const values = aggregated 17 | .map((a) => ({ 18 | ...a, 19 | displayParent: lastSegments(a.parent, 1), 20 | })) 21 | .flatMap((v) => [ 22 | { ...v, count: v.countHotspot, type: 'hotspot' }, 23 | { ...v, count: v.countWarning, type: 'warning' }, 24 | { ...v, count: v.countOk, type: 'fine' }, 25 | ]) as AggregatedHotspotVM[]; 26 | 27 | const options = { 28 | responsive: true, 29 | maintainAspectRatio: false, 30 | onHover: (event: ChartEvent, elements: InteractionItem[]) => { 31 | const chartElement = event.native?.target as HTMLCanvasElement; 32 | if (elements.length > 2) { 33 | chartElement.style.cursor = 'pointer'; 34 | } else { 35 | chartElement.style.cursor = 'default'; 36 | } 37 | }, 38 | plugins: { 39 | title: { 40 | display: true, 41 | text: 'Hotspots', 42 | }, 43 | legend: { 44 | display: false, 45 | }, 46 | tooltip: { 47 | callbacks: { 48 | title() { 49 | return 'File Count'; 50 | }, 51 | }, 52 | }, 53 | }, 54 | }; 55 | 56 | const config: TreeMapChartConfig = { 57 | type: 'treemap', 58 | data: { 59 | datasets: [ 60 | { 61 | data: values, 62 | key: 'count', 63 | groups: ['displayParent', 'module', 'type'], 64 | spacing: 1, 65 | borderWidth: 0.5, 66 | borderColor: '#EFEFEF', 67 | backgroundColor: (ctx) => { 68 | if (typeof ctx.raw?.l !== 'undefined' && ctx.raw?.l < 2) { 69 | return '#EFEFEF'; 70 | } 71 | return getScoreTypeColor(ctx.raw?.g as ScoreType); 72 | }, 73 | captions: { 74 | align: 'center', 75 | display: true, 76 | color: 'black', 77 | font: { 78 | size: 14, 79 | }, 80 | hoverFont: { 81 | size: 16, 82 | weight: 'bold', 83 | }, 84 | padding: 5, 85 | }, 86 | labels: { 87 | display: false, 88 | overflow: 'hidden', 89 | }, 90 | }, 91 | ], 92 | }, 93 | options: options, 94 | }; 95 | 96 | return config; 97 | } 98 | 99 | export function getScoreTypeColor(scoreType: ScoreType) { 100 | switch (scoreType) { 101 | case 'hotspot': 102 | return '#E74C3C'; 103 | case 'warning': 104 | return '#F1C40F'; 105 | case 'fine': 106 | return '#2ECC71'; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.css: -------------------------------------------------------------------------------- 1 | .title { 2 | margin-left: 15px; 3 | margin-top: 10px; 4 | } 5 | 6 | .copy-icon { 7 | font-size: 16px; 8 | padding: 0px; 9 | margin: 0px; 10 | line-height: 1; 11 | vertical-align: middle; 12 | } 13 | 14 | .copy-link { 15 | cursor: pointer; 16 | } 17 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.html: -------------------------------------------------------------------------------- 1 |

2 |
6 | {{ module() }} 7 |
8 |

9 | 10 |
11 |
12 | @if (loadingHotspots()) { Determining Hotspots ... 13 | 14 | } @else if (hotspots().length > 0) { 15 |
16 | 17 | 18 | 19 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 | 35 | 38 | 41 | 42 | 43 | 44 | 45 | 48 | 49 | 50 | 51 | 52 |
Module 20 | 21 | content_copy 22 | {{ element.fileName }} 23 | 24 | Commits 30 | {{ element.commits }} 31 | 36 | Complexity 37 | 39 | {{ element.complexity }} 40 | Score 46 | {{ element.score }} 47 |
53 |
54 | } 55 |
56 |
57 | 58 | 59 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot-detail/hotspot-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { Clipboard } from '@angular/cdk/clipboard'; 2 | import { CommonModule } from '@angular/common'; 3 | import { 4 | Component, 5 | computed, 6 | effect, 7 | inject, 8 | untracked, 9 | viewChild, 10 | } from '@angular/core'; 11 | import { MatDialogModule } from '@angular/material/dialog'; 12 | import { MatIconModule } from '@angular/material/icon'; 13 | import { MatPaginator } from '@angular/material/paginator'; 14 | import { MatProgressBar } from '@angular/material/progress-bar'; 15 | import { MatSnackBar } from '@angular/material/snack-bar'; 16 | import { MatSortModule } from '@angular/material/sort'; 17 | import { MatTableDataSource, MatTableModule } from '@angular/material/table'; 18 | 19 | import { FlatHotspot } from '../../../model/hotspot-result'; 20 | import { getScoreTypeColor } from '../hotspot-adapter'; 21 | import { HotspotStore } from '../hotspot.store'; 22 | 23 | @Component({ 24 | selector: 'app-hotspot-detail', 25 | standalone: true, 26 | imports: [ 27 | CommonModule, 28 | MatTableModule, 29 | MatSortModule, 30 | MatPaginator, 31 | MatProgressBar, 32 | MatDialogModule, 33 | MatIconModule, 34 | ], 35 | templateUrl: './hotspot-detail.component.html', 36 | styleUrl: './hotspot-detail.component.css', 37 | }) 38 | export class HotspotDetailComponent { 39 | private hotspotStore = inject(HotspotStore); 40 | 41 | private clipboard = inject(Clipboard); 42 | private snackBar = inject(MatSnackBar); 43 | 44 | module = this.hotspotStore.filter.module; 45 | color = computed(() => getScoreTypeColor(this.hotspotStore.scoreType())); 46 | 47 | paginator = viewChild(MatPaginator); 48 | detailDataSource = new MatTableDataSource(); 49 | detailColumns = ['fileName', 'commits', 'complexity', 'score']; 50 | 51 | loadingAggregated = this.hotspotStore.loadingAggregated; 52 | loadingHotspots = this.hotspotStore.loadingHotspots; 53 | 54 | aggregatedResult = this.hotspotStore.aggregatedResult; 55 | hotspots = this.hotspotStore.hotspotsInRange; 56 | 57 | formattedHotspots = computed(() => 58 | formatHotspots( 59 | this.hotspots(), 60 | untracked(() => this.hotspotStore.filter.module()) 61 | ) 62 | ); 63 | 64 | constructor() { 65 | effect(() => { 66 | const hotspots = this.formattedHotspots(); 67 | this.detailDataSource.data = hotspots; 68 | }); 69 | 70 | effect(() => { 71 | const paginator = this.paginator(); 72 | if (paginator) { 73 | this.detailDataSource.paginator = paginator; 74 | } 75 | }); 76 | } 77 | 78 | copy(fileName: string) { 79 | if (this.clipboard.copy(fileName)) { 80 | this.snackBar.open('Filename copied to clipboard', 'Ok', { 81 | duration: 3000, 82 | }); 83 | } else { 84 | console.log('Error writing to clipboard'); 85 | this.snackBar.open( 86 | 'Writing the filename to the clipboard did not work', 87 | 'Ok' 88 | ); 89 | } 90 | } 91 | } 92 | 93 | function formatHotspots( 94 | hotspot: FlatHotspot[], 95 | selectedModule: string 96 | ): FlatHotspot[] { 97 | return hotspot.map((hs) => ({ 98 | ...hs, 99 | fileName: trimSegments(hs.fileName, selectedModule), 100 | })); 101 | } 102 | 103 | function trimSegments(fileName: string, prefix: string): string { 104 | if (fileName.startsWith(prefix)) { 105 | return fileName.substring(prefix.length + 1); 106 | } 107 | return fileName; 108 | } 109 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot.component.css: -------------------------------------------------------------------------------- 1 | /* Standardbreite für kleinere Bildschirme (z.B. Mobilgeräte) */ 2 | .aggregated { 3 | width: 100%; 4 | } 5 | 6 | .detail { 7 | width: 100%; 8 | } 9 | 10 | .p10 { 11 | padding: 10px; 12 | } 13 | 14 | .metric-cell { 15 | width: 100px; 16 | } 17 | 18 | /* Anpassung für größere Bildschirme (z.B. Laptops) */ 19 | @media (min-width: 1024px) { 20 | .aggregated { 21 | width: 30%; 22 | float: left; 23 | } 24 | 25 | .detail { 26 | width: 70%; 27 | float: right; 28 | } 29 | } 30 | 31 | .selected { 32 | background-color: rgba( 33 | 63, 34 | 81, 35 | 181, 36 | 0.1 37 | ) !important; /* Ein leicht transparentes Blau */ 38 | color: #3f51b5 !important; /* Das gleiche Blau wie in der Leiste */ 39 | font-weight: bold; /* Betont den Text */ 40 | border-left: 4px solid #3f51b5; /* Optional: ein markanter blauer Rand auf der linken Seite */ 41 | } 42 | 43 | .aggregated td { 44 | cursor: pointer !important; 45 | } 46 | 47 | .forensic-filter mat-form-field { 48 | margin-right: 20px; 49 | } 50 | 51 | .metric { 52 | width: 250px; 53 | } 54 | 55 | .min-score { 56 | width: 120px; 57 | } 58 | 59 | .docu-link { 60 | margin-right: 15px; 61 | margin-left: 0px; 62 | } 63 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 9 | Filter 10 | 11 | 12 | Tolerance 13 | 14 | 15 | 16 | 17 | {{ minScore().value() }} % 20 | 21 | 22 | Complexity Metric 23 | 24 | @for (option of metricOptions; track option.id) { 25 | {{ option.label }} 26 | } 27 | 28 | 29 | 30 | 35 | help_outline 36 | 37 |
38 | 39 | 44 | 45 |
46 | 47 | 51 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject } from '@angular/core'; 2 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatDialog, MatDialogModule } from '@angular/material/dialog'; 6 | import { MatFormFieldModule } from '@angular/material/form-field'; 7 | import { MatIconModule } from '@angular/material/icon'; 8 | import { MatInputModule } from '@angular/material/input'; 9 | import { MatPaginatorModule } from '@angular/material/paginator'; 10 | import { MatProgressBarModule } from '@angular/material/progress-bar'; 11 | import { MatSelectModule } from '@angular/material/select'; 12 | import { MatSliderModule } from '@angular/material/slider'; 13 | import { MatSortModule } from '@angular/material/sort'; 14 | import { MatTableModule } from '@angular/material/table'; 15 | import { MatTooltipModule } from '@angular/material/tooltip'; 16 | import { combineLatest, startWith } from 'rxjs'; 17 | 18 | import { LimitsStore } from '../../data/limits.store'; 19 | import { StatusStore } from '../../data/status.store'; 20 | import { 21 | AggregatedHotspot, 22 | ComplexityMetric, 23 | } from '../../model/hotspot-result'; 24 | import { Limits } from '../../model/limits'; 25 | import { LimitsComponent } from '../../ui/limits/limits.component'; 26 | import { 27 | TreeMapComponent, 28 | TreeMapEvent, 29 | } from '../../ui/treemap/treemap.component'; 30 | import { debounceTimeSkipFirst } from '../../utils/debounce'; 31 | import { EventService } from '../../utils/event.service'; 32 | import { lastSegments } from '../../utils/segments'; 33 | import { mirror } from '../../utils/signal-helpers'; 34 | 35 | import { 36 | AggregatedHotspotVM, 37 | ScoreType, 38 | toTreeMapConfig, 39 | } from './hotspot-adapter'; 40 | import { HotspotDetailComponent } from './hotspot-detail/hotspot-detail.component'; 41 | import { HotspotStore } from './hotspot.store'; 42 | 43 | interface Option { 44 | id: ComplexityMetric; 45 | label: string; 46 | } 47 | 48 | @Component({ 49 | selector: 'app-hotspot', 50 | standalone: true, 51 | imports: [ 52 | MatTableModule, 53 | MatSortModule, 54 | MatFormFieldModule, 55 | MatInputModule, 56 | MatSelectModule, 57 | MatSliderModule, 58 | MatProgressBarModule, 59 | MatPaginatorModule, 60 | MatDialogModule, 61 | LimitsComponent, 62 | FormsModule, 63 | MatIconModule, 64 | MatTooltipModule, 65 | TreeMapComponent, 66 | MatButtonModule, 67 | ], 68 | templateUrl: './hotspot.component.html', 69 | styleUrl: './hotspot.component.css', 70 | }) 71 | export class HotspotComponent { 72 | private statusStore = inject(StatusStore); 73 | private limitsStore = inject(LimitsStore); 74 | private hotspotStore = inject(HotspotStore); 75 | 76 | private eventService = inject(EventService); 77 | 78 | private dialog = inject(MatDialog); 79 | 80 | columnsToDisplay = ['module', 'count']; 81 | 82 | metricOptions: Option[] = [ 83 | { id: 'Length', label: 'File Length' }, 84 | { id: 'McCabe', label: 'Cyclomatic Complexity' }, 85 | ]; 86 | 87 | totalCommits = this.statusStore.commits; 88 | limits = this.limitsStore.limits; 89 | 90 | minScore = mirror(this.hotspotStore.filter.minScore); 91 | metric = mirror(this.hotspotStore.filter.metric); 92 | 93 | loadingAggregated = this.hotspotStore.loadingAggregated; 94 | aggregatedResult = this.hotspotStore.aggregatedResult; 95 | 96 | formattedAggregated = computed(() => 97 | formatAggregated(this.aggregatedResult().aggregated) 98 | ); 99 | 100 | treeMapConfig = computed(() => toTreeMapConfig(this.formattedAggregated())); 101 | 102 | constructor() { 103 | this.hotspotStore.resetResults(); 104 | 105 | const loadAggregatedEvents = { 106 | filterChanged: this.eventService.filterChanged.pipe(startWith(null)), 107 | minScore: toObservable(this.minScore().value).pipe( 108 | debounceTimeSkipFirst(300) 109 | ), 110 | limits: toObservable(this.limits).pipe(debounceTimeSkipFirst(300)), 111 | metric: toObservable(this.metric().value), 112 | }; 113 | 114 | const loadAggregatedOptions$ = combineLatest(loadAggregatedEvents).pipe( 115 | takeUntilDestroyed() 116 | ); 117 | 118 | this.hotspotStore.rxLoadAggregated(loadAggregatedOptions$); 119 | } 120 | 121 | updateLimits(limits: Limits): void { 122 | this.limitsStore.updateLimits(limits); 123 | } 124 | 125 | selectModule(event: TreeMapEvent): void { 126 | const selected = event.entry as AggregatedHotspotVM; 127 | const selectedModule = [selected.parent, selected.module].join('/'); 128 | const scoreRange = this.getScoreRange(selected); 129 | 130 | this.hotspotStore.rxLoadHotspots({ 131 | limits: this.limits(), 132 | metric: this.metric().value(), 133 | selectedModule, 134 | scoreRange, 135 | scoreType: selected.type, 136 | }); 137 | 138 | this.dialog.open(HotspotDetailComponent, { 139 | width: '95%', 140 | height: '700px', 141 | }); 142 | } 143 | 144 | private getScoreRange(selected: AggregatedHotspotVM) { 145 | const range = this.getScoreBoundaries(); 146 | const index = getScoreIndex(selected.type); 147 | 148 | const scoreRange = { 149 | from: range[index], 150 | to: range[index + 1], 151 | }; 152 | return scoreRange; 153 | } 154 | 155 | private getScoreBoundaries() { 156 | const result = this.aggregatedResult(); 157 | const range = [ 158 | 0, 159 | result.warningBoundary, 160 | result.hotspotBoundary, 161 | result.maxScore + 1, 162 | ]; 163 | return range; 164 | } 165 | } 166 | 167 | function getScoreIndex(type: ScoreType) { 168 | let index = 0; 169 | switch (type) { 170 | case 'fine': 171 | index = 0; 172 | break; 173 | case 'warning': 174 | index = 1; 175 | break; 176 | case 'hotspot': 177 | index = 2; 178 | break; 179 | } 180 | return index; 181 | } 182 | 183 | function formatAggregated(hotspot: AggregatedHotspot[]): AggregatedHotspot[] { 184 | return hotspot.map((hs) => ({ 185 | ...hs, 186 | module: lastSegments(hs.module, 1), 187 | })); 188 | } 189 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/hotspot/hotspot.store.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject } from '@angular/core'; 2 | import { 3 | patchState, 4 | signalStore, 5 | withComputed, 6 | withMethods, 7 | withState, 8 | } from '@ngrx/signals'; 9 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 10 | import { catchError, filter, Observable, of, pipe, switchMap, tap } from 'rxjs'; 11 | 12 | import { HotspotService } from '../../data/hotspot.service'; 13 | import { 14 | AggregatedHotspotsResult, 15 | ComplexityMetric, 16 | HotspotCriteria, 17 | HotspotResult, 18 | initAggregatedHotspotsResult, 19 | initHotspotResult, 20 | } from '../../model/hotspot-result'; 21 | import { Limits } from '../../model/limits'; 22 | import { injectShowError } from '../../utils/error-handler'; 23 | 24 | import { ScoreType } from './hotspot-adapter'; 25 | 26 | export type HotspotFilter = { 27 | minScore: number; 28 | metric: ComplexityMetric; 29 | module: string; 30 | scoreRange: ScoreRange; 31 | }; 32 | 33 | export type LoadAggregateOptions = { 34 | minScore: number; 35 | metric: ComplexityMetric; 36 | limits: Limits; 37 | }; 38 | 39 | export type LoadHotspotOptions = { 40 | selectedModule: string; 41 | scoreRange: ScoreRange; 42 | metric: ComplexityMetric; 43 | limits: Limits; 44 | scoreType: ScoreType; 45 | }; 46 | 47 | export type ScoreRange = { 48 | from: number; 49 | to: number; 50 | }; 51 | 52 | const initScoreRange: ScoreRange = { 53 | from: 0, 54 | to: 0, 55 | }; 56 | 57 | export const HotspotStore = signalStore( 58 | { providedIn: 'root' }, 59 | withState({ 60 | filter: { 61 | minScore: 33, 62 | metric: 'Length', 63 | module: '', 64 | scoreRange: initScoreRange, 65 | } as HotspotFilter, 66 | scoreType: 'fine' as ScoreType, 67 | aggregatedResult: initAggregatedHotspotsResult, 68 | hotspotResult: initHotspotResult, 69 | loadingAggregated: false, 70 | loadingHotspots: false, 71 | }), 72 | withComputed((store) => ({ 73 | hotspotsInRange: computed(() => 74 | store.hotspotResult().hotspots.filter((h) => { 75 | return ( 76 | h.score >= store.filter().scoreRange.from && 77 | h.score < store.filter().scoreRange.to 78 | ); 79 | }) 80 | ), 81 | })), 82 | withMethods( 83 | ( 84 | store, 85 | hotspotService = inject(HotspotService), 86 | showError = injectShowError() 87 | ) => ({ 88 | _loadAggregated( 89 | options: LoadAggregateOptions 90 | ): Observable { 91 | const filter = { 92 | metric: options.metric, 93 | minScore: options.minScore, 94 | }; 95 | 96 | const criteria: HotspotCriteria = { 97 | ...filter, 98 | module: '', 99 | }; 100 | 101 | patchState(store, (state) => ({ 102 | loadingAggregated: true, 103 | filter: { 104 | ...state.filter, 105 | ...filter, 106 | }, 107 | })); 108 | 109 | return hotspotService.loadAggregated(criteria, options.limits).pipe( 110 | tap(() => { 111 | patchState(store, { loadingAggregated: false }); 112 | }), 113 | catchError((err) => { 114 | patchState(store, { loadingAggregated: false }); 115 | showError(err); 116 | return of(initAggregatedHotspotsResult); 117 | }) 118 | ); 119 | }, 120 | 121 | _loadHotspots(options: LoadHotspotOptions): Observable { 122 | const criteria: HotspotCriteria = { 123 | metric: options.metric, 124 | minScore: 0, 125 | module: options.selectedModule, 126 | }; 127 | 128 | patchState(store, (state) => ({ 129 | loadingHotspots: true, 130 | filter: { 131 | ...state.filter, 132 | module: options.selectedModule, 133 | minScore: state.filter.minScore, 134 | scoreRange: options.scoreRange, 135 | }, 136 | scoreType: options.scoreType, 137 | })); 138 | 139 | return hotspotService.load(criteria, options.limits).pipe( 140 | tap(() => { 141 | patchState(store, { loadingHotspots: false }); 142 | }), 143 | catchError((err) => { 144 | patchState(store, { loadingHotspots: false }); 145 | showError(err); 146 | return of(initHotspotResult); 147 | }) 148 | ); 149 | }, 150 | }) 151 | ), 152 | 153 | withMethods((store) => ({ 154 | resetResults(): void { 155 | patchState(store, { 156 | aggregatedResult: initAggregatedHotspotsResult, 157 | hotspotResult: initHotspotResult, 158 | }); 159 | }, 160 | 161 | updateFilter(filter: Partial) { 162 | patchState(store, (state) => ({ 163 | filter: { 164 | ...state.filter, 165 | ...filter, 166 | }, 167 | })); 168 | }, 169 | 170 | rxLoadAggregated: rxMethod( 171 | pipe( 172 | switchMap((combi) => store._loadAggregated(combi)), 173 | tap((aggregatedResult) => patchState(store, { aggregatedResult })) 174 | ) 175 | ), 176 | 177 | rxLoadHotspots: rxMethod( 178 | pipe( 179 | filter((combi) => !!combi.selectedModule), 180 | switchMap((combi) => store._loadHotspots(combi)), 181 | tap((hotspotResult) => patchState(store, { hotspotResult })) 182 | ) 183 | ), 184 | })) 185 | ); 186 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/define-teams/define-teams.component.css: -------------------------------------------------------------------------------- 1 | .teams { 2 | text-wrap: nowrap; 3 | height: 100%; 4 | overflow-x: hidden; 5 | margin-left: 10px; 6 | 7 | .teams__list { 8 | display: inline-block; 9 | overflow: scroll; 10 | text-wrap: nowrap; 11 | max-width: calc(100% - 220px); 12 | height: calc(100% - 10px); 13 | } 14 | 15 | .team { 16 | display: inline-block; 17 | vertical-align: top; 18 | width: 200px; 19 | 20 | h2 { 21 | height: 48px; 22 | } 23 | 24 | span.team__name--fixed { 25 | display: inline-block; 26 | padding-top: 12px; 27 | } 28 | 29 | input.team__name { 30 | width: 175px; 31 | height: 48px; 32 | font-size: 20px; 33 | font-weight: 600; 34 | } 35 | 36 | .team__users { 37 | list-style-type: none; 38 | min-height: 280px; 39 | margin-left: 0px; 40 | padding: 0 10px 32px 10px; 41 | } 42 | } 43 | } 44 | 45 | button + button { 46 | margin-left: 16px; 47 | } 48 | 49 | li, 50 | ul { 51 | list-style-type: none; 52 | margin-left: 0 !important; 53 | padding-left: 0 !important; 54 | cursor: pointer; 55 | } 56 | 57 | .dialog-container { 58 | display: flex; 59 | flex-direction: column; 60 | height: 100%; 61 | } 62 | 63 | .dialog-content { 64 | flex: 1; 65 | overflow: auto; 66 | display: flex; 67 | flex-direction: column; 68 | padding: 16px; 69 | } 70 | 71 | .dialog-actions { 72 | display: flex; 73 | justify-content: flex-end; 74 | padding: 8px 16px; 75 | } 76 | 77 | .h-full { 78 | height: 100%; 79 | } 80 | 81 | .delete-button { 82 | padding: 10px !important; 83 | } 84 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/define-teams/define-teams.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Teams

3 | 4 |
5 |
6 |
9 | 10 |
11 | @for (team of store.teams(); track $index){ 12 | 13 | } 14 |
15 | 16 | 17 |
18 |

19 | @if (editing === team.name){ 20 | 27 | } @else if (team.mayBeEdited) { 28 | {{ team.name }} 29 | 36 | } @else { {{ team.name }} } 37 |

38 | 39 |
    46 | @for (user of team.users; track user) { 47 |
  • {{ user }}
  • 48 | } 49 |
50 |
51 |
52 |
53 |
54 |
55 | 56 | 57 |
58 |
59 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/define-teams/define-teams.component.ts: -------------------------------------------------------------------------------- 1 | import { DialogRef } from '@angular/cdk/dialog'; 2 | import { 3 | CdkDrag, 4 | CdkDragDrop, 5 | CdkDropList, 6 | CdkDropListGroup, 7 | } from '@angular/cdk/drag-drop'; 8 | import { CommonModule } from '@angular/common'; 9 | import { Component, inject, OnInit } from '@angular/core'; 10 | import { MatButtonModule } from '@angular/material/button'; 11 | import { MatDialogModule } from '@angular/material/dialog'; 12 | import { MatIcon } from '@angular/material/icon'; 13 | 14 | import { LimitsStore } from '../../../data/limits.store'; 15 | 16 | import { DefineTeamsStore, Team, User } from './define-teams.store'; 17 | 18 | @Component({ 19 | selector: 'app-define-teams', 20 | standalone: true, 21 | imports: [ 22 | CommonModule, 23 | CdkDropListGroup, 24 | CdkDropList, 25 | CdkDrag, 26 | MatButtonModule, 27 | MatIcon, 28 | MatDialogModule, 29 | ], 30 | templateUrl: './define-teams.component.html', 31 | styleUrl: './define-teams.component.css', 32 | providers: [DefineTeamsStore], 33 | }) 34 | export class DefineTeamsComponent implements OnInit { 35 | protected store = inject(DefineTeamsStore); 36 | protected editing?: string; 37 | private doneAutoFocus = false; 38 | 39 | private limitsStore = inject(LimitsStore); 40 | 41 | dialogRef = inject(DialogRef); 42 | 43 | ngOnInit() { 44 | this.store.load(this.limitsStore.limits); 45 | } 46 | 47 | droppedOnTeam(dragDropEvent: CdkDragDrop) { 48 | const to = dragDropEvent.container.data; 49 | const from = dragDropEvent.previousContainer.data; 50 | const user = dragDropEvent.item.data; 51 | 52 | if (!user || !from?.name || !to?.name || from.name === to.name) { 53 | return; 54 | } 55 | 56 | this.store.removeFromTeam(from, user); 57 | this.store.addToTeam(to, user); 58 | } 59 | 60 | autoFocusOnce(event: MouseEvent) { 61 | if (this.doneAutoFocus) { 62 | return; 63 | } 64 | const inputElm = event.currentTarget as HTMLInputElement; 65 | inputElm.focus(); 66 | this.doneAutoFocus = true; 67 | } 68 | 69 | doRename(name: string, event: FocusEvent) { 70 | const inputElm = event.currentTarget as HTMLInputElement; 71 | const newName = inputElm.value; 72 | this.store.renameTeam(name, newName); 73 | this.editing = undefined; 74 | this.doneAutoFocus = false; 75 | } 76 | 77 | close(): void { 78 | this.dialogRef.close(); 79 | } 80 | 81 | addTeam(): void { 82 | this.store.addTeam(); 83 | 84 | const container = document.querySelector('.teams__list'); 85 | if (container) { 86 | container.scrollTo({ 87 | left: container.scrollWidth, 88 | behavior: 'smooth', // für einen weichen Scrollvorgang 89 | }); 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/define-teams/define-teams.store.ts: -------------------------------------------------------------------------------- 1 | import { computed, inject } from '@angular/core'; 2 | import { 3 | patchState, 4 | signalStore, 5 | withComputed, 6 | withHooks, 7 | withMethods, 8 | withState, 9 | } from '@ngrx/signals'; 10 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 11 | import { pipe, switchMap, tap } from 'rxjs'; 12 | 13 | import { ConfigService } from '../../../data/config.service'; 14 | import { TeamAlignmentService } from '../../../data/team-alignment.service'; 15 | import { initConfig, Teams, Users } from '../../../model/config'; 16 | import { Limits } from '../../../model/limits'; 17 | 18 | const UNKNOWN_TEAM = 'Not Assigned'; 19 | export type User = string; 20 | export type Team = { name: string; users: User[]; mayBeEdited?: boolean }; 21 | 22 | export const DefineTeamsStore = signalStore( 23 | withState({ users: [] as Users, config: initConfig }), 24 | withComputed((store) => ({ 25 | teams: computed(() => 26 | Object.entries(store.config()?.teams || {}).map( 27 | ([name, users]): Team => ({ name, users, mayBeEdited: true }) 28 | ) 29 | ), 30 | })), 31 | withComputed((store) => ({ 32 | unknownTeam: computed(() => { 33 | const usersInTeams = store.teams().flatMap(({ users }) => users); 34 | const users = store 35 | .users() 36 | .filter((user) => !usersInTeams.includes(user)); 37 | return { name: UNKNOWN_TEAM, users }; 38 | }), 39 | })), 40 | withMethods( 41 | ( 42 | store, 43 | taService = inject(TeamAlignmentService), 44 | cfgService = inject(ConfigService) 45 | ) => ({ 46 | load: rxMethod( 47 | pipe( 48 | switchMap((limits) => taService.load(true, limits)), 49 | tap(({ teams }) => patchState(store, { users: teams })), 50 | switchMap(() => cfgService.load()), 51 | tap((config) => (config.teams ||= {})), 52 | tap((config) => patchState(store, { config })) 53 | ) 54 | ), 55 | _save: () => cfgService.save(store.config()).subscribe(), 56 | _changeTeams: (changeTheTeams: (teams: Teams) => Teams | void) => { 57 | const config = store.config(); 58 | let teams = store.config().teams ?? {}; 59 | teams = changeTheTeams(teams) ?? teams; 60 | patchState(store, { config: { ...config, teams: { ...teams } } }); 61 | }, 62 | _existsTeam: (name: string) => store.config()?.teams?.[name], 63 | }) 64 | ), 65 | withMethods((store) => ({ 66 | addTeam: () => { 67 | store._changeTeams((teams) => { 68 | const newName = 'Team #' + (Object.keys(teams).length + 1); 69 | teams[newName] = []; 70 | }); 71 | }, 72 | removeTeam: ({ name }: Team) => { 73 | if (name === UNKNOWN_TEAM) return; 74 | store._changeTeams((teams) => { 75 | delete teams[name]; 76 | }); 77 | }, 78 | renameTeam: (oldName: string, newName: string) => { 79 | if (!oldName || !newName) return; 80 | if (oldName === UNKNOWN_TEAM || newName === UNKNOWN_TEAM) return; 81 | if (!store._existsTeam(oldName) || store._existsTeam(newName)) return; 82 | const rename = (name: string) => (name === oldName ? newName : name); 83 | store._changeTeams((teams) => 84 | Object.fromEntries( 85 | Object.entries(teams).map(([name, users]) => [rename(name), users]) 86 | ) 87 | ); 88 | }, 89 | addToTeam: ({ name }: Team, user: User) => { 90 | if (name === UNKNOWN_TEAM) return; 91 | store._changeTeams((teams) => { 92 | teams[name] = [...teams[name], user]; 93 | }); 94 | }, 95 | removeFromTeam: ({ name }: Team, user: User) => { 96 | if (name === UNKNOWN_TEAM) return; 97 | store._changeTeams((teams) => { 98 | teams[name] = teams[name].filter((u) => u !== user); 99 | }); 100 | }, 101 | })), 102 | 103 | withHooks((store) => ({ onDestroy: () => store._save() })) 104 | ); 105 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/define-teams/index.ts: -------------------------------------------------------------------------------- 1 | export { DefineTeamsComponent } from './define-teams.component'; 2 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/team-alignment-chart-adapter.ts: -------------------------------------------------------------------------------- 1 | import { TeamAlignmentResult } from '../../model/team-alignment-result'; 2 | import { DoughnutChartConfig } from '../../ui/doughnut/doughnut.component'; 3 | import { lastSegments } from '../../utils/segments'; 4 | 5 | export function toAlignmentChartConfigs( 6 | result: TeamAlignmentResult, 7 | colors: string[] 8 | ): DoughnutChartConfig[] { 9 | const chartConfigs: DoughnutChartConfig[] = []; 10 | 11 | const moduleNames = Object.keys(result.modules); 12 | const teams = result.teams; 13 | 14 | for (const moduleName of moduleNames) { 15 | const module = result.modules[moduleName]; 16 | 17 | const label = lastSegments(moduleName, 3); 18 | 19 | const data = teams.map((t) => module.changes[t]); 20 | const sum = data.reduce((acc, curr) => acc + (curr || 0), 0); 21 | 22 | chartConfigs.push({ 23 | type: 'doughnut', 24 | data: { 25 | labels: teams, 26 | datasets: [ 27 | { 28 | data, 29 | borderWidth: 1, 30 | backgroundColor: colors, 31 | }, 32 | ], 33 | }, 34 | options: { 35 | responsive: false, 36 | plugins: { 37 | legend: { 38 | display: false, 39 | }, 40 | title: { 41 | display: true, 42 | text: label, 43 | font: { 44 | size: 16, 45 | }, 46 | }, 47 | tooltip: { 48 | callbacks: { 49 | title: () => moduleName, 50 | label: function (context) { 51 | let label = ' ' + context.label || ''; 52 | if (label) { 53 | label += ': '; 54 | } 55 | if (context.raw !== null) { 56 | const changed = context.raw as number; 57 | label += changed + ' lines changed, '; 58 | label += ((changed / sum) * 100).toFixed(2) + '%\n'; 59 | } 60 | return label; 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | }); 67 | } 68 | return chartConfigs; 69 | } 70 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/team-alignment.component.css: -------------------------------------------------------------------------------- 1 | .docu-link { 2 | margin-left: 15px; 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/team-alignment.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | By User 9 | 10 | 13 | 14 | 19 | help_outline 20 | 21 |
22 | 27 |
28 | 29 |
    30 | @for (team of teams(); track team) { 31 |
  • 32 | {{ team }} 34 |
  • 35 | } 36 |
37 | 38 | @for(config of chartConfigs(); track config) { 39 | 40 | } 41 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/team-alignment.component.ts: -------------------------------------------------------------------------------- 1 | import { Component, computed, inject } from '@angular/core'; 2 | import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatButtonModule } from '@angular/material/button'; 5 | import { MatCheckboxModule } from '@angular/material/checkbox'; 6 | import { MatDialog, MatDialogModule } from '@angular/material/dialog'; 7 | import { MatIconModule } from '@angular/material/icon'; 8 | import { MatTooltipModule } from '@angular/material/tooltip'; 9 | import { interpolateRainbow, quantize } from 'd3'; 10 | import { combineLatest, startWith, Subject } from 'rxjs'; 11 | 12 | import { LimitsStore } from '../../data/limits.store'; 13 | import { StatusStore } from '../../data/status.store'; 14 | import { Limits } from '../../model/limits'; 15 | import { DoughnutComponent } from '../../ui/doughnut/doughnut.component'; 16 | import { LimitsComponent } from '../../ui/limits/limits.component'; 17 | import { debounceTimeSkipFirst } from '../../utils/debounce'; 18 | import { EventService } from '../../utils/event.service'; 19 | 20 | import { DefineTeamsComponent } from './define-teams'; 21 | import { toAlignmentChartConfigs } from './team-alignment-chart-adapter'; 22 | import { TeamAlignmentStore } from './team-alignment.store'; 23 | 24 | @Component({ 25 | selector: 'app-team-alignment', 26 | standalone: true, 27 | imports: [ 28 | LimitsComponent, 29 | MatCheckboxModule, 30 | FormsModule, 31 | MatIconModule, 32 | MatTooltipModule, 33 | MatButtonModule, 34 | DoughnutComponent, 35 | MatDialogModule, 36 | ], 37 | templateUrl: './team-alignment.component.html', 38 | styleUrl: './team-alignment.component.css', 39 | }) 40 | export class TeamAlignmentComponent { 41 | private limitsStore = inject(LimitsStore); 42 | private statusStore = inject(StatusStore); 43 | private taStore = inject(TeamAlignmentStore); 44 | 45 | private eventService = inject(EventService); 46 | 47 | private dialog = inject(MatDialog); 48 | 49 | totalCommits = this.statusStore.commits; 50 | limits = this.limitsStore.limits; 51 | byUser = this.taStore.filter.byUser; 52 | 53 | teamAlignmentResult = this.taStore.result; 54 | 55 | teams = this.taStore.result.teams; 56 | colors = computed(() => this.toColors(this.teams().length)); 57 | 58 | reload = new Subject(); 59 | 60 | loadOptions$ = combineLatest({ 61 | limits: toObservable(this.limits).pipe(debounceTimeSkipFirst(300)), 62 | byUser: toObservable(this.byUser), 63 | filterChanged: this.eventService.filterChanged.pipe(startWith(null)), 64 | reload: this.reload.pipe(startWith(null)), 65 | }).pipe(takeUntilDestroyed()); 66 | 67 | chartConfigs = computed(() => 68 | toAlignmentChartConfigs(this.teamAlignmentResult(), this.colors()) 69 | ); 70 | 71 | constructor() { 72 | this.taStore.rxLoad(this.loadOptions$); 73 | } 74 | 75 | updateLimits(limits: Limits): void { 76 | this.limitsStore.updateLimits(limits); 77 | } 78 | 79 | updateFilter(byUser: boolean): void { 80 | this.taStore.updateFilter(byUser); 81 | } 82 | 83 | private toColors(count: number): string[] { 84 | return quantize(interpolateRainbow, count + 1); 85 | } 86 | 87 | showDefineDialog() { 88 | const hash = location.hash ?? ''; 89 | if (hash.includes('define-teams')) { 90 | this.dialog.open(DefineTeamsComponent, { 91 | width: '80%', 92 | height: '80%', 93 | }); 94 | } else { 95 | const a = document.createElement('a'); 96 | a.target = '_blank'; 97 | a.href = 98 | 'https://github.com/angular-architects/detective?tab=readme-ov-file#defining-teams'; 99 | a.click(); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /apps/frontend/src/app/features/team-alignment/team-alignment.store.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { patchState, signalStore, withMethods, withState } from '@ngrx/signals'; 3 | import { rxMethod } from '@ngrx/signals/rxjs-interop'; 4 | import { catchError, Observable, of, pipe, switchMap, tap } from 'rxjs'; 5 | 6 | import { TeamAlignmentService } from '../../data/team-alignment.service'; 7 | import { Limits } from '../../model/limits'; 8 | import { 9 | initTeamAlignmentResult, 10 | TeamAlignmentResult, 11 | } from '../../model/team-alignment-result'; 12 | import { injectShowError } from '../../utils/error-handler'; 13 | 14 | export type LoadOptions = { 15 | limits: Limits; 16 | byUser: boolean; 17 | }; 18 | 19 | export const TeamAlignmentStore = signalStore( 20 | { providedIn: 'root' }, 21 | withState({ 22 | filter: { 23 | byUser: false, 24 | }, 25 | result: initTeamAlignmentResult, 26 | }), 27 | withMethods( 28 | ( 29 | _store, 30 | taService = inject(TeamAlignmentService), 31 | showError = injectShowError() 32 | ) => ({ 33 | _loadTeamAlignment( 34 | options: LoadOptions 35 | ): Observable { 36 | return taService.load(options.byUser, options.limits).pipe( 37 | catchError((err) => { 38 | showError(err); 39 | return of(initTeamAlignmentResult); 40 | }) 41 | ); 42 | }, 43 | }) 44 | ), 45 | withMethods((store) => ({ 46 | updateFilter(byUser: boolean) { 47 | patchState(store, { filter: { byUser } }); 48 | }, 49 | rxLoad: rxMethod( 50 | pipe( 51 | switchMap((combi) => store._loadTeamAlignment(combi)), 52 | tap((result) => patchState(store, { result })) 53 | ) 54 | ), 55 | })) 56 | ); 57 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/cache-status.ts: -------------------------------------------------------------------------------- 1 | export interface CacheStatus { 2 | isStale: boolean; 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/config.ts: -------------------------------------------------------------------------------- 1 | export type Users = string[]; 2 | export type Aliases = { [alias: string]: string }; 3 | export type Teams = { [team: string]: Users }; 4 | 5 | export interface Config { 6 | groups?: string[]; 7 | scopes: string[]; 8 | focus?: string; 9 | aliases?: Aliases; 10 | teams?: Teams; 11 | } 12 | 13 | export const initConfig: Config = { 14 | scopes: [], 15 | }; 16 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/coupling-result.ts: -------------------------------------------------------------------------------- 1 | export interface CouplingResult { 2 | dimensions: string[]; 3 | fileCount: number[]; 4 | cohesion: number[]; 5 | matrix: number[][]; 6 | groups: string[]; 7 | sumOfCoupling: []; 8 | } 9 | 10 | export const initCouplingResult: CouplingResult = { 11 | dimensions: [], 12 | fileCount: [], 13 | cohesion: [], 14 | matrix: [[]], 15 | groups: [], 16 | sumOfCoupling: [], 17 | }; 18 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/folder.ts: -------------------------------------------------------------------------------- 1 | export interface Folder { 2 | name: string; 3 | path: string; 4 | folders: Folder[]; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/graph-type.ts: -------------------------------------------------------------------------------- 1 | export type GraphType = 'structure' | 'changes'; 2 | 3 | export interface GraphTypeData { 4 | type: GraphType; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/hotspot-result.ts: -------------------------------------------------------------------------------- 1 | export interface Hotspot { 2 | commits: number; 3 | changedLines: number; 4 | complexity: number; 5 | score: number; 6 | } 7 | 8 | export interface FlatHotspot { 9 | fileName: string; 10 | commits: number; 11 | changedLines: number; 12 | complexity: number; 13 | score: number; 14 | } 15 | 16 | export interface HotspotResult { 17 | hotspots: FlatHotspot[]; 18 | } 19 | 20 | export const initHotspotResult: HotspotResult = { 21 | hotspots: [], 22 | }; 23 | 24 | export type ComplexityMetric = 'McCabe' | 'Length'; 25 | 26 | export interface HotspotCriteria { 27 | module: string; 28 | metric: ComplexityMetric; 29 | minScore: number; 30 | } 31 | 32 | export interface AggregatedHotspot { 33 | parent: string; 34 | module: string; 35 | count: number; 36 | countWarning: number; 37 | countHotspot: number; 38 | countOk: number; 39 | } 40 | 41 | export interface AggregatedHotspotsResult { 42 | aggregated: AggregatedHotspot[]; 43 | maxScore: number; 44 | minScore: number; 45 | warningBoundary: number; 46 | hotspotBoundary: number; 47 | } 48 | 49 | export const initAggregatedHotspotsResult: AggregatedHotspotsResult = { 50 | aggregated: [], 51 | maxScore: 0, 52 | minScore: 0, 53 | warningBoundary: 0, 54 | hotspotBoundary: 0, 55 | }; 56 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/limits.ts: -------------------------------------------------------------------------------- 1 | export type LimitType = 'COMMITS' | 'MONTHS'; 2 | 3 | export interface Limits { 4 | limitType: LimitType; 5 | limitCommits: number; 6 | limitMonths: number; 7 | } 8 | 9 | export const initLimits: Limits = { 10 | limitType: 'COMMITS' as LimitType, 11 | limitCommits: 1000, 12 | limitMonths: 0, 13 | }; 14 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/module-info.ts: -------------------------------------------------------------------------------- 1 | export interface ModuleInfo { 2 | fileCount: number[]; 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/status.ts: -------------------------------------------------------------------------------- 1 | export interface Status { 2 | commits: number; 3 | } 4 | 5 | export const initStatus: Status = { 6 | commits: 0, 7 | }; 8 | -------------------------------------------------------------------------------- /apps/frontend/src/app/model/team-alignment-result.ts: -------------------------------------------------------------------------------- 1 | export interface ModuleDetails { 2 | changes: Record; 3 | } 4 | 5 | export interface TeamAlignmentResult { 6 | modules: Record; 7 | teams: string[]; 8 | } 9 | 10 | export const initTeamAlignmentResult: TeamAlignmentResult = { 11 | modules: {}, 12 | teams: [], 13 | }; 14 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/about/about.component.css: -------------------------------------------------------------------------------- 1 | article { 2 | font-size: 18px; 3 | line-height: 24px; 4 | padding: 15px; 5 | } 6 | 7 | article h2 { 8 | margin-top: 40px; 9 | } 10 | 11 | .workshop-img, 12 | .book-img { 13 | height: 300px; 14 | } 15 | 16 | h3 { 17 | font-weight: bold; 18 | } 19 | 20 | .book { 21 | float: left; 22 | margin-right: 50px; 23 | } 24 | 25 | .workshop { 26 | float: left; 27 | } 28 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/about/about.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Detective 1.0.0

3 | 4 |

5 | 2024 by AngularArchitects.io 6 |

7 | 8 |

Credits

9 | 10 |

Detective stand on the shoulders of giants:

11 | 12 | 39 | 40 |

More Information

41 | 42 |

43 | Please find more details in our 44 | README. 48 |

49 |
50 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/about/about.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component } from '@angular/core'; 3 | 4 | @Component({ 5 | selector: 'app-about', 6 | standalone: true, 7 | imports: [CommonModule], 8 | templateUrl: './about.component.html', 9 | styleUrl: './about.component.css', 10 | }) 11 | export class AboutComponent {} 12 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/cache.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { MatDialog, MatDialogRef } from '@angular/material/dialog'; 3 | import { Observable, of, switchMap, tap } from 'rxjs'; 4 | 5 | import { CacheService } from '../data/cache.service'; 6 | import { LoadingComponent } from '../ui/loading/loading.component'; 7 | 8 | export function ensureCache(): Observable { 9 | const cacheService = inject(CacheService); 10 | const dialog = inject(MatDialog); 11 | 12 | let dialogRef: MatDialogRef | null = null; 13 | 14 | return cacheService.loadLogCacheStatus().pipe( 15 | switchMap((status) => { 16 | if (status.isStale) { 17 | dialogRef = dialog.open(LoadingComponent, { 18 | disableClose: true, 19 | }); 20 | return cacheService.updateLogCache(); 21 | } else { 22 | return of(undefined); 23 | } 24 | }), 25 | tap(() => { 26 | if (dialogRef) { 27 | dialogRef.close(); 28 | } 29 | }) 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/filter-tree/filter-tree.component.css: -------------------------------------------------------------------------------- 1 | mat-tree-node { 2 | white-space: nowrap; 3 | overflow: hidden; 4 | text-overflow: ellipsis; 5 | } 6 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/filter-tree/filter-tree.component.html: -------------------------------------------------------------------------------- 1 | 2 | 7 | 8 | {{ node.name }} 14 | 15 | 16 | 17 | 26 |
30 | {{ node.name }} 36 | 37 | @if (hasFocus(node)) { 38 | 42 | } @else { 43 | 47 | } 48 | 52 | 53 |
54 |
55 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/filter-tree/filter-tree.component.ts: -------------------------------------------------------------------------------- 1 | import { CdkMenuModule } from '@angular/cdk/menu'; 2 | import { CdkTree } from '@angular/cdk/tree'; 3 | import { 4 | ChangeDetectorRef, 5 | Component, 6 | inject, 7 | OnInit, 8 | viewChild, 9 | } from '@angular/core'; 10 | import { MatButtonModule } from '@angular/material/button'; 11 | import { 12 | MatCheckboxChange, 13 | MatCheckboxModule, 14 | } from '@angular/material/checkbox'; 15 | import { MatIconModule } from '@angular/material/icon'; 16 | import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; 17 | import { MatTreeModule, MatTreeNestedDataSource } from '@angular/material/tree'; 18 | import { combineLatest, of } from 'rxjs'; 19 | 20 | import { ConfigService } from '../../data/config.service'; 21 | import { FolderService } from '../../data/folder.service'; 22 | import { initConfig } from '../../model/config'; 23 | import { Folder } from '../../model/folder'; 24 | import { EventService } from '../../utils/event.service'; 25 | 26 | const MIN_OPEN_LEVEL = 2; 27 | 28 | @Component({ 29 | selector: 'app-filter-tree', 30 | standalone: true, 31 | imports: [ 32 | MatTreeModule, 33 | MatIconModule, 34 | MatButtonModule, 35 | MatCheckboxModule, 36 | MatMenuModule, 37 | CdkMenuModule, 38 | ], 39 | templateUrl: './filter-tree.component.html', 40 | styleUrl: './filter-tree.component.css', 41 | }) 42 | export class FilterTreeComponent implements OnInit { 43 | private folderService = inject(FolderService); 44 | private configService = inject(ConfigService); 45 | private eventService = inject(EventService); 46 | private cdr = inject(ChangeDetectorRef); 47 | 48 | tree = viewChild.required>(CdkTree); 49 | dataSource = new MatTreeNestedDataSource(); 50 | 51 | config = initConfig; 52 | selected = new Set(); 53 | folders: Folder[] = []; 54 | 55 | childrenAccessor = ({ folders }: Folder) => of(folders); 56 | hasChild = (_: number, { folders }: Folder) => !!folders?.length; 57 | 58 | ngOnInit(): void { 59 | const folders$ = this.folderService.load(); 60 | const config$ = this.configService.load(); 61 | combineLatest({ 62 | folders: folders$, 63 | config: config$, 64 | }).subscribe(({ folders, config }) => { 65 | const focusFolder = this.findFolder(folders, config.focus); 66 | this.dataSource.data = focusFolder ? [focusFolder] : folders; 67 | this.config = config; 68 | this.folders = folders; 69 | this.selected.clear(); 70 | this.config.scopes.forEach((scope) => this.selected.add(scope)); 71 | this.expandChecked(this.dataSource.data); 72 | removeFocus(); 73 | }); 74 | } 75 | 76 | private findFolder(folders: Folder[], focus?: string): Folder | undefined { 77 | if (!focus || !folders?.length) return undefined; 78 | return ( 79 | folders.find((folder) => folder && folder.path === focus) ?? 80 | this.findFolder( 81 | folders.flatMap(({ folders }) => folders), 82 | focus 83 | ) 84 | ); 85 | } 86 | 87 | expandChecked(folders: Folder[], depth = 0): boolean { 88 | let open = depth <= MIN_OPEN_LEVEL; 89 | for (const folder of folders) { 90 | if (this.selected.has(folder.path)) { 91 | open = true; 92 | } 93 | if (folder.folders && this.expandChecked(folder.folders, depth + 1)) { 94 | this.tree().expand(folder); 95 | open = true; 96 | } 97 | } 98 | return open; 99 | } 100 | 101 | noContextMenu(event: MouseEvent) { 102 | event.preventDefault(); 103 | } 104 | 105 | onContextMenu(event: MouseEvent, trigger: MatMenuTrigger) { 106 | event.preventDefault(); 107 | trigger.openMenu(); 108 | document 109 | .querySelector('div.cdk-overlay-backdrop') 110 | ?.addEventListener('mousedown', () => { 111 | trigger.closeMenu(); 112 | }); 113 | removeFocus(); 114 | } 115 | 116 | selectChildren(folder: Folder) { 117 | this.deselectParents(folder); 118 | this.selected.delete(folder.path); 119 | for (const child of folder.folders) { 120 | this.selected.add(child.path); 121 | } 122 | this.tree().expand(folder); 123 | this.updateConfig(); 124 | } 125 | 126 | focusTree(folder?: Folder) { 127 | this.dataSource = new MatTreeNestedDataSource(); 128 | this.dataSource.data = folder ? [{ ...folder }] : this.folders; 129 | this.config.focus = folder?.path; 130 | this.expandChecked(this.dataSource.data); 131 | this.tree().renderNodeChanges(this.dataSource.data); 132 | this.updateConfig(); 133 | } 134 | 135 | isChecked({ path }: Folder): boolean { 136 | return this.selected.has(path); 137 | } 138 | 139 | hasFocus({ path }: Folder): boolean { 140 | return path === this.config.focus; 141 | } 142 | 143 | onCheckChange(folder: Folder, $event: MatCheckboxChange) { 144 | if ($event.checked) { 145 | this.selected.add(folder.path); 146 | } else { 147 | this.selected.delete(folder.path); 148 | } 149 | 150 | this.deselectParents(folder); 151 | this.deselectSubtree(folder.folders); 152 | this.updateConfig(); 153 | } 154 | 155 | private updateConfig() { 156 | this.config.scopes = [...this.selected]; 157 | this.config.groups = this.findParents(); 158 | 159 | this.configService.save(this.config).subscribe(() => { 160 | this.eventService.filterChanged.next(); 161 | }); 162 | } 163 | 164 | private deselectParents(folder: Folder) { 165 | const segments = folder.path.split('/'); 166 | while (segments.length > 0) { 167 | segments.pop(); 168 | this.selected.delete(segments.join('/')); 169 | } 170 | } 171 | 172 | private deselectSubtree(folders: Folder[]) { 173 | for (const folder of folders) { 174 | this.selected.delete(folder.path); 175 | this.deselectSubtree(folder.folders || []); 176 | } 177 | } 178 | 179 | private findParents(): string[] { 180 | const parents: string[] = []; 181 | this._findParents(this.folders, parents); 182 | return parents; 183 | } 184 | 185 | private _findParents(folders = this.folders, parents: string[]): boolean { 186 | let selected = false; 187 | for (const folder of folders) { 188 | if (this.selected.has(folder.path)) { 189 | selected = true; 190 | } else { 191 | const selectedBelow = this._findParents(folder.folders || [], parents); 192 | if (selectedBelow) { 193 | parents.push(folder.path); 194 | selected = true; 195 | } 196 | } 197 | } 198 | return selected; 199 | } 200 | } 201 | 202 | function removeFocus() { 203 | setTimeout(() => { 204 | (document.activeElement as HTMLElement)?.blur(); 205 | }); 206 | } 207 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/nav/nav.component.css: -------------------------------------------------------------------------------- 1 | .sidenav-container { 2 | height: 100%; 3 | } 4 | 5 | /* .sidenav { 6 | width: 350px; 7 | } */ 8 | 9 | .sidenav .mat-toolbar { 10 | background: inherit; 11 | } 12 | 13 | .mat-toolbar.mat-primary { 14 | position: sticky; 15 | top: 0; 16 | z-index: 1; 17 | } 18 | 19 | .active { 20 | background-color: navy; 21 | } 22 | 23 | .app-title { 24 | padding-left: 15px; 25 | margin-right: 20px; 26 | } 27 | 28 | .flex-spacer { 29 | flex: 1 1 auto; 30 | } 31 | 32 | .sidenav-container { 33 | display: flex; 34 | flex-direction: row; 35 | height: 100vh; 36 | } 37 | 38 | mat-sidenav { 39 | flex-shrink: 0; 40 | } 41 | 42 | mat-sidenav-content { 43 | flex-grow: 1; 44 | } 45 | 46 | mat-nav-list { 47 | overflow: hidden; 48 | } 49 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/nav/nav.component.html: -------------------------------------------------------------------------------- 1 | 2 | 11 | Filter 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | @if (isHandset()) { 22 | 30 | } 31 | 32 | Detective 33 | 34 | 37 | 40 | 43 | 50 | 51 | 52 | 53 | 61 | 62 | 63 |
64 | 65 |
66 |
67 |
68 | -------------------------------------------------------------------------------- /apps/frontend/src/app/shell/nav/nav.component.ts: -------------------------------------------------------------------------------- 1 | import { BreakpointObserver } from '@angular/cdk/layout'; 2 | import { AsyncPipe } from '@angular/common'; 3 | import { Component, computed, inject, signal } from '@angular/core'; 4 | import { toSignal } from '@angular/core/rxjs-interop'; 5 | import { MatButtonModule } from '@angular/material/button'; 6 | import { MatIconModule } from '@angular/material/icon'; 7 | import { MatListModule } from '@angular/material/list'; 8 | import { MatSidenavModule } from '@angular/material/sidenav'; 9 | import { MatToolbarModule } from '@angular/material/toolbar'; 10 | import { RouterModule } from '@angular/router'; 11 | import { map, shareReplay } from 'rxjs/operators'; 12 | 13 | import { CouplingComponent } from '../../features/coupling/coupling.component'; 14 | import { ResizerComponent } from '../../ui/resizer/resizer.component'; 15 | import { FilterTreeComponent } from '../filter-tree/filter-tree.component'; 16 | 17 | @Component({ 18 | selector: 'app-nav', 19 | templateUrl: './nav.component.html', 20 | styleUrl: './nav.component.css', 21 | standalone: true, 22 | imports: [ 23 | MatToolbarModule, 24 | MatButtonModule, 25 | MatSidenavModule, 26 | MatListModule, 27 | MatIconModule, 28 | AsyncPipe, 29 | FilterTreeComponent, 30 | CouplingComponent, 31 | RouterModule, 32 | ResizerComponent, 33 | ], 34 | }) 35 | export class NavComponent { 36 | private breakpointObserver = inject(BreakpointObserver); 37 | 38 | isHandset$ = this.breakpointObserver.observe(['(max-width: 1200px)']).pipe( 39 | map((result) => result.matches), 40 | shareReplay() 41 | ); 42 | 43 | isHandset = toSignal(this.isHandset$); 44 | sidenavWidth = signal(350); 45 | 46 | contentMarginLeft = computed(() => 47 | this.isHandset() ? 0 : this.sidenavWidth() 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/frontend/src/app/types.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'cytoscape-cola'; 2 | declare module 'cytoscape-qtip'; 3 | declare module 'cytoscape-dagre'; 4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/doughnut/doughnut.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/apps/frontend/src/app/ui/doughnut/doughnut.component.css -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/doughnut/doughnut.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/doughnut/doughnut.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { 3 | Component, 4 | ElementRef, 5 | input, 6 | OnChanges, 7 | OnDestroy, 8 | SimpleChanges, 9 | viewChild, 10 | } from '@angular/core'; 11 | import { 12 | ArcElement, 13 | Chart, 14 | ChartConfiguration, 15 | DoughnutController, 16 | Legend, 17 | Title, 18 | Tooltip, 19 | } from 'chart.js'; 20 | 21 | Chart.register(ArcElement, Tooltip, Legend, Title, DoughnutController); 22 | 23 | export type DoughnutChartConfig = ChartConfiguration< 24 | 'doughnut', 25 | number[], 26 | string 27 | >; 28 | type DoughnutChart = Chart<'doughnut', number[], string>; 29 | 30 | @Component({ 31 | selector: 'app-doughnut', 32 | standalone: true, 33 | imports: [CommonModule], 34 | templateUrl: './doughnut.component.html', 35 | styleUrl: './doughnut.component.css', 36 | }) 37 | export class DoughnutComponent implements OnChanges, OnDestroy { 38 | canvasRef = viewChild.required>('canvas'); 39 | chartConfig = input.required(); 40 | 41 | private chart: DoughnutChart | undefined; 42 | 43 | ngOnChanges(_changes: SimpleChanges): void { 44 | const canvasRef = this.canvasRef(); 45 | const canvas = canvasRef.nativeElement; 46 | const ctx = canvas.getContext('2d'); 47 | const config = this.chartConfig(); 48 | 49 | if (!ctx) { 50 | throw new Error('2d context not found'); 51 | } 52 | 53 | this.chart = new Chart(ctx, config); 54 | } 55 | 56 | ngOnDestroy(): void { 57 | this.chart?.destroy(); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/graph/graph.component.css: -------------------------------------------------------------------------------- 1 | svg { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | .qtip-bootstrap { 7 | max-width: 700px; 8 | } 9 | 10 | .tooltip { 11 | position: absolute; 12 | text-align: center; 13 | padding: 8px; 14 | font: 12px sans-serif; 15 | background: lightsteelblue; 16 | border: 1px solid #ddd; 17 | border-radius: 8px; 18 | pointer-events: none; 19 | opacity: 0; 20 | width: auto; 21 | max-width: 700px; 22 | } 23 | 24 | #cy { 25 | width: 100%; 26 | height: 100vh; 27 | display: block; 28 | } 29 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/graph/graph.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/graph/graph.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { 3 | Component, 4 | ElementRef, 5 | input, 6 | OnChanges, 7 | SimpleChanges, 8 | viewChild, 9 | } from '@angular/core'; 10 | 11 | import { drawGraph, Graph } from './graph'; 12 | 13 | @Component({ 14 | selector: 'app-graph', 15 | standalone: true, 16 | imports: [CommonModule], 17 | templateUrl: './graph.component.html', 18 | styleUrl: './graph.component.css', 19 | }) 20 | export class GraphComponent implements OnChanges { 21 | graph = input.required(); 22 | containerRef = viewChild.required>('container'); 23 | 24 | ngOnChanges(_changes: SimpleChanges): void { 25 | const containerRef = this.containerRef(); 26 | const container = containerRef.nativeElement; 27 | drawGraph(this.graph(), container); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/graph/graph.ts: -------------------------------------------------------------------------------- 1 | import cytoscape, { 2 | EdgeDefinition, 3 | EdgeSingular, 4 | LayoutOptions, 5 | NodeDefinition, 6 | NodeSingular, 7 | } from 'cytoscape'; 8 | import cola from 'cytoscape-cola'; 9 | import dagre from 'cytoscape-dagre'; 10 | import qtip from 'cytoscape-qtip'; 11 | 12 | cytoscape.use(dagre); 13 | cytoscape.use(cola); 14 | cytoscape.use(qtip); 15 | 16 | export interface CouplingNodeDefinition extends NodeDefinition { 17 | data: { 18 | id: string; 19 | label: string; 20 | parent?: string; 21 | tooltip: string; 22 | dimension: string; 23 | }; 24 | classes?: string; 25 | } 26 | 27 | export interface Graph { 28 | nodes: CouplingNodeDefinition[]; 29 | edges: EdgeDefinition[]; 30 | groupByFolder: boolean; 31 | directed: boolean; 32 | } 33 | 34 | interface Qtip { 35 | qtip(options: unknown): void; 36 | } 37 | 38 | type NodeWithQtip = NodeSingular & Qtip; 39 | type EdgeWithQtip = EdgeSingular & Qtip; 40 | 41 | export function drawGraph(graph: Graph, container: HTMLElement) { 42 | const cy = createGraph(container, graph); 43 | 44 | cy.ready(() => { 45 | adjustNodeWidth(cy); 46 | formatEdges(cy); 47 | }); 48 | 49 | defineToolTipsForNodes(cy); 50 | defineToolTipsForEdges(cy); 51 | centerAllNodes(cy); 52 | } 53 | 54 | function createGraph(container: HTMLElement, graph: Graph): cytoscape.Core { 55 | return cytoscape({ 56 | container, 57 | 58 | layout: { 59 | name: 'dagre', 60 | padding: 30, 61 | nodeSpacing: 40, 62 | nodeSep: 80, 63 | avoidOverlap: true, 64 | flow: { axis: 'x', minSeparation: 50 }, 65 | fit: false, 66 | animate: false, 67 | } as LayoutOptions, 68 | 69 | style: [ 70 | { 71 | selector: 'node', 72 | style: { 73 | shape: 'round-rectangle', 74 | label: 'data(label)', 75 | 'text-valign': 'center', 76 | 'text-halign': 'center', 77 | height: '20px', 78 | width: '100px', 79 | padding: '10px', 80 | 'background-color': '#60a3bc', 81 | 'border-color': '#1e272e', 82 | 'border-width': 1, 83 | color: '#ffffff', 84 | 'font-size': '16px', 85 | 'min-zoomed-font-size': 8, 86 | 'text-wrap': 'wrap', 87 | 'text-max-width': '100px', 88 | } as never, 89 | }, 90 | { 91 | selector: 'edge', 92 | style: { 93 | width: 1, 94 | 'line-color': '#1e272e', 95 | 'target-arrow-color': '#1e272e', 96 | 'target-arrow-shape': graph.directed ? 'triangle' : 'none', 97 | 'curve-style': 'bezier', 98 | }, 99 | }, 100 | { 101 | selector: '.group', 102 | style: { 103 | shape: 'round-rectangle', 104 | 'background-color': 'rgba(255,255,255,0.5)', 105 | 'border-color': '#1e272e', 106 | 'border-width': 1, 107 | padding: '20px', 108 | label: 'data(label)', 109 | 'text-valign': 'top', 110 | 'text-halign': 'center', 111 | 'font-size': '14px', 112 | 'font-weight': 'bold', 113 | color: 'black', 114 | } as never, 115 | }, 116 | ], 117 | elements: { 118 | nodes: graph.nodes, 119 | edges: graph.edges, 120 | }, 121 | zoomingEnabled: true, 122 | userZoomingEnabled: true, 123 | panningEnabled: true, 124 | userPanningEnabled: true, 125 | }); 126 | } 127 | 128 | function defineToolTipsForEdges(cy: cytoscape.Core) { 129 | cy.edges().forEach((edge: EdgeSingular) => { 130 | const tooltip = edge.data('tooltip'); 131 | 132 | if (!tooltip) { 133 | return; 134 | } 135 | 136 | const edgeWithQtip = edge as EdgeWithQtip; 137 | 138 | edgeWithQtip.qtip({ 139 | content: tooltip, 140 | position: { 141 | my: 'top center', 142 | at: 'bottom center', 143 | }, 144 | style: { 145 | classes: 'qtip-bootstrap', 146 | tip: { 147 | corner: true, 148 | mimic: 'center', 149 | width: 10, 150 | height: 10, 151 | }, 152 | 'z-index': 1, 153 | }, 154 | hide: { 155 | event: 'mouseout', 156 | }, 157 | }); 158 | }); 159 | } 160 | 161 | function defineToolTipsForNodes(cy: cytoscape.Core) { 162 | cy.nodes().forEach((node) => { 163 | const tooltip = node.data('tooltip'); 164 | 165 | if (!tooltip) { 166 | return; 167 | } 168 | 169 | node.on('tap', (event) => { 170 | event.stopPropagation(); 171 | }); 172 | 173 | const nodeWithQtip = node as NodeWithQtip; 174 | nodeWithQtip.qtip({ 175 | content: tooltip, 176 | position: { 177 | my: 'top center', 178 | at: 'bottom center', 179 | }, 180 | style: { 181 | classes: 'qtip-bootstrap', 182 | tip: { 183 | corner: true, 184 | mimic: 'center', 185 | width: 10, 186 | height: 10, 187 | }, 188 | 'z-index': 1, 189 | }, 190 | hide: { 191 | event: 'mouseout', 192 | }, 193 | }); 194 | }); 195 | } 196 | 197 | function formatEdges(cy: cytoscape.Core) { 198 | const [min, max] = getMinMaxWeight(cy); 199 | const step = (max - min) / 3; 200 | const border1 = min + step; 201 | const border2 = max - step; 202 | 203 | cy.style() 204 | .selector('edge') 205 | .style({ 206 | width: function (edge: EdgeSingular) { 207 | if (edge.data('weight') <= border1) return '1px'; 208 | if (edge.data('weight') >= border2) return '3px'; 209 | return '2px'; 210 | }, 211 | }) 212 | .update(); 213 | } 214 | 215 | function adjustNodeWidth(cy: cytoscape.Core) { 216 | cy.nodes().forEach((node) => { 217 | const label = node.data('label'); 218 | node.style('width', `${label.length * 10}px`); 219 | }); 220 | } 221 | 222 | function getMinMaxWeight(cy: cytoscape.Core): [number, number] { 223 | const edges = cy.edges(); 224 | const min = edges.min((e) => e.data('weight')); 225 | const max = edges.max((e) => e.data('weight')); 226 | return [min.value, max.value]; 227 | } 228 | 229 | function centerAllNodes(cy: cytoscape.Core): void { 230 | const boundingBox = cy.elements().boundingBox(); 231 | const container = cy.container(); 232 | 233 | if (!container) { 234 | return; 235 | } 236 | 237 | const containerCenterX = container.clientWidth / 2; 238 | const containerCenterY = container.clientHeight / 2; 239 | 240 | const graphCenterX = (boundingBox.x1 + boundingBox.x2) / 2; 241 | const graphCenterY = (boundingBox.y1 + boundingBox.y2) / 2; 242 | 243 | const shiftX = containerCenterX - graphCenterX; 244 | const shiftY = containerCenterY - graphCenterY; 245 | 246 | cy.panBy({ x: shiftX, y: Math.max(0, shiftY - 200) }); 247 | } 248 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/limits/limits.component.css: -------------------------------------------------------------------------------- 1 | .form-container { 2 | display: flex; 3 | } 4 | 5 | .form-field { 6 | margin-right: 16px; /* Add some space between the fields */ 7 | } 8 | 9 | .type { 10 | width: 180px; 11 | } 12 | 13 | .commits { 14 | width: 150px; 15 | } 16 | 17 | .months { 18 | width: 150px; 19 | } 20 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/limits/limits.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 | 7 | @for (option of options; track option.id) { 8 | {{ option.label }} 9 | } 10 | 11 | 12 | 13 | @if (limits().limitType === 'COMMITS') { 14 | 15 | Max. Commits 16 | 24 | 25 | } @else { 26 | 27 | Months 28 | 35 | 36 | } 37 |
38 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/limits/limits.component.ts: -------------------------------------------------------------------------------- 1 | import { DecimalPipe } from '@angular/common'; 2 | import { Component, computed, inject, input, model } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { MatFormFieldModule } from '@angular/material/form-field'; 5 | import { MatInputModule } from '@angular/material/input'; 6 | import { MatSelectModule } from '@angular/material/select'; 7 | import { MatTooltipModule } from '@angular/material/tooltip'; 8 | 9 | import { Limits, LimitType } from '../../model/limits'; 10 | 11 | interface Option { 12 | id: LimitType; 13 | label: string; 14 | } 15 | 16 | const initCommits = 1000; 17 | const initMonths = 12; 18 | 19 | @Component({ 20 | selector: 'app-limits', 21 | standalone: true, 22 | imports: [ 23 | MatFormFieldModule, 24 | MatInputModule, 25 | MatSelectModule, 26 | FormsModule, 27 | MatTooltipModule, 28 | ], 29 | providers: [DecimalPipe], 30 | templateUrl: './limits.component.html', 31 | styleUrl: './limits.component.css', 32 | }) 33 | export class LimitsComponent { 34 | limits = model.required(); 35 | totalCommits = input(0); 36 | 37 | decimal = inject(DecimalPipe); 38 | 39 | commitToolTip = computed(() => { 40 | const totalCommits = this.totalCommits(); 41 | if (totalCommits) { 42 | return this.decimal.transform(totalCommits) + ' total commits'; 43 | } 44 | return ''; 45 | }); 46 | 47 | optionChanged(option: LimitType) { 48 | if (option === 'COMMITS') { 49 | this.limits.set({ 50 | limitCommits: initCommits, 51 | limitMonths: 0, 52 | limitType: 'COMMITS', 53 | }); 54 | } else { 55 | this.limits.set({ 56 | limitCommits: 0, 57 | limitMonths: initMonths, 58 | limitType: 'MONTHS', 59 | }); 60 | } 61 | } 62 | 63 | update(commits: number, months: number): void { 64 | this.limits.update((limits) => ({ 65 | ...limits, 66 | limitCommits: commits, 67 | limitMonths: months, 68 | })); 69 | } 70 | 71 | options: Option[] = [ 72 | { id: 'COMMITS', label: 'Limit Commits' }, 73 | { id: 'MONTHS', label: 'Limit by Date' }, 74 | ]; 75 | } 76 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/loading/loading.component.css: -------------------------------------------------------------------------------- 1 | .dialog { 2 | padding: 30px; 3 | } 4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/loading/loading.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Updating Cache

3 |
4 |

5 | Updating your Git Log Cache. Depending on your repository size, this might 6 | take a minute. 7 |

8 |
9 |
10 | 11 |
12 |
13 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/loading/loading.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | import { MatProgressBarModule } from '@angular/material/progress-bar'; 3 | 4 | @Component({ 5 | selector: 'app-loading', 6 | standalone: true, 7 | imports: [MatProgressBarModule], 8 | templateUrl: './loading.component.html', 9 | styleUrl: './loading.component.css', 10 | }) 11 | export class LoadingComponent {} 12 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/resizer/resizer.component.css: -------------------------------------------------------------------------------- 1 | .resizer { 2 | width: 5px; 3 | height: 100%; 4 | cursor: ew-resize; 5 | background-color: white; 6 | position: absolute; 7 | right: 0; 8 | top: 0; 9 | z-index: 10; 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/resizer/resizer.component.html: -------------------------------------------------------------------------------- 1 |
2 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/resizer/resizer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed } from '@angular/core/testing'; 2 | 3 | import { ResizerComponent } from './resizer.component'; 4 | 5 | describe('ResizerComponent', () => { 6 | let component: ResizerComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(async () => { 10 | await TestBed.configureTestingModule({ 11 | imports: [ResizerComponent], 12 | }).compileComponents(); 13 | 14 | fixture = TestBed.createComponent(ResizerComponent); 15 | component = fixture.componentInstance; 16 | fixture.detectChanges(); 17 | }); 18 | 19 | it('should create', () => { 20 | expect(component).toBeTruthy(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/resizer/resizer.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { Component, HostListener, model } from '@angular/core'; 3 | 4 | @Component({ 5 | selector: 'app-resizer', 6 | standalone: true, 7 | imports: [CommonModule], 8 | templateUrl: './resizer.component.html', 9 | styleUrl: './resizer.component.css', 10 | }) 11 | export class ResizerComponent { 12 | minWidth = 100; 13 | maxWidth = 800; 14 | resizing = false; 15 | startX = 0; 16 | 17 | position = model(350); 18 | 19 | onMouseDown(event: MouseEvent) { 20 | this.resizing = true; 21 | this.startX = event.clientX; 22 | document.body.classList.add('resizing'); 23 | event.preventDefault(); 24 | } 25 | 26 | @HostListener('document:mousemove', ['$event']) 27 | onMouseMove(event: MouseEvent) { 28 | if (this.resizing) { 29 | event.preventDefault(); 30 | const deltaX = event.clientX - this.startX; 31 | const newWidth = this.position() + deltaX; 32 | if (newWidth >= this.minWidth && newWidth <= this.maxWidth) { 33 | this.position.set(newWidth); 34 | this.startX = event.clientX; 35 | } 36 | } 37 | } 38 | 39 | @HostListener('document:mouseup') 40 | onMouseUp() { 41 | this.resizing = false; 42 | document.body.classList.remove('resizing'); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/treemap/treemap.component.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/apps/frontend/src/app/ui/treemap/treemap.component.css -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/treemap/treemap.component.html: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | -------------------------------------------------------------------------------- /apps/frontend/src/app/ui/treemap/treemap.component.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { 3 | Component, 4 | ElementRef, 5 | input, 6 | OnChanges, 7 | OnDestroy, 8 | output, 9 | SimpleChanges, 10 | viewChild, 11 | } from '@angular/core'; 12 | import { 13 | CategoryScale, 14 | Chart, 15 | ChartConfiguration, 16 | ChartEvent, 17 | InteractionItem, 18 | Legend, 19 | LinearScale, 20 | Title, 21 | Tooltip, 22 | } from 'chart.js'; 23 | import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; 24 | 25 | export type TreeMapChartConfig = ChartConfiguration< 26 | 'treemap', 27 | object[], 28 | string 29 | >; 30 | 31 | type TreeMapChart = Chart<'treemap', object[], string>; 32 | 33 | type Item = { 34 | _data: { 35 | children: unknown[]; 36 | }; 37 | }; 38 | 39 | export type TreeMapEvent = { 40 | entry: unknown; 41 | }; 42 | 43 | Chart.register( 44 | TreemapElement, 45 | LinearScale, 46 | CategoryScale, 47 | Tooltip, 48 | Legend, 49 | Title, 50 | TreemapController 51 | ); 52 | 53 | @Component({ 54 | selector: 'app-treemap', 55 | standalone: true, 56 | imports: [CommonModule], 57 | templateUrl: './treemap.component.html', 58 | styleUrl: './treemap.component.css', 59 | }) 60 | export class TreeMapComponent implements OnChanges, OnDestroy { 61 | canvasRef = viewChild.required>('canvas'); 62 | chartConfig = input.required(); 63 | 64 | elementSelected = output(); 65 | 66 | private chart: TreeMapChart | undefined; 67 | 68 | ngOnChanges(_changes: SimpleChanges): void { 69 | const canvasRef = this.canvasRef(); 70 | const canvas = canvasRef.nativeElement; 71 | const ctx = canvas.getContext('2d'); 72 | const config = this.chartConfig(); 73 | 74 | this.chart?.destroy(); 75 | 76 | if (config.data.datasets[0].data.length === 0) { 77 | return; 78 | } 79 | 80 | if (!ctx) { 81 | throw new Error('2d context not found'); 82 | } 83 | 84 | config.options = config.options ?? {}; 85 | config.options.onClick = ( 86 | _event: ChartEvent, 87 | elements: InteractionItem[] 88 | ) => { 89 | if (elements.length > 2) { 90 | const element = elements[elements.length - 1]; 91 | const dataIndex = element.index; 92 | const dataset = config.data.datasets[0]; 93 | const data = dataset.data; 94 | const item = data[dataIndex] as Item; 95 | const entry = item._data.children[0]; 96 | this.elementSelected.emit({ entry }); 97 | } 98 | }; 99 | this.chart = new Chart(ctx, config); 100 | } 101 | 102 | ngOnDestroy(): void { 103 | this.chart?.destroy(); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /apps/frontend/src/app/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | import { concat, connect, debounceTime, OperatorFunction, take } from 'rxjs'; 2 | 3 | export function debounceTimeSkipFirst( 4 | dueTime: number 5 | ): OperatorFunction { 6 | return connect((value) => 7 | concat(value.pipe(take(1)), value.pipe(debounceTime(dueTime))) 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/frontend/src/app/utils/error-handler.ts: -------------------------------------------------------------------------------- 1 | import { 2 | EnvironmentProviders, 3 | ErrorHandler, 4 | inject, 5 | makeEnvironmentProviders, 6 | Type, 7 | } from '@angular/core'; 8 | import { MatSnackBar } from '@angular/material/snack-bar'; 9 | 10 | export type ShowErrorFn = (error: GenericError) => void; 11 | 12 | export interface GenericError { 13 | error?: { 14 | message: string; 15 | }; 16 | message: string; 17 | } 18 | 19 | export function injectShowError(): ShowErrorFn { 20 | const snackBar = inject(MatSnackBar); 21 | return (error: GenericError) => { 22 | const message = error.error?.message || error.message; 23 | snackBar.open(message, 'OK', { 24 | panelClass: ['snackbar-alarm'], 25 | }); 26 | console.error(error); 27 | }; 28 | } 29 | 30 | export class AppErrorHandler implements ErrorHandler { 31 | private showError = injectShowError(); 32 | handleError(error: GenericError): void { 33 | this.showError(error); 34 | } 35 | } 36 | 37 | export function provideErrorHandler( 38 | errorHandler: Type 39 | ): EnvironmentProviders { 40 | return makeEnvironmentProviders([ 41 | { provide: ErrorHandler, useClass: errorHandler }, 42 | ]); 43 | } 44 | -------------------------------------------------------------------------------- /apps/frontend/src/app/utils/event.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { Subject } from 'rxjs'; 3 | 4 | @Injectable({ providedIn: 'root' }) 5 | export class EventService { 6 | filterChanged = new Subject(); 7 | } 8 | -------------------------------------------------------------------------------- /apps/frontend/src/app/utils/segments.ts: -------------------------------------------------------------------------------- 1 | export function lastSegments(moduleName: string, segments: number) { 2 | let moduleNameParts = moduleName.split('/'); 3 | if (moduleNameParts.length > segments) { 4 | moduleNameParts = moduleNameParts.slice(moduleNameParts.length - segments); 5 | } 6 | const label = moduleNameParts.join('/'); 7 | return label; 8 | } 9 | -------------------------------------------------------------------------------- /apps/frontend/src/app/utils/signal-helpers.ts: -------------------------------------------------------------------------------- 1 | import { Signal, computed, effect, signal, untracked } from '@angular/core'; 2 | 3 | export function explicitEffect( 4 | source: Signal, 5 | action: (value: T) => void 6 | ) { 7 | effect(() => { 8 | const s = source(); 9 | untracked(() => { 10 | action(s); 11 | }); 12 | }); 13 | } 14 | 15 | export function onceEffect(action: () => void) { 16 | const ref = effect(() => { 17 | untracked(() => { 18 | action(); 19 | ref.destroy(); 20 | }); 21 | }); 22 | } 23 | 24 | export function mirror(source: Signal) { 25 | const value = signal(source()); 26 | return computed(() => { 27 | untracked(() => { 28 | value.set(source()); 29 | }); 30 | return { 31 | source: source(), 32 | value: value, 33 | }; 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /apps/frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Detective 6 | 7 | 8 | 9 | 13 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /apps/frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapApplication } from '@angular/platform-browser'; 2 | 3 | import { AppComponent } from './app/app.component'; 4 | import { appConfig } from './app/app.config'; 5 | 6 | bootstrapApplication(AppComponent, appConfig).catch((err) => 7 | console.error(err) 8 | ); 9 | -------------------------------------------------------------------------------- /apps/frontend/src/styles.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | body { 6 | margin: 0; 7 | font-family: Roboto, 'Helvetica Neue', sans-serif; 8 | } 9 | 10 | .container { 11 | padding: 15px; 12 | } 13 | 14 | .ta-diagram { 15 | width: 100%; 16 | float: left; 17 | padding: 10px; 18 | } 19 | 20 | .ta-container { 21 | width: 25%; 22 | float: left; 23 | } 24 | 25 | @media (max-width: 1200px) { 26 | .ta-container { 27 | width: 33.33%; 28 | } 29 | } 30 | 31 | @media (max-width: 768px) { 32 | .ta-container { 33 | width: 50%; 34 | } 35 | } 36 | 37 | @media (max-width: 480px) { 38 | .ta-container { 39 | width: 100%; 40 | } 41 | } 42 | 43 | #legend { 44 | display: flex; 45 | flex-wrap: wrap; 46 | list-style: none; 47 | padding: 10px; 48 | margin: 20px 50px; 49 | justify-content: center; 50 | } 51 | 52 | #legend li { 53 | display: flex; 54 | align-items: center; 55 | margin-right: 15px; 56 | margin-bottom: 20px; 57 | font-family: Arial, sans-serif; 58 | font-size: 14px; 59 | } 60 | 61 | .legend-color { 62 | display: inline-block; 63 | width: 20px; 64 | height: 20px; 65 | margin-right: 5px; 66 | } 67 | 68 | .snackbar-alarm { 69 | --mdc-snackbar-container-color: #c04040 !important; 70 | color: white !important; 71 | .mat-mdc-button { 72 | color: white !important; 73 | } 74 | } 75 | 76 | .forensic-filter { 77 | display: flex; 78 | justify-content: space-between; 79 | align-items: center; 80 | padding: 10px; 81 | } 82 | 83 | .pl20 { 84 | padding-left: 20px; 85 | } 86 | 87 | .help-icon { 88 | margin-left: 20px; 89 | cursor: pointer; 90 | color: #757575; 91 | vertical-align: middle; 92 | } 93 | 94 | .help-icon-hotspot { 95 | margin-top: -10px; 96 | margin-left: 20px; 97 | cursor: pointer; 98 | color: #757575; 99 | vertical-align: middle; 100 | } 101 | 102 | a.docu-link { 103 | color: black; 104 | cursor: pointer; 105 | font-weight: normal; 106 | margin-left: 20px; 107 | } 108 | 109 | .resizing { 110 | cursor: ew-resize; 111 | } 112 | -------------------------------------------------------------------------------- /apps/frontend/src/test-setup.ts: -------------------------------------------------------------------------------- 1 | // @ts-expect-error https://thymikee.github.io/jest-preset-angular/docs/getting-started/test-environment 2 | globalThis.ngJest = { 3 | testEnvironmentOptions: { 4 | errorOnUnknownElements: true, 5 | errorOnUnknownProperties: true, 6 | }, 7 | }; 8 | import 'jest-preset-angular/setup-jest'; 9 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "types": [] 6 | }, 7 | "files": ["src/main.ts"], 8 | "include": ["src/**/*.d.ts"], 9 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.editor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.ts"], 4 | "compilerOptions": {}, 5 | "exclude": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts"] 6 | } 7 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2022", 4 | "lib": ["ES2022", "dom"], 5 | "esModuleInterop": true, 6 | "forceConsistentCasingInFileNames": true, 7 | "strict": true, 8 | "noImplicitOverride": true, 9 | "noPropertyAccessFromIndexSignature": true, 10 | "noImplicitReturns": true, 11 | "noFallthroughCasesInSwitch": true 12 | }, 13 | "files": [], 14 | "include": [], 15 | "references": [ 16 | { 17 | "path": "./tsconfig.editor.json" 18 | }, 19 | { 20 | "path": "./tsconfig.app.json" 21 | }, 22 | { 23 | "path": "./tsconfig.spec.json" 24 | } 25 | ], 26 | "extends": "../../tsconfig.base.json", 27 | "angularCompilerOptions": { 28 | "enableI18nLegacyMessageIdFormat": false, 29 | "strictInjectionParameters": true, 30 | "strictInputAccessModifiers": true, 31 | "strictTemplates": true 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/frontend/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../../dist/out-tsc", 5 | "module": "commonjs", 6 | "target": "es2016", 7 | "types": ["jest", "node"] 8 | }, 9 | "files": ["src/test-setup.ts"], 10 | "include": [ 11 | "jest.config.ts", 12 | "src/**/*.test.ts", 13 | "src/**/*.spec.ts", 14 | "src/**/*.d.ts" 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | nx build backend 2 | nx build frontend 3 | 4 | cp -r dist/apps/frontend/browser/* dist/apps/backend/assets 5 | cp README.md dist/apps/backend 6 | echo done. -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /docs/change-coupling.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/change-coupling.png -------------------------------------------------------------------------------- /docs/context-menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/context-menu.png -------------------------------------------------------------------------------- /docs/domains-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/domains-detail.png -------------------------------------------------------------------------------- /docs/hotspot-details.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/hotspot-details.png -------------------------------------------------------------------------------- /docs/hotspots-treemap.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/hotspots-treemap.png -------------------------------------------------------------------------------- /docs/hotspots.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/hotspots.png -------------------------------------------------------------------------------- /docs/team-alignment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angular-architects/detective/33dbb4341d2f956cf66d79c99693a7cf13ca547f/docs/team-alignment.png -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { getJestProjectsAsync } from '@nx/jest'; 2 | 3 | export default async () => ({ 4 | projects: await getJestProjectsAsync(), 5 | }); 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const nxPreset = require('@nx/jest/preset').default; 2 | 3 | module.exports = { ...nxPreset }; 4 | -------------------------------------------------------------------------------- /nx.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/nx/schemas/nx-schema.json", 3 | "namedInputs": { 4 | "default": ["{projectRoot}/**/*", "sharedGlobals"], 5 | "production": [ 6 | "default", 7 | "!{projectRoot}/.eslintrc.json", 8 | "!{projectRoot}/eslint.config.js", 9 | "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", 10 | "!{projectRoot}/tsconfig.spec.json", 11 | "!{projectRoot}/jest.config.[jt]s", 12 | "!{projectRoot}/src/test-setup.[jt]s", 13 | "!{projectRoot}/test-setup.[jt]s" 14 | ], 15 | "sharedGlobals": [] 16 | }, 17 | "release": { 18 | "projects": ["backend"] 19 | }, 20 | "targetDefaults": { 21 | "@angular-devkit/build-angular:application": { 22 | "cache": true, 23 | "dependsOn": ["^build"], 24 | "inputs": ["production", "^production"] 25 | }, 26 | "@nx/eslint:lint": { 27 | "cache": true, 28 | "inputs": [ 29 | "default", 30 | "{workspaceRoot}/.eslintrc.json", 31 | "{workspaceRoot}/.eslintignore", 32 | "{workspaceRoot}/eslint.config.js" 33 | ] 34 | }, 35 | "@nx/jest:jest": { 36 | "cache": true, 37 | "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], 38 | "options": { 39 | "passWithNoTests": true 40 | }, 41 | "configurations": { 42 | "ci": { 43 | "ci": true, 44 | "codeCoverage": true 45 | } 46 | } 47 | } 48 | }, 49 | "generators": { 50 | "@nx/angular:application": { 51 | "e2eTestRunner": "none", 52 | "linter": "eslint", 53 | "style": "css", 54 | "unitTestRunner": "jest" 55 | }, 56 | "@nx/angular:component": { 57 | "style": "css" 58 | } 59 | }, 60 | "plugins": [ 61 | { 62 | "plugin": "@nx/webpack/plugin", 63 | "options": { 64 | "buildTargetName": "build", 65 | "serveTargetName": "serve", 66 | "previewTargetName": "preview" 67 | } 68 | }, 69 | { 70 | "plugin": "@nx/eslint/plugin", 71 | "options": { 72 | "targetName": "eslint:lint" 73 | } 74 | } 75 | ] 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@detective/source", 3 | "version": "0.0.0", 4 | "license": "MIT", 5 | "private": true, 6 | "engines": { 7 | "node": ">=20.17.0", 8 | "npm": ">=10.8.2" 9 | }, 10 | "dependencies": { 11 | "@angular/animations": "~18.2.0", 12 | "@angular/cdk": "^18.2.0", 13 | "@angular/common": "~18.2.0", 14 | "@angular/compiler": "~18.2.0", 15 | "@angular/core": "~18.2.0", 16 | "@angular/forms": "~18.2.0", 17 | "@angular/material": "^18.2.0", 18 | "@angular/platform-browser": "~18.2.0", 19 | "@angular/platform-browser-dynamic": "~18.2.0", 20 | "@angular/router": "~18.2.0", 21 | "@ngrx/operators": "^18.0.2", 22 | "@ngrx/signals": "^18.0.2", 23 | "@softarc/sheriff-core": "^0.17.1", 24 | "axios": "^1.6.0", 25 | "chart.js": "^4.4.4", 26 | "chartjs-chart-treemap": "^3.1.0", 27 | "cytoscape": "^3.30.2", 28 | "cytoscape-cola": "^2.5.1", 29 | "cytoscape-dagre": "^2.5.0", 30 | "cytoscape-euler": "^1.2.3", 31 | "cytoscape-klay": "^3.1.4", 32 | "cytoscape-qtip": "^2.8.0", 33 | "cytoscape-spread": "^3.0.0", 34 | "d3": "^7.9.0", 35 | "express": "^4.18.1", 36 | "fast-glob": "^3.3.2", 37 | "micromatch": "^4.0.8", 38 | "qtip2": "^3.0.3", 39 | "rxjs": "~7.8.0", 40 | "tslib": "^2.3.0", 41 | "zone.js": "~0.14.3" 42 | }, 43 | "devDependencies": { 44 | "@angular-devkit/build-angular": "~18.2.0", 45 | "@angular-devkit/core": "~18.2.0", 46 | "@angular-devkit/schematics": "~18.2.0", 47 | "@angular-eslint/eslint-plugin": "^18.0.1", 48 | "@angular-eslint/eslint-plugin-template": "^18.0.1", 49 | "@angular-eslint/template-parser": "^18.0.1", 50 | "@angular/cli": "~18.2.0", 51 | "@angular/compiler-cli": "~18.2.0", 52 | "@angular/language-service": "~18.2.0", 53 | "@commitlint/cli": "^19.4.1", 54 | "@commitlint/config-conventional": "^19.4.1", 55 | "@nx/angular": "19.6.4", 56 | "@nx/eslint": "19.6.4", 57 | "@nx/eslint-plugin": "19.6.4", 58 | "@nx/express": "19.6.4", 59 | "@nx/jest": "19.6.4", 60 | "@nx/js": "19.6.4", 61 | "@nx/linter": "^19.6.4", 62 | "@nx/node": "19.6.4", 63 | "@nx/web": "19.6.4", 64 | "@nx/webpack": "19.6.4", 65 | "@nx/workspace": "19.6.4", 66 | "@pmmmwh/react-refresh-webpack-plugin": "^0.5.7", 67 | "@schematics/angular": "~18.2.0", 68 | "@svgr/webpack": "^8.0.1", 69 | "@swc-node/register": "~1.9.1", 70 | "@swc/core": "~1.5.7", 71 | "@swc/helpers": "~0.5.11", 72 | "@types/cytoscape": "^3.21.6", 73 | "@types/d3": "^7.4.3", 74 | "@types/jest": "^29.5.12", 75 | "@types/micromatch": "^4.0.9", 76 | "@types/node": "18.16.9", 77 | "@typescript-eslint/eslint-plugin": "^7.16.0", 78 | "@typescript-eslint/parser": "^7.16.0", 79 | "@typescript-eslint/utils": "^7.16.0", 80 | "eslint": "~8.57.0", 81 | "eslint-config-prettier": "^9.0.0", 82 | "eslint-plugin-import": "^2.30.0", 83 | "husky": "^9.1.5", 84 | "jest": "^29.7.0", 85 | "jest-environment-jsdom": "^29.7.0", 86 | "jest-environment-node": "^29.7.0", 87 | "jest-preset-angular": "~14.1.0", 88 | "nx": "19.6.4", 89 | "prettier": "^2.6.2", 90 | "react-refresh": "^0.10.0", 91 | "ts-jest": "^29.1.0", 92 | "ts-node": "10.9.1", 93 | "typescript": "~5.5.2", 94 | "webpack-cli": "^5.1.4" 95 | }, 96 | "scripts": { 97 | "prepare": "husky", 98 | "lint": "nx run-many --target=lint --all --fix --max-warnings=0", 99 | "test": "nx run-many --target=test --all", 100 | "build": "nx run-many --target=build --all", 101 | "release": "nx release --skip-publish" 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /publish-local.sh: -------------------------------------------------------------------------------- 1 | npm unpublish @softarc/detective --registry http://localhost:4873 --force 2 | npm publish dist/apps/backend --registry http://localhost:4873 3 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "rootDir": ".", 5 | "sourceMap": true, 6 | "declaration": false, 7 | "moduleResolution": "node", 8 | "emitDecoratorMetadata": true, 9 | "experimentalDecorators": true, 10 | "importHelpers": true, 11 | "target": "es2015", 12 | "module": "esnext", 13 | "lib": ["es2020", "dom"], 14 | "skipLibCheck": true, 15 | "skipDefaultLibCheck": true, 16 | "baseUrl": ".", 17 | "paths": {} 18 | }, 19 | "exclude": ["node_modules", "tmp"] 20 | } 21 | --------------------------------------------------------------------------------