├── .github └── workflows │ ├── delta-typescript-graph.yml │ ├── integration-testing.yml │ └── release.yml ├── .gitignore ├── .husky └── pre-commit ├── .prettierrc.json ├── .tsgrc.json ├── .vscode └── settings.json ├── README.md ├── bin └── tsg.js ├── docs ├── README_en.md ├── README_ja.md └── img │ └── watch-metrics.png ├── dummy_project ├── .tsgrc.json ├── data.json ├── src │ ├── config.ts │ ├── includeFiles │ │ ├── D.vue │ │ ├── a.ts │ │ ├── abstractions │ │ │ ├── children │ │ │ │ └── childA.ts │ │ │ ├── j.ts │ │ │ ├── k.ts │ │ │ └── l.ts │ │ ├── b.ts │ │ ├── c.ts │ │ ├── children │ │ │ └── childA.ts │ │ ├── components │ │ │ └── HelloWorld.vue │ │ ├── d │ │ │ ├── d.ts │ │ │ └── index.ts │ │ └── excludeFiles │ │ │ ├── children │ │ │ └── childA.ts │ │ │ ├── class │ │ │ └── classA.ts │ │ │ ├── g.ts │ │ │ ├── h.ts │ │ │ ├── i.ts │ │ │ └── style │ │ │ └── style.ts │ ├── main.ts │ ├── otherFiles │ │ ├── children │ │ │ ├── :id.json │ │ │ ├── childA.ts │ │ │ └── {id}.json │ │ ├── d.ts │ │ ├── e.ts │ │ └── f.ts │ └── utils.ts └── tsconfig.json ├── dummy_project_for_metrics ├── badCode.ts ├── goodCode.ts └── tsconfig.json ├── eslint.config.mjs ├── integration.spec.ts ├── integration.vue.spec.ts ├── package-lock.json ├── package.json ├── renovate.json5 ├── src ├── cli │ └── entry.ts ├── feature │ ├── graph │ │ ├── GraphAnalyzer.spec.ts │ │ ├── GraphAnalyzer.ts │ │ ├── abstraction.abstractionPath.spec.ts │ │ ├── abstraction.getAbstractionDirArr.spec.ts │ │ ├── abstraction.spec.ts │ │ ├── abstraction.ts │ │ ├── filterGraph.spec.data │ │ │ ├── exclude.json │ │ │ ├── include.json │ │ │ ├── noconfig.json │ │ │ ├── nodes.json │ │ │ └── relations.json │ │ ├── filterGraph.spec.ts │ │ ├── filterGraph.ts │ │ ├── highlight.ts │ │ ├── instability.ts │ │ ├── models.ts │ │ ├── refineGraph.ts │ │ └── utils.ts │ ├── mermaid │ │ ├── mermaidify.fileNameToMermaidId.spec.ts │ │ └── mermaidify.ts │ ├── metric │ │ ├── HierarchicalMetricsAnalyzer.ts │ │ ├── HierarchicalMetris.ts │ │ ├── Metrics.ts │ │ ├── VisitorFactory.ts │ │ ├── architecture.md │ │ ├── calculateCodeMetrics.ts │ │ ├── cognitiveComplexity │ │ │ ├── CognitiveComplexity.children.spec.ts │ │ │ ├── CognitiveComplexity.nestlevel.spec.ts │ │ │ ├── CognitiveComplexity.spec.ts │ │ │ ├── CognitiveComplexityAnalyzer.ts │ │ │ ├── CognitiveComplexityForClass.ts │ │ │ ├── CognitiveComplexityForNormalNode.ts │ │ │ ├── CognitiveComplexityForSourceCode.ts │ │ │ ├── CognitiveComplexityMetrics.ts │ │ │ └── index.ts │ │ ├── cyclomaticComplexity │ │ │ ├── CyclomaticComplexity.children.spec.ts │ │ │ ├── CyclomaticComplexity.spec.ts │ │ │ ├── CyclomaticComplexityAnalyzer.ts │ │ │ ├── CyclomaticComplexityForClass.ts │ │ │ ├── CyclomaticComplexityForNormalNode.ts │ │ │ ├── CyclomaticComplexityForSourceCode.ts │ │ │ ├── CyclomaticComplexityMetrics.ts │ │ │ └── index.ts │ │ ├── functions │ │ │ ├── calculateMaintainabilityIndex.ts │ │ │ ├── convertRawToCodeMetrics.ts │ │ │ ├── getIconByState.ts │ │ │ ├── getMetricsRawData.ts │ │ │ ├── toSortedMetrics.ts │ │ │ └── updateMetricsName.ts │ │ ├── metricsModels.ts │ │ └── semanticSyntaxVolume │ │ │ ├── SemanticSyntaxVolume.spec.ts │ │ │ ├── SemanticSyntaxVolume.tsx.spec.ts │ │ │ ├── SemanticSyntaxVolume.type.spec.ts │ │ │ ├── SemanticSyntaxVolumeAnalyzer.ts │ │ │ ├── SemanticSyntaxVolumeForClass.ts │ │ │ ├── SemanticSyntaxVolumeForNormalNode.ts │ │ │ ├── SemanticSyntaxVolumeForSourceCode.ts │ │ │ └── index.ts │ └── util │ │ ├── AstLogger.ts │ │ ├── AstTraverser.ts │ │ ├── AstVisitor.ts │ │ ├── ProjectTraverser.ts │ │ └── astUtils.ts ├── index.ts ├── reset.d.ts ├── setting │ ├── config │ │ ├── .config.spec.ts │ │ ├── .readRuntimeConfig.spec.ts │ │ ├── .setupConfig.spec.ts │ │ ├── config.json │ │ ├── index.ts │ │ └── rcSamples │ │ │ ├── invalid.tsgrc.json │ │ │ ├── notJson.tsgrc.text │ │ │ └── valid.tsgrc.json │ └── model.ts ├── usecase │ ├── generateTsg │ │ ├── index.ts │ │ ├── writeMarkdownFile.ts │ │ └── writeMetricsTable.ts │ └── watchMetrics.ts └── utils │ ├── Tree.ts │ ├── tsc-util.ts │ └── vue-util.ts ├── tsconfig.build.json └── tsconfig.json /.github/workflows/delta-typescript-graph.yml: -------------------------------------------------------------------------------- 1 | on: pull_request 2 | 3 | # Sets permissions of the GITHUB_TOKEN to allow write pull-requests 4 | permissions: 5 | pull-requests: write 6 | 7 | jobs: 8 | delta-typescript-graph-job: 9 | runs-on: ubuntu-latest 10 | name: Delta TypeScript Graph 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 # specify latest version 14 | - uses: ysk8hori/delta-typescript-graph-action@v1.2.0 # specify latest version 15 | with: 16 | max-size: 100 17 | show-metrics: true 18 | -------------------------------------------------------------------------------- /.github/workflows/integration-testing.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs 3 | 4 | name: integration testing 5 | 6 | on: 7 | pull_request: 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [18.x] 16 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v4 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - run: npm run build --if-present 27 | - run: npm run test 28 | - run: npm run test:integration 29 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | release: 8 | name: Release 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | with: 14 | fetch-depth: 0 15 | - name: Setup Node.js 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: 'lts/*' 19 | - name: Install dependencies 20 | run: npm ci 21 | - name: Release 22 | env: 23 | GH_TOKEN: ${{ secrets.GH_TOKEN }} 24 | NPM_TOKEN: ${{ secrets.npm_token }} 25 | run: npx semantic-release 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | typescript-graph.md 4 | .npmrc 5 | __tmp__ -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm test 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": true, 3 | "endOfLine": "auto", 4 | "semi": true, 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "trailingComma": "all", 8 | "arrowParens": "avoid" 9 | } 10 | -------------------------------------------------------------------------------- /.tsgrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "dist": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /bin/tsg.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | // eslint-disable-next-line no-undef 3 | require('../dist/src/cli/entry.js'); 4 | -------------------------------------------------------------------------------- /docs/img/watch-metrics.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysk8hori/typescript-graph/b21025805c2f89c6c5c31d9933dc541d2d17d34d/docs/img/watch-metrics.png -------------------------------------------------------------------------------- /dummy_project/.tsgrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"] 3 | } 4 | -------------------------------------------------------------------------------- /dummy_project/data.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /dummy_project/src/config.ts: -------------------------------------------------------------------------------- 1 | export const config = {}; 2 | import { log } from './utils'; 3 | import C from './includeFiles/c'; 4 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/D.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/a.ts: -------------------------------------------------------------------------------- 1 | import childA from './children/childA'; 2 | // eslint-disable-next-line @typescript-eslint/no-var-requires 3 | const a2 = require('./excludeFiles/g'); 4 | const c2 = import('./excludeFiles/i'); 5 | import { style } from './excludeFiles/style/style'; 6 | import ClassA from './excludeFiles/class/classA'; 7 | 8 | export default async function a() { 9 | // eslint-disable-next-line @typescript-eslint/no-var-requires 10 | const b2 = require('./excludeFiles/h'); 11 | const d = await import('./d/index'); 12 | childA(); 13 | a2(); 14 | b2(); 15 | } 16 | import { log } from '../utils'; 17 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/abstractions/children/childA.ts: -------------------------------------------------------------------------------- 1 | export default function childA() {} 2 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/abstractions/j.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../../utils'; 2 | import childA from './children/childA'; 3 | import data from '../../../data.json'; 4 | 5 | export default function a() { 6 | childA(); 7 | log(); 8 | } 9 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/abstractions/k.ts: -------------------------------------------------------------------------------- 1 | import c from './l'; 2 | export default function b() { 3 | c(); 4 | } 5 | import { log } from '../../utils'; 6 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/abstractions/l.ts: -------------------------------------------------------------------------------- 1 | export default function c() {} 2 | import { log } from '../../utils'; 3 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/b.ts: -------------------------------------------------------------------------------- 1 | export default function b() { 2 | config; 3 | } 4 | import { log } from '../utils'; 5 | import { config } from '../config'; 6 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/c.ts: -------------------------------------------------------------------------------- 1 | export default function c() {} 2 | import { log } from '../utils'; 3 | import b from './b'; 4 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/children/childA.ts: -------------------------------------------------------------------------------- 1 | export default function childA() {} 2 | import { log } from '../../utils'; 3 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 42 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/d/d.ts: -------------------------------------------------------------------------------- 1 | export function d() {} 2 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/d/index.ts: -------------------------------------------------------------------------------- 1 | export * as d from './d'; 2 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/excludeFiles/children/childA.ts: -------------------------------------------------------------------------------- 1 | import { log } from '../../../utils'; 2 | export default function childA() {} 3 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/excludeFiles/class/classA.ts: -------------------------------------------------------------------------------- 1 | export default class ClassA {} 2 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/excludeFiles/g.ts: -------------------------------------------------------------------------------- 1 | import childA from '../children/childA'; 2 | 3 | export default function a() { 4 | childA(); 5 | } 6 | import { log } from '../../utils'; 7 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/excludeFiles/h.ts: -------------------------------------------------------------------------------- 1 | import c from './i'; 2 | export default function b() { 3 | c(); 4 | config; 5 | } 6 | import { log } from '../../utils'; 7 | import { config } from '../../config'; 8 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/excludeFiles/i.ts: -------------------------------------------------------------------------------- 1 | export default function c() {} 2 | import { log } from '../../utils'; 3 | -------------------------------------------------------------------------------- /dummy_project/src/includeFiles/excludeFiles/style/style.ts: -------------------------------------------------------------------------------- 1 | export const style = { 2 | fontSize: 'large', 3 | }; 4 | -------------------------------------------------------------------------------- /dummy_project/src/main.ts: -------------------------------------------------------------------------------- 1 | import a from './includeFiles/a'; 2 | import b from './includeFiles/b'; 3 | import a2 from './otherFiles/d'; 4 | import b2 from './otherFiles/e'; 5 | import a3 from './includeFiles/abstractions/j'; 6 | import b3 from './includeFiles/abstractions/k'; 7 | import ts from 'typescript'; 8 | import D from './includeFiles/D.vue'; 9 | 10 | export default function main() { 11 | a(); 12 | b(); 13 | a2(); 14 | b2(); 15 | a3(); 16 | b3(); 17 | } 18 | import { log } from './utils'; 19 | -------------------------------------------------------------------------------- /dummy_project/src/otherFiles/children/:id.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysk8hori/typescript-graph/b21025805c2f89c6c5c31d9933dc541d2d17d34d/dummy_project/src/otherFiles/children/:id.json -------------------------------------------------------------------------------- /dummy_project/src/otherFiles/children/childA.ts: -------------------------------------------------------------------------------- 1 | export default function childA() {} 2 | import { log } from '../../utils'; 3 | import id from './:id.json'; 4 | import id2 from './{id}.json'; 5 | -------------------------------------------------------------------------------- /dummy_project/src/otherFiles/children/{id}.json: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ysk8hori/typescript-graph/b21025805c2f89c6c5c31d9933dc541d2d17d34d/dummy_project/src/otherFiles/children/{id}.json -------------------------------------------------------------------------------- /dummy_project/src/otherFiles/d.ts: -------------------------------------------------------------------------------- 1 | import childA from './children/childA'; 2 | 3 | export default function a() { 4 | childA(); 5 | } 6 | import { log } from '../utils'; 7 | -------------------------------------------------------------------------------- /dummy_project/src/otherFiles/e.ts: -------------------------------------------------------------------------------- 1 | import c from './f'; 2 | export default function b() { 3 | c(); 4 | config; 5 | } 6 | import { log } from '../utils'; 7 | import { config } from '../config'; 8 | -------------------------------------------------------------------------------- /dummy_project/src/otherFiles/f.ts: -------------------------------------------------------------------------------- 1 | export default function c() {} 2 | import { log } from '../utils'; 3 | -------------------------------------------------------------------------------- /dummy_project/src/utils.ts: -------------------------------------------------------------------------------- 1 | export function log() {} 2 | -------------------------------------------------------------------------------- /dummy_project/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "preserve" /* Specify what JSX code is generated. */, 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | "resolveJsonModule": true /* Enable importing .json files */, 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 46 | "declarationMap": true /* Create sourcemaps for d.ts files. */, 47 | // "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, 48 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./dist/" /* Specify an output folder for all emitted files. */, 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | }, 101 | "include": ["./src/*"], 102 | "exclude": ["./**/*.spec.ts"] 103 | } 104 | -------------------------------------------------------------------------------- /dummy_project_for_metrics/badCode.ts: -------------------------------------------------------------------------------- 1 | export function badCode({ 2 | a, 3 | b, 4 | c, 5 | d, 6 | e, 7 | f, 8 | g, 9 | h, 10 | i, 11 | j, 12 | k, 13 | l, 14 | m, 15 | n, 16 | o, 17 | p, 18 | q, 19 | r, 20 | s, 21 | t, 22 | u, 23 | v, 24 | w, 25 | x, 26 | y, 27 | z, 28 | }: { 29 | a: string; 30 | b: string; 31 | c: string; 32 | d: string; 33 | e: string; 34 | f: string; 35 | g: string; 36 | h: string; 37 | i: string; 38 | j: string; 39 | k: string; 40 | l: string; 41 | m: string; 42 | n: string; 43 | o: string; 44 | p: string; 45 | q: string; 46 | r: string; 47 | s: string; 48 | t: string; 49 | u: string; 50 | v: string; 51 | w: string; 52 | x: string; 53 | y: string; 54 | z: string; 55 | }) { 56 | if (a === 'a') { 57 | if (b === 'b') { 58 | if (c === 'c') { 59 | if (d === 'd') { 60 | if (e === 'e') { 61 | if (f === 'f') { 62 | if (g === 'g') { 63 | if (h === 'h') { 64 | if (i === 'i') { 65 | if (j === 'j') { 66 | if (k === 'k') { 67 | if (l === 'l') { 68 | if (m === 'm') { 69 | if (n === 'n') { 70 | if (o === 'o') { 71 | if (p === 'p') { 72 | if (q === 'q') { 73 | if (r === 'r') { 74 | if (s === 's') { 75 | if (t === 't') { 76 | if (u === 'u') { 77 | if (v === 'v') { 78 | if (w === 'w') { 79 | if (x === 'x') { 80 | if (y === 'y') { 81 | if (z === 'z') { 82 | return 'Bad Code'; 83 | } 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | } 95 | } 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | } 108 | } 109 | return 'Good Code'; 110 | } 111 | -------------------------------------------------------------------------------- /dummy_project_for_metrics/goodCode.ts: -------------------------------------------------------------------------------- 1 | export function goodCode({ isGood }: { isGood: boolean }) { 2 | return isGood ? 'Good Code' : 'Bad Code'; 3 | } 4 | -------------------------------------------------------------------------------- /dummy_project_for_metrics/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Enable incremental compilation */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "preserve" /* Specify what JSX code is generated. */, 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */ 22 | // "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | 26 | /* Modules */ 27 | "module": "commonjs" /* Specify what module code is generated. */, 28 | // "rootDir": "./", /* Specify the root folder within your source files. */ 29 | // "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 30 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 31 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 32 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 33 | // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ 34 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 35 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 36 | "resolveJsonModule": true /* Enable importing .json files */, 37 | // "noResolve": true, /* Disallow `import`s, `require`s or ``s from expanding the number of files TypeScript should add to a project. */ 38 | 39 | /* JavaScript Support */ 40 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */ 41 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 42 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */ 43 | 44 | /* Emit */ 45 | "declaration": true /* Generate .d.ts files from TypeScript and JavaScript files in your project. */, 46 | "declarationMap": true /* Create sourcemaps for d.ts files. */, 47 | // "emitDeclarationOnly": true /* Only output d.ts files and not JavaScript files. */, 48 | "sourceMap": true /* Create source map files for emitted JavaScript files. */, 49 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */ 50 | "outDir": "./dist/" /* Specify an output folder for all emitted files. */, 51 | // "removeComments": true, /* Disable emitting comments. */ 52 | // "noEmit": true, /* Disable emitting files from a compilation. */ 53 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 54 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */ 55 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 56 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 57 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 58 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 59 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 60 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 61 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 62 | // "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */ 63 | // "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */ 64 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 65 | // "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */ 66 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 67 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 68 | 69 | /* Interop Constraints */ 70 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 71 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 72 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */, 73 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 74 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 75 | 76 | /* Type Checking */ 77 | "strict": true /* Enable all strict type-checking options. */, 78 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */ 79 | // "strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */ 80 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 81 | // "strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */ 82 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 83 | // "noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */ 84 | // "useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */ 85 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 86 | // "noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */ 87 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read */ 88 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 89 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 90 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 91 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 92 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 93 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */ 94 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 95 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 96 | 97 | /* Completeness */ 98 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 99 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import globals from 'globals'; 2 | import pluginJs from '@eslint/js'; 3 | import { flatConfigs as pluginImportFlatConfigs } from 'eslint-plugin-import'; 4 | import tseslint from 'typescript-eslint'; 5 | 6 | /** @type {import('eslint').Linter.Config[]} */ 7 | export default [ 8 | { files: ['**/*.{ts}'] }, 9 | { 10 | files: ['**/*.{js,mjs,cjs}'], 11 | extends: [tseslint.configs.disableTypeChecked], 12 | }, 13 | { languageOptions: { globals: globals.browser } }, 14 | pluginJs.configs.recommended, 15 | pluginImportFlatConfigs.recommended, 16 | pluginImportFlatConfigs.typescript, 17 | ...tseslint.configs.recommended, 18 | ...tseslint.configs.strict, 19 | ...tseslint.configs.stylistic, 20 | { 21 | languageOptions: { 22 | parserOptions: { 23 | projectService: true, 24 | tsconfigRootDir: import.meta.dirname, 25 | }, 26 | }, 27 | }, 28 | { 29 | // eslint 30 | rules: { 31 | 'import/order': 'error', 32 | 'import/no-unresolved': 'off', 33 | }, 34 | }, 35 | { 36 | // typescript-eslint 37 | rules: { 38 | '@typescript-eslint/no-invalid-void-type': 'off', 39 | '@typescript-eslint/consistent-type-imports': 'error', 40 | '@typescript-eslint/no-unused-vars': [ 41 | 'error', 42 | { args: 'all', argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 43 | ], 44 | '@typescript-eslint/no-floating-promises': 'error', 45 | }, 46 | }, 47 | ]; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ysk8hori/typescript-graph", 3 | "repository": { 4 | "type": "git", 5 | "url": "https://github.com/ysk8hori/typescript-graph.git" 6 | }, 7 | "bugs": { 8 | "url": "https://github.com/ysk8hori/typescript-graph/issues" 9 | }, 10 | "version": "0.0.0-development", 11 | "description": "A CLI to visualize the dependencies between files in the TypeScript codebase.", 12 | "homepage": "https://github.com/ysk8hori/typescript-graph", 13 | "exports": { 14 | ".": "./dist/src/index.js", 15 | "./*": "./dist/src/*" 16 | }, 17 | "bin": { 18 | "tsg": "./bin/tsg.js" 19 | }, 20 | "files": [ 21 | "bin", 22 | "dist" 23 | ], 24 | "keywords": [ 25 | "TypeScript", 26 | "dependencies", 27 | "dependencies graph" 28 | ], 29 | "scripts": { 30 | "run": "tsx ./src/cli/entry.ts", 31 | "run:help": "tsx ./src/cli/entry.ts -h", 32 | "run:sample": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --LR", 33 | "run:sample:include": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --include includeFiles config --LR", 34 | "run:sample:exclude": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --include includeFiles config --exclude excludeFiles utils --LR", 35 | "run:sample:abstraction": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --include includeFiles config --exclude excludeFiles utils --abstraction abstractions --LR", 36 | "run:sample:highlight": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --include includeFiles config --exclude excludeFiles utils --abstraction abstractions --LR --highlight config.ts b.ts", 37 | "run:sample:link": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --mermaid-link", 38 | "run:sample:README": "tsx ./src/cli/entry.ts -d /Users/horiyousuke/Documents/dev/numberplace --include src/components/atoms/ConfigMenu --exclude test stories node_modules", 39 | "run:sample:dir": "tsx ./src/cli/entry.ts --tsconfig './dummy_project/tsconfig.json' --LR", 40 | "run:instability": "tsx ./src/cli/entry.ts --measure-instability", 41 | "build": "tsc -p tsconfig.build.json", 42 | "build:re": "rm -r ./dist && npm run build", 43 | "commit": "git-cz", 44 | "commitmsg": "validate-commit-msg", 45 | "lint:fix": "eslint --fix src", 46 | "prettier": "prettier --write ./src", 47 | "type-check": "tsc --noEmit", 48 | "test": "vitest run src", 49 | "test:watch": "vitest src", 50 | "test:integration": "vitest run ./integration", 51 | "test:integration:update": "vitest run -u ./integration", 52 | "prepublishOnly": "npm run build", 53 | "prepare": "husky install", 54 | "semantic-release": "semantic-release" 55 | }, 56 | "author": "ysk8hori", 57 | "license": "ISC", 58 | "dependencies": { 59 | "chalk": "4.1.2", 60 | "chokidar": "4.0.3", 61 | "commander": "14.0.0", 62 | "console-table-printer": "2.12.1", 63 | "remeda": "2.19.1", 64 | "zod": "3.24.1" 65 | }, 66 | "devDependencies": { 67 | "@eslint/js": "9.18.0", 68 | "@total-typescript/ts-reset": "0.6.1", 69 | "@types/node": "22.10.7", 70 | "commitizen": "4.3.1", 71 | "cz-conventional-changelog": "3.3.0", 72 | "eslint": "9.28.0", 73 | "eslint-config-prettier": "10.1.5", 74 | "eslint-plugin-import": "2.31.0", 75 | "globals": "16.0.0", 76 | "husky": "9.1.7", 77 | "lint-staged": "16.0.0", 78 | "prettier": "3.4.2", 79 | "semantic-release": "24.2.1", 80 | "tsx": "4.19.2", 81 | "typescript": "5.7.3", 82 | "typescript-eslint": "8.20.0", 83 | "vitest": "3.0.4", 84 | "zx": "8.3.0" 85 | }, 86 | "config": { 87 | "commitizen": { 88 | "path": "cz-conventional-changelog" 89 | } 90 | }, 91 | "lint-staged": { 92 | "*.@(ts|tsx)": [ 93 | "eslint --fix", 94 | "npm run prettier --", 95 | "git add" 96 | ] 97 | }, 98 | "release": { 99 | "branches": [ 100 | "main" 101 | ] 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | extends: ['config:recommended'], 3 | baseBranches: ['main'], 4 | timezone: 'Asia/Tokyo', 5 | schedule: ['after 5pm and before 10pm on Thursday'], 6 | rangeStrategy: 'pin', 7 | packageRules: [ 8 | { 9 | matchUpdateTypes: ['major', 'minor'], 10 | labels: ['TypeScript'], 11 | matchPackageNames: ['typescript'], 12 | groupName: 'TypeScript', 13 | }, 14 | { 15 | groupName: 'ESLint related packages', 16 | packageNames: ['eslint', 'eslint-config-prettier'], 17 | packagePatterns: ['^@typescript-eslint/', '^eslint-plugin'], 18 | }, 19 | { 20 | groupName: 'All minor and patch dependencies', 21 | matchUpdateTypes: ['minor', 'patch'], 22 | labels: ['UPDATE-MINOR&PATCH'], 23 | excludePackageNames: ['typescript', 'eslint', 'eslint-config-prettier'], 24 | excludePackagePatterns: ['^@typescript-eslint/', '^eslint-plugin'], 25 | }, 26 | ], 27 | automerge: true, 28 | ignoreDeps: [ 29 | 'chalk', // 要 esm 対応 @see https://gist.github.com/sindresorhus/a39789f98801d908bbc7ff3ecc99d99c 30 | ], 31 | } 32 | -------------------------------------------------------------------------------- /src/cli/entry.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { Command } from 'commander'; 4 | import packagejson from '../../package.json'; 5 | import type { OptionValues } from '../setting/model'; 6 | import { generateTsg } from '../usecase/generateTsg'; 7 | import { watchMetrics } from '../usecase/watchMetrics'; 8 | 9 | const program = new Command(); 10 | program 11 | .name(packagejson.name) 12 | .description(packagejson.description) 13 | .version(packagejson.version); 14 | program 15 | .argument( 16 | '[include-files...]', 17 | 'Specify file paths or parts of file paths to include in the graph (relative to the tsconfig directory, without `./`).', 18 | '', 19 | ) 20 | .option( 21 | '--md [char]', 22 | 'Specify the name of the markdown file to be output. Default is typescript-graph.md.', 23 | ) 24 | .option( 25 | '--mermaid-link', 26 | 'Generates a link on node to open that file in VSCode.', 27 | ) 28 | .option( 29 | '-d, --dir [char]', 30 | 'Specifies the root directory of the TypeScript project to analyze. It reads and uses the tsconfig.json file found in this directory.', 31 | ) 32 | .option( 33 | '--tsconfig [char]', 34 | 'Specifies the path to the tsconfig file to use for analysis. If this option is provided, -d, --dir will be ignored.', 35 | ) 36 | .option( 37 | '--include [char...]', 38 | 'Specify file paths or parts of file paths to include in the graph (relative to the tsconfig directory, without `./`).', 39 | ) 40 | .option( 41 | '--exclude [char...]', 42 | 'Specify file paths or parts of file paths to exclude from the graph (relative to the tsconfig directory, without `./`).', 43 | ) 44 | .option('--abstraction [char...]', 'Specify the path to abstract') 45 | .option( 46 | '--highlight [char...]', 47 | 'Specify the path and file name to highlight', 48 | ) 49 | .option('--LR', 'Specify Flowchart orientation Left-to-Right') 50 | .option('--TB', 'Specify Flowchart orientation Top-to-Bottom') 51 | .option( 52 | '--measure-instability', 53 | 'Enable beta feature to measure module instability', 54 | ) 55 | .option( 56 | '--metrics', 57 | 'Enable beta feature to measures metrics such as Maintainability Index, Cyclomatic Complexity, and Cognitive Complexity.', 58 | ) 59 | .option('-w, --watch-metrics [char...]', 'watch metrics', '') 60 | .option( 61 | '--config-file', 62 | 'Specify the relative path to the config file (from cwd or specified by -d, --dir). Default is .tsgrc.json.', 63 | ) 64 | .option('--vue', '(experimental) Enable Vue.js support'); 65 | program.parse(); 66 | 67 | const opt = program.opts(); 68 | // tsg の arguments と --include オプションをマージする 69 | opt.include = [...program.args, ...(opt.include ?? [])]; 70 | opt.include = opt.include.length === 0 ? undefined : opt.include; 71 | 72 | if (opt.watchMetrics) { 73 | watchMetrics(opt); 74 | } else { 75 | const executedScript = `tsg ${process.argv.slice(2).join(' ')}`; 76 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 77 | generateTsg({ ...opt, executedScript }); 78 | } 79 | -------------------------------------------------------------------------------- /src/feature/graph/GraphAnalyzer.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { test, expect } from 'vitest'; 3 | import ts from 'typescript'; 4 | import AstLogger from '../util/AstLogger'; 5 | import AstTraverser from '../util/AstTraverser'; 6 | import { GraphAnalyzer } from './GraphAnalyzer'; 7 | 8 | test('GraphAnalyzer は Visitor として機能しソースファイルひとつ分のグラフを生成する', () => { 9 | const rootDir = path.resolve(__dirname, '../../../'); 10 | console.log('rootDir', rootDir); 11 | const system = { 12 | ...ts.sys, 13 | fileExists: () => true, 14 | directoryExists: () => true, 15 | } satisfies ts.System; 16 | const tsconfig = ts.parseJsonConfigFileContent({}, system, rootDir); 17 | tsconfig.options.rootDir = rootDir; 18 | const source = ts.createSourceFile( 19 | `${rootDir}/src/feature/graph/sample.tsx`, 20 | 'import a from "./a"; import { b } from \'../../b\'; import type { C } from "./c";', 21 | ts.ScriptTarget.ESNext, 22 | // parent を使うことがあるので true 23 | true, 24 | ts.ScriptKind.TS, 25 | ); 26 | const astLogger = new AstLogger(); 27 | const analyzer = new GraphAnalyzer(source, tsconfig, system); 28 | const astTraverser = new AstTraverser(source, [astLogger, analyzer]); 29 | astTraverser.traverse(); 30 | console.log(astLogger.log); 31 | expect(analyzer.generateGraph()).toMatchInlineSnapshot(` 32 | { 33 | "nodes": [ 34 | { 35 | "changeStatus": "not_modified", 36 | "name": "sample.tsx", 37 | "path": "src/feature/graph/sample.tsx", 38 | }, 39 | { 40 | "changeStatus": "not_modified", 41 | "name": "a.ts", 42 | "path": "src/feature/graph/a.ts", 43 | }, 44 | { 45 | "changeStatus": "not_modified", 46 | "name": "b.ts", 47 | "path": "src/b.ts", 48 | }, 49 | { 50 | "changeStatus": "not_modified", 51 | "name": "c.ts", 52 | "path": "src/feature/graph/c.ts", 53 | }, 54 | ], 55 | "relations": [ 56 | { 57 | "changeStatus": "not_modified", 58 | "from": { 59 | "changeStatus": "not_modified", 60 | "name": "sample.tsx", 61 | "path": "src/feature/graph/sample.tsx", 62 | }, 63 | "kind": "depends_on", 64 | "to": { 65 | "changeStatus": "not_modified", 66 | "name": "a.ts", 67 | "path": "src/feature/graph/a.ts", 68 | }, 69 | }, 70 | { 71 | "changeStatus": "not_modified", 72 | "from": { 73 | "changeStatus": "not_modified", 74 | "name": "sample.tsx", 75 | "path": "src/feature/graph/sample.tsx", 76 | }, 77 | "kind": "depends_on", 78 | "to": { 79 | "changeStatus": "not_modified", 80 | "name": "b.ts", 81 | "path": "src/b.ts", 82 | }, 83 | }, 84 | { 85 | "changeStatus": "not_modified", 86 | "from": { 87 | "changeStatus": "not_modified", 88 | "name": "sample.tsx", 89 | "path": "src/feature/graph/sample.tsx", 90 | }, 91 | "kind": "depends_on", 92 | "to": { 93 | "changeStatus": "not_modified", 94 | "name": "c.ts", 95 | "path": "src/feature/graph/c.ts", 96 | }, 97 | }, 98 | ], 99 | } 100 | `); 101 | }); 102 | -------------------------------------------------------------------------------- /src/feature/graph/GraphAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import ts from 'typescript'; 3 | import type { AstVisitor, VisitProps } from '../util/AstVisitor'; 4 | import type { Graph, Node, Relation } from './models'; 5 | 6 | export class GraphAnalyzer implements AstVisitor { 7 | static create( 8 | sourceFile: ts.SourceFile, 9 | tsconfig: ts.ParsedCommandLine, 10 | system: ts.System, 11 | ) { 12 | return new GraphAnalyzer(sourceFile, tsconfig, system); 13 | } 14 | 15 | constructor( 16 | sourceFile: ts.SourceFile, 17 | tsconfig: ts.ParsedCommandLine, 18 | system: ts.System, 19 | ) { 20 | this.#sourceFile = sourceFile; 21 | this.#tsconfig = tsconfig; 22 | this.#system = system; 23 | } 24 | 25 | readonly #sourceFile: ts.SourceFile; 26 | readonly #tsconfig: ts.ParsedCommandLine; 27 | readonly #system: ts.System; 28 | 29 | visit({ node }: VisitProps): void { 30 | const importPath = this.#getImportPath(node); 31 | if (!importPath) return; 32 | 33 | this.#addModuleFilePath(this.#getModuleFilePath(importPath)); 34 | return; 35 | } 36 | 37 | #getImportPath(node: ts.Node) { 38 | if (ts.isImportDeclaration(node)) { 39 | return node.moduleSpecifier?.getText(this.#sourceFile); 40 | } else if (ts.isCallExpression(node)) { 41 | const text = node.getText(this.#sourceFile); 42 | if (text.includes('require') || text.includes('import')) { 43 | return node.arguments[0]?.getText(this.#sourceFile); 44 | } 45 | } else if (ts.isExportDeclaration(node)) { 46 | return node.moduleSpecifier?.getText(this.#sourceFile); 47 | } 48 | } 49 | 50 | #getModuleFilePath(moduleNameText: string) { 51 | const moduleName = moduleNameText.slice(1, moduleNameText.length - 1); // import 文のクォート及びダブルクォートを除去 52 | const moduleFileFullName = 53 | ts.resolveModuleName( 54 | moduleName, 55 | this.#sourceFile.fileName, 56 | this.#tsconfig.options, 57 | this.#system, 58 | ).resolvedModule?.resolvedFileName ?? ''; 59 | const moduleFilePath = this.#getFilePath(moduleFileFullName); 60 | return moduleFilePath; 61 | } 62 | 63 | /** 64 | * 通常 ts.SourceFile の fileName は `/usr/ysk8/dev/typescript-graph/src/foo/bar` なのでそれを `src/foo/bar` に加工して返す。 65 | * 前提として、options に rootDir が指定されている必要がある。 66 | */ 67 | #getFilePath(fileName: string): string { 68 | return this.#tsconfig.options.rootDir 69 | ? fileName.replace(this.#tsconfig.options.rootDir + '/', '') 70 | : fileName; 71 | } 72 | 73 | #moduleFilePath: string[] = []; 74 | #addModuleFilePath(moduleFilePath: string | undefined) { 75 | if (!moduleFilePath) return; 76 | this.#moduleFilePath.push(moduleFilePath); 77 | } 78 | 79 | public generateGraph(): Graph { 80 | const nodes: Node[] = []; 81 | const relations: Relation[] = []; 82 | const filePath = this.#getFilePath(this.#sourceFile.fileName); 83 | const fileName = getName(this.#sourceFile.fileName); 84 | const fromNode: Node = { 85 | path: filePath, 86 | name: fileName, 87 | changeStatus: 'not_modified', 88 | }; 89 | nodes.push(fromNode); 90 | 91 | this.#moduleFilePath.forEach(moduleFilePath => { 92 | const toNode: Node = { 93 | path: moduleFilePath, 94 | name: getName(moduleFilePath), 95 | changeStatus: 'not_modified', 96 | }; 97 | if (!findNode(nodes, moduleFilePath)) { 98 | nodes.push(toNode); 99 | } 100 | relations.push({ 101 | kind: 'depends_on', 102 | from: fromNode, 103 | to: toNode, 104 | changeStatus: 'not_modified', 105 | }); 106 | }); 107 | return { nodes, relations }; 108 | } 109 | } 110 | 111 | /** 112 | * そのモジュールを表す文字列を抽出する。 113 | * node_modules の配下のモジュールの場合は詳細を見せないようにする。 114 | */ 115 | function getName(filePath: string) { 116 | if (!filePath.includes('node_modules')) return path.basename(filePath); 117 | 118 | const dirOrFileName = filePath.split('/'); 119 | const nodeModulesIndex = dirOrFileName.findIndex( 120 | name => name === 'node_modules', 121 | ); 122 | if (dirOrFileName[nodeModulesIndex + 1]?.startsWith('@')) { 123 | // @ で始まる node_modules 配下のディレクトリは、@hoge/fuga の形で返す 124 | return path.join( 125 | dirOrFileName[nodeModulesIndex + 1], 126 | dirOrFileName[nodeModulesIndex + 2], 127 | ); 128 | } 129 | // node_modules の直下の名前を返す 130 | return dirOrFileName[nodeModulesIndex + 1] ?? path.basename(filePath); 131 | } 132 | 133 | function findNode(nodes: Node[], filePath: string): Node | undefined { 134 | return nodes.find(node => node.path === filePath); 135 | } 136 | -------------------------------------------------------------------------------- /src/feature/graph/abstraction.abstractionPath.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { abstractionPath } from './abstraction'; 3 | 4 | test('/src/components/atoms/Button.tsx atoms = /src/components/atoms', () => { 5 | expect( 6 | abstractionPath('/src/components/atoms/Button.tsx', ['atoms']), 7 | ).toEqual('/src/components/atoms'); 8 | }); 9 | -------------------------------------------------------------------------------- /src/feature/graph/abstraction.getAbstractionDirArr.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest'; 2 | import { getAbstractionDirArr } from './abstraction'; 3 | import type { Node } from './models'; 4 | 5 | test('atoms matches /src/components/atoms/Button.tsx', () => { 6 | const node: Node = { 7 | name: 'Button.tsx', 8 | path: '/src/components/atoms/Button.tsx', 9 | changeStatus: 'not_modified', 10 | }; 11 | const abs: string[][] = [['foo'], ['atoms'], ['bar']]; 12 | expect(getAbstractionDirArr(abs, node)).toEqual(['atoms']); 13 | }); 14 | 15 | test('[components,atoms] matches /src/components/atoms/Button.tsx', () => { 16 | const node: Node = { 17 | name: 'Button.tsx', 18 | path: '/src/components/atoms/Button.tsx', 19 | changeStatus: 'not_modified', 20 | }; 21 | const abs: string[][] = [ 22 | ['components', 'foo'], 23 | ['components', 'atoms'], 24 | ['components', 'bar'], 25 | ]; 26 | expect(getAbstractionDirArr(abs, node)).toEqual(['components', 'atoms']); 27 | }); 28 | 29 | test('/src/components/atoms/Button.tsx is filterd by src,components,atoms', () => { 30 | const node: Node = { 31 | name: 'Button.tsx', 32 | path: '/src/components/atoms/Button.tsx', 33 | changeStatus: 'not_modified', 34 | }; 35 | const abs: string[][] = [ 36 | ['src', 'components', 'foo'], 37 | ['src', 'components', 'atoms'], 38 | ['src', 'components', 'bar'], 39 | ]; 40 | expect(getAbstractionDirArr(abs, node)).toEqual([ 41 | 'src', 42 | 'components', 43 | 'atoms', 44 | ]); 45 | }); 46 | 47 | test('/src/components/atoms/Button.tsx is not filterd by atom', () => { 48 | const node: Node = { 49 | name: 'Button.tsx', 50 | path: '/src/components/atoms/Button.tsx', 51 | changeStatus: 'not_modified', 52 | }; 53 | const abs: string[][] = [['atom']]; 54 | expect(getAbstractionDirArr(abs, node)).toBeUndefined(); 55 | }); 56 | test('/src/components/atoms/Button.tsx is not filterd by src,atoms', () => { 57 | const node: Node = { 58 | name: 'Button.tsx', 59 | path: '/src/components/atoms/Button.tsx', 60 | changeStatus: 'not_modified', 61 | }; 62 | const abs: string[][] = [['src', 'atoms']]; 63 | expect(getAbstractionDirArr(abs, node)).toBeUndefined(); 64 | }); 65 | test('/src/components/atoms/Button.tsx is not filterd by [["atom"],["src", "atoms"]]', () => { 66 | const node: Node = { 67 | name: 'Button.tsx', 68 | path: '/src/components/atoms/Button.tsx', 69 | changeStatus: 'not_modified', 70 | }; 71 | const abs: string[][] = [['atom'], ['src', 'atoms']]; 72 | expect(getAbstractionDirArr(abs, node)).toBeUndefined(); 73 | }); 74 | -------------------------------------------------------------------------------- /src/feature/graph/abstraction.spec.ts: -------------------------------------------------------------------------------- 1 | import { it, expect } from 'vitest'; 2 | import { abstraction } from './abstraction'; 3 | 4 | it('指定したディレクトリを抽象化できる', () => { 5 | expect( 6 | abstraction(['src/a/b', 'src/a/b/c'], { 7 | nodes: [ 8 | { 9 | path: 'src/a/a.ts', 10 | name: 'a.ts', 11 | changeStatus: 'not_modified', 12 | }, 13 | { 14 | path: 'src/a/b/b.ts', 15 | name: 'b.ts', 16 | changeStatus: 'not_modified', 17 | }, 18 | { 19 | path: 'src/a/b/c/c.ts', 20 | name: 'c.ts', 21 | changeStatus: 'not_modified', 22 | }, 23 | { 24 | path: 'src/a/b/c/d/d.ts', 25 | name: 'd.ts', 26 | changeStatus: 'not_modified', 27 | }, 28 | ], 29 | relations: [], 30 | }), 31 | ).toMatchInlineSnapshot(` 32 | { 33 | "nodes": [ 34 | { 35 | "changeStatus": "not_modified", 36 | "name": "a.ts", 37 | "path": "src/a/a.ts", 38 | }, 39 | { 40 | "changeStatus": "not_modified", 41 | "isDirectory": true, 42 | "name": "/b", 43 | "path": "src/a/b", 44 | }, 45 | ], 46 | "relations": [], 47 | } 48 | `); 49 | }); 50 | 51 | it('ファイル名を指定した場合、その対象はディレクトリ扱いにならない', () => { 52 | expect( 53 | abstraction(['src/a/b', 'src/a/b/c', 'src/a/a.ts'], { 54 | nodes: [ 55 | { 56 | path: 'src/a/a.ts', 57 | name: 'a.ts', 58 | changeStatus: 'not_modified', 59 | }, 60 | { 61 | path: 'src/a/b/b.ts', 62 | name: 'b.ts', 63 | changeStatus: 'not_modified', 64 | }, 65 | { 66 | path: 'src/a/b/c/c.ts', 67 | name: 'c.ts', 68 | changeStatus: 'not_modified', 69 | }, 70 | { 71 | path: 'src/a/b/c/d/d.ts', 72 | name: 'd.ts', 73 | changeStatus: 'not_modified', 74 | }, 75 | ], 76 | relations: [], 77 | }), 78 | ).toMatchInlineSnapshot(` 79 | { 80 | "nodes": [ 81 | { 82 | "changeStatus": "not_modified", 83 | "name": "a.ts", 84 | "path": "src/a/a.ts", 85 | }, 86 | { 87 | "changeStatus": "not_modified", 88 | "isDirectory": true, 89 | "name": "/b", 90 | "path": "src/a/b", 91 | }, 92 | ], 93 | "relations": [], 94 | } 95 | `); 96 | }); 97 | -------------------------------------------------------------------------------- /src/feature/graph/abstraction.ts: -------------------------------------------------------------------------------- 1 | import type { Graph, Node } from './models'; 2 | import { getUniqueRelations, isSameNode } from './models'; 3 | import { extractUniqueNodes } from './utils'; 4 | 5 | /** 6 | * absList で渡されたパス文字列に一致するディレクトリを抽象化する。 7 | * 8 | * 抽象化とは、そのディレクトリに含まれるファイルや子孫ディレクトリを Graph 上では見せず、子孫の関わる Relation を abs で指定されたディレクトリに集約することを言う。 9 | * 10 | * abs で指定するパス文字列は、フルパスの一部分で良いが、ディレクトリ名とその順番には完全一致しなくてはならない。 11 | * 12 | * 例として、`/src/components/atoms` を抽象化したい場合、以下の文字列を指定すると抽象化できる。 13 | * 14 | * - `/src/components/atoms` 15 | * - `src/components/atoms` 16 | * - `/components/atoms` 17 | * - `components/atoms` 18 | * - `/atoms` 19 | * - `atoms` 20 | * 21 | * 以下の文字列では抽象化できない。 22 | * 23 | * - `atom` 24 | * - `atoms2` 25 | * - `components/atom` 26 | * - `onponents/atoms` 27 | * - `atoms/components` 28 | * - `src/atoms` 29 | * 30 | * @param graph 31 | * @param absArray 32 | * @returns 33 | */ 34 | export function abstraction( 35 | absArray: string[] | undefined, 36 | graph: Graph, 37 | ): Graph { 38 | if (!absArray || absArray.length === 0) return graph; 39 | const absDirArrArr = absArray 40 | .map(abs => abs.split('/')) 41 | .filter(absDirArray => absDirArray.at(0) !== undefined) 42 | .map(absDirArray => 43 | absDirArray.at(0) === '' ? absDirArray.slice(1) : absDirArray, 44 | ); 45 | const { nodes: _nodes, relations: _relations } = graph; 46 | 47 | // abs 対象ノードを抽象化する 48 | const nodes = _nodes.map(node => abstractionNode(node, absDirArrArr)); 49 | 50 | const relations = getUniqueRelations( 51 | _relations 52 | .map(original => ({ 53 | ...original, 54 | from: abstractionNode(original.from, absDirArrArr), 55 | to: abstractionNode(original.to, absDirArrArr), 56 | })) 57 | .filter(relation => !isSameNode(relation.from, relation.to)), 58 | ); 59 | 60 | return { 61 | nodes: extractUniqueNodes({ nodes, relations }), 62 | relations: relations, 63 | }; 64 | } 65 | 66 | function abstractionNode(node: Node, absDirArrArr: string[][]): Node { 67 | const absDirArr = getAbstractionDirArr(absDirArrArr, node); 68 | if (!absDirArr || absDirArr.at(-1) === node.name) return node; 69 | return { 70 | name: `/${absDirArr.at(-1)}`, 71 | path: abstractionPath(node.path, absDirArr), 72 | isDirectory: true, 73 | changeStatus: 'not_modified', 74 | }; 75 | } 76 | 77 | export function abstractionPath(path: string, absDirArr: string[]): string { 78 | const dirArrFromPath = path.split('/'); 79 | return dirArrFromPath 80 | .slice(0, dirArrFromPath.findIndex(dir => absDirArr.at(-1) === dir) + 1) 81 | .join('/'); 82 | } 83 | 84 | export function getAbstractionDirArr( 85 | absDirArrArr: string[][], 86 | node: Node, 87 | ): string[] | undefined { 88 | const targetDirArr = node.path.split('/'); 89 | return absDirArrArr.find(absDirArr => { 90 | // このブロックは、当該ノードが指定した abs に該当する場合に true を返す。 91 | // abs 先頭の dir を含む index を見つける。 92 | const startIndex = targetDirArr.findIndex(dir => dir === absDirArr.at(0)); 93 | if (startIndex === -1) return false; 94 | return absDirArr 95 | .slice(1) 96 | .every((absDir, i) => absDir === targetDirArr.at(startIndex + i + 1)); 97 | }); 98 | } 99 | -------------------------------------------------------------------------------- /src/feature/graph/filterGraph.spec.data/exclude.json: -------------------------------------------------------------------------------- 1 | ["excludeFiles", "utils"] 2 | -------------------------------------------------------------------------------- /src/feature/graph/filterGraph.spec.data/include.json: -------------------------------------------------------------------------------- 1 | ["includeFiles", "config"] 2 | -------------------------------------------------------------------------------- /src/feature/graph/filterGraph.spec.data/noconfig.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /src/feature/graph/filterGraph.spec.data/nodes.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "src/utils.ts", 4 | "name": "utils.ts", 5 | "changeStatus": "not_modified" 6 | }, 7 | { 8 | "path": "src/includeFiles/b.ts", 9 | "name": "b.ts", 10 | "changeStatus": "not_modified" 11 | }, 12 | { 13 | "path": "src/config.ts", 14 | "name": "config.ts", 15 | "changeStatus": "not_modified" 16 | }, 17 | { 18 | "path": "src/includeFiles/c.ts", 19 | "name": "c.ts", 20 | "changeStatus": "not_modified" 21 | }, 22 | { 23 | "path": "src/config.ts", 24 | "name": "config.ts", 25 | "changeStatus": "not_modified" 26 | }, 27 | { 28 | "path": "src/includeFiles/children/childA.ts", 29 | "name": "childA.ts", 30 | "changeStatus": "not_modified" 31 | }, 32 | { 33 | "path": "src/includeFiles/excludeFiles/style/style.ts", 34 | "name": "style.ts", 35 | "changeStatus": "not_modified" 36 | }, 37 | { 38 | "path": "src/includeFiles/excludeFiles/class/classA.ts", 39 | "name": "classA.ts", 40 | "changeStatus": "not_modified" 41 | }, 42 | { 43 | "path": "src/includeFiles/excludeFiles/i.ts", 44 | "name": "i.ts", 45 | "changeStatus": "not_modified" 46 | }, 47 | { 48 | "path": "src/includeFiles/d/d.ts", 49 | "name": "d.ts", 50 | "changeStatus": "not_modified" 51 | }, 52 | { 53 | "path": "src/includeFiles/d/index.ts", 54 | "name": "index.ts", 55 | "changeStatus": "not_modified" 56 | }, 57 | { 58 | "path": "src/includeFiles/a.ts", 59 | "name": "a.ts", 60 | "changeStatus": "not_modified" 61 | }, 62 | { 63 | "path": "src/includeFiles/excludeFiles/g.ts", 64 | "name": "g.ts", 65 | "changeStatus": "not_modified" 66 | }, 67 | { 68 | "path": "src/includeFiles/excludeFiles/h.ts", 69 | "name": "h.ts", 70 | "changeStatus": "not_modified" 71 | }, 72 | { 73 | "path": "src/includeFiles/excludeFiles/butInclude.ts", 74 | "name": "butInclude.ts", 75 | "changeStatus": "not_modified" 76 | }, 77 | { 78 | "path": "src/includeFiles/excludeFiles/dependsFromButInclude.ts", 79 | "name": "dependsFromButInclude.ts", 80 | "changeStatus": "not_modified" 81 | }, 82 | { 83 | "path": "src/otherFiles/children/:id.json", 84 | "name": ":id.json", 85 | "changeStatus": "not_modified" 86 | }, 87 | { 88 | "path": "src/otherFiles/children/{id}.json", 89 | "name": "{id}.json", 90 | "changeStatus": "not_modified" 91 | }, 92 | { 93 | "path": "src/otherFiles/children/childA.ts", 94 | "name": "childA.ts", 95 | "changeStatus": "not_modified" 96 | }, 97 | { 98 | "path": "src/otherFiles/d.ts", 99 | "name": "d.ts", 100 | "changeStatus": "not_modified" 101 | }, 102 | { 103 | "path": "src/otherFiles/f.ts", 104 | "name": "f.ts", 105 | "changeStatus": "not_modified" 106 | }, 107 | { 108 | "path": "src/otherFiles/e.ts", 109 | "name": "e.ts", 110 | "changeStatus": "not_modified" 111 | }, 112 | { 113 | "path": "src/includeFiles/abstractions/children/childA.ts", 114 | "name": "childA.ts", 115 | "changeStatus": "not_modified" 116 | }, 117 | { 118 | "path": "data.json", 119 | "name": "data.json", 120 | "changeStatus": "not_modified" 121 | }, 122 | { 123 | "path": "src/includeFiles/abstractions/j.ts", 124 | "name": "j.ts", 125 | "changeStatus": "not_modified" 126 | }, 127 | { 128 | "path": "src/includeFiles/abstractions/l.ts", 129 | "name": "l.ts", 130 | "changeStatus": "not_modified" 131 | }, 132 | { 133 | "path": "src/includeFiles/abstractions/k.ts", 134 | "name": "k.ts", 135 | "changeStatus": "not_modified" 136 | }, 137 | { 138 | "path": "src/main.ts", 139 | "name": "main.ts", 140 | "changeStatus": "not_modified" 141 | } 142 | ] 143 | -------------------------------------------------------------------------------- /src/feature/graph/filterGraph.ts: -------------------------------------------------------------------------------- 1 | import type { Graph, Node } from './models'; 2 | import { getUniqueNodes, getUniqueRelations, isSameNode } from './models'; 3 | import { extractUniqueNodes } from './utils'; 4 | 5 | /** word に該当するか */ 6 | const bindMatchFunc = (word: string) => (node: Node) => 7 | node.path.toLowerCase().includes(word.toLowerCase()); 8 | /** word に完全一致するか */ 9 | const bindExactMatchFunc = (word: string) => (node: Node) => node.path === word; 10 | /** 抽象的な判定関数 */ 11 | const judge = (node: Node) => (f: (node: Node) => boolean) => f(node); 12 | 13 | const isMatchSome = (words: string[]) => (node: Node) => 14 | words.map(bindMatchFunc).some(judge(node)); 15 | const isExactMatchSome = (words: string[]) => (node: Node) => 16 | words.map(bindExactMatchFunc).some(judge(node)); 17 | 18 | export function filterGraph( 19 | _include: string[] | undefined, 20 | _exclude: string[] | undefined, 21 | { nodes, relations }: Graph, 22 | ) { 23 | let tmpNodes = [...nodes]; 24 | let tmpRelations = [...relations]; 25 | const include = _include ?? []; 26 | const exclude = _exclude ?? []; 27 | 28 | const isMatchSomeIncludes = isMatchSome(include); 29 | const isExactMatchSomeIncludes = isExactMatchSome(include); 30 | const isMatchSomeExcludes = isMatchSome(exclude); 31 | // const isExactMatchSomeExcludes = isExactMatchSome(exclude); 32 | 33 | if (include.length !== 0) { 34 | tmpNodes = tmpNodes.filter(isMatchSomeIncludes); 35 | tmpRelations = tmpRelations.filter( 36 | ({ from, to }) => isMatchSomeIncludes(from) || isMatchSomeIncludes(to), 37 | ); 38 | } 39 | if (exclude.length !== 0) { 40 | tmpNodes = tmpNodes.filter( 41 | node => isExactMatchSomeIncludes(node) || !isMatchSomeExcludes(node), 42 | ); 43 | tmpRelations = tmpRelations.filter(({ from, to }) => { 44 | /** from が exclude に含まれるか */ 45 | const isFromMatchSomeExcludes = isMatchSomeExcludes(from); 46 | /** to が exclude に含まれるか */ 47 | const isToMatchSomeExcludes = isMatchSomeExcludes(to); 48 | 49 | // from と to が exclude に含まれる 50 | if (isFromMatchSomeExcludes && isToMatchSomeExcludes) { 51 | // from と to が 両方とも include に完全一致する場合は残し、そうでない場合は除外する 52 | return isExactMatchSomeIncludes(from) && isExactMatchSomeIncludes(to); 53 | } 54 | // from が exclude に含まれる 55 | if (isFromMatchSomeExcludes) { 56 | // from が include に完全一致する場合は残し、そうでない場合は除外する 57 | return isExactMatchSomeIncludes(from); 58 | } 59 | // to が exclude に含まれる 60 | if (isToMatchSomeExcludes) { 61 | // to が include に完全一致する場合は残し、そうでない場合は除外する 62 | return isExactMatchSomeIncludes(to); 63 | } 64 | // from と to が exclude に含まれない 65 | return true; 66 | }); 67 | } 68 | // 失われた relation の復元。 69 | // from と to の両方が include に含まれない relation が手前の処理で除外されるが、 70 | // 片方が include に含まれていて残った relation(変数名はtmpRelations) の include 対象外の node 同士が本来繋がっている場合があるので、 71 | // それを復元する。 72 | tmpRelations = getUniqueRelations( 73 | tmpRelations.concat( 74 | relations.filter(({ from, to }) => { 75 | const relationNodes = getUniqueNodes( 76 | tmpRelations.map(({ from, to }) => [from, to]).flat(), 77 | ).filter(node => tmpNodes.some(tmpNode => !isSameNode(node, tmpNode))); 78 | if ( 79 | relationNodes.some(node => isSameNode(node, from)) && 80 | relationNodes.some(node => isSameNode(node, to)) 81 | ) { 82 | return true; 83 | } 84 | return false; 85 | }), 86 | ), 87 | ); 88 | 89 | return { 90 | nodes: extractUniqueNodes({ nodes: tmpNodes, relations: tmpRelations }), 91 | relations: tmpRelations, 92 | }; 93 | } 94 | -------------------------------------------------------------------------------- /src/feature/graph/highlight.ts: -------------------------------------------------------------------------------- 1 | import type { Graph, Node } from './models'; 2 | 3 | export function highlight( 4 | highlightArray: string[] | undefined, 5 | graph: Graph, 6 | ): Graph { 7 | if (!highlightArray || highlightArray.length === 0) return graph; 8 | const nodes = graph.nodes.map(node => { 9 | if ( 10 | highlightArray.some(word => 11 | node.path.toLowerCase().includes(word.toLowerCase()), 12 | ) 13 | ) { 14 | return { ...node, highlight: true }; 15 | } 16 | return node; 17 | }); 18 | return { ...graph, nodes }; 19 | } 20 | -------------------------------------------------------------------------------- /src/feature/graph/instability.ts: -------------------------------------------------------------------------------- 1 | import type { Graph, Node } from './models'; 2 | 3 | export type NodeWithInstability = Node & { 4 | afferentCoupling: number; 5 | efferentCoupling: number; 6 | instability: number; 7 | }; 8 | 9 | export function measureInstability(graph: Graph): NodeWithInstability[] { 10 | const couplingData = graph.nodes 11 | .filter(node => !node.isDirectory) 12 | .filter( 13 | node => !/node_modules|\.{test|spec|stories}\.|.json$/.test(node.path), 14 | ) 15 | .map(node => { 16 | // このノードに依存しているノードの数 17 | const afferentCoupling = graph.relations.filter( 18 | r => r.kind === 'depends_on' && r.to.path === node.path, 19 | ).length; 20 | // このノードが依存しているノードの数 21 | const efferentCoupling = graph.relations.filter( 22 | r => r.kind === 'depends_on' && r.from.path === node.path, 23 | ).length; 24 | return { ...node, afferentCoupling, efferentCoupling }; 25 | }) 26 | .map(node => { 27 | const totalCoupling = node.afferentCoupling + node.efferentCoupling; 28 | const instability = 29 | totalCoupling === 0 ? 0 : node.efferentCoupling / totalCoupling; 30 | return { ...node, instability }; 31 | }) 32 | .toSorted((a, b) => { 33 | return b.efferentCoupling - a.efferentCoupling; 34 | }) 35 | 36 | .toSorted((a, b) => { 37 | const totalCouplingA = a.afferentCoupling + a.efferentCoupling; 38 | const totalCouplingB = b.afferentCoupling + b.efferentCoupling; 39 | return totalCouplingB - totalCouplingA; 40 | }) 41 | .toSorted((a, b) => { 42 | return b.instability - a.instability; 43 | }); 44 | return couplingData; 45 | } 46 | 47 | export function writeCouplingData( 48 | write: (str: string) => void, 49 | couplingData: ReturnType, 50 | ) { 51 | if (couplingData.length === 0) return; 52 | write('## Instability\n'); 53 | write('\n'); 54 | write( 55 | 'module name | Afferent
coupling | Efferent
coupling | Instability\n', 56 | ); 57 | write('--|--|--|--\n'); 58 | 59 | couplingData.forEach(node => { 60 | write( 61 | `${node.path} | ${node.afferentCoupling} | ${node.efferentCoupling} | ${node.instability.toFixed(2)}\n`, 62 | ); 63 | }); 64 | write('\n'); 65 | } 66 | -------------------------------------------------------------------------------- /src/feature/graph/models.ts: -------------------------------------------------------------------------------- 1 | type FileName = string; 2 | type DirName = string; 3 | type FilePath = string; 4 | export type ChangeStatus = 'not_modified' | 'created' | 'modified' | 'deleted'; 5 | export interface Node { 6 | path: FilePath; 7 | name: FileName | DirName; 8 | isDirectory?: boolean; 9 | highlight?: boolean; 10 | changeStatus: ChangeStatus; 11 | } 12 | 13 | export type RelationType = 'depends_on' | 'rename_to'; 14 | export interface RelationOfDependsOn { 15 | kind: 'depends_on'; 16 | from: Node; 17 | to: Node; 18 | changeStatus: ChangeStatus; 19 | } 20 | export interface RelationOfRenameTo { 21 | kind: 'rename_to'; 22 | from: Node; 23 | to: Node; 24 | } 25 | export type Relation = RelationOfDependsOn | RelationOfRenameTo; 26 | export interface Graph { 27 | nodes: Node[]; 28 | relations: Relation[]; 29 | } 30 | export interface Meta { 31 | rootDir: string; 32 | } 33 | 34 | export function isSameNode(a: Node, b: Node): boolean { 35 | return a.path === b.path; 36 | } 37 | export function isSameRelation(a: Relation, b: Relation): boolean { 38 | return ( 39 | a.kind === b.kind && isSameNode(a.from, b.from) && isSameNode(a.to, b.to) 40 | ); 41 | } 42 | /** 受け取った relation の重複をなくす */ 43 | export function getUniqueRelations(relations: Relation[]): Relation[] { 44 | return relations.reduce((prev, current) => { 45 | if (prev.some(rel => isSameRelation(rel, current))) return prev; 46 | prev.push(current); 47 | return prev; 48 | }, new Array()); 49 | } 50 | 51 | /** 受け取った node の重複をなくす */ 52 | export function getUniqueNodes(nodes: Node[]): Node[] { 53 | return nodes.reduce((prev, current) => { 54 | if (prev.some(rel => isSameNode(rel, current))) return prev; 55 | prev.push(current); 56 | return prev; 57 | }, new Array()); 58 | } 59 | -------------------------------------------------------------------------------- /src/feature/graph/refineGraph.ts: -------------------------------------------------------------------------------- 1 | import { piped } from 'remeda'; 2 | import { getConfig } from '../../setting/config'; 3 | import type { OptionValues } from '../../setting/model'; 4 | import { abstraction } from './abstraction'; 5 | import { filterGraph } from './filterGraph'; 6 | import { highlight } from './highlight'; 7 | import type { Graph } from './models'; 8 | 9 | /** filter, abstraction, highlight を行う */ 10 | export const bind_refineGraph = ( 11 | commandOptions: OptionValues, 12 | ): ((fullGraph: Graph) => Graph) => 13 | piped( 14 | graph => 15 | filterGraph( 16 | commandOptions.include, 17 | [...(getConfig().exclude ?? []), ...(commandOptions.exclude ?? [])], 18 | graph, 19 | ), 20 | graph => abstraction(commandOptions.abstraction, graph), 21 | graph => highlight(commandOptions.highlight, graph), 22 | ); 23 | -------------------------------------------------------------------------------- /src/feature/graph/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Graph, Node } from './models'; 2 | import { 3 | getUniqueNodes, 4 | getUniqueRelations, 5 | isSameNode, 6 | isSameRelation, 7 | } from './models'; 8 | 9 | /** 10 | * nodes と relations をマージしたユニークな node のリストを作り直す。 11 | * フィルタリング処理などで「relations にあるが nodes にない」が発生したりするので必要。 12 | */ 13 | export function extractUniqueNodes({ nodes, relations }: Graph): Node[] { 14 | const allNodes = getUniqueNodes([ 15 | ...nodes, 16 | ...relations.map(({ from, to }) => [from, to]).flat(), 17 | ]); 18 | return allNodes; 19 | } 20 | 21 | export function mergeGraph(graphs: Graph[]): Graph { 22 | const nodes = getUniqueNodes(graphs.map(graph => graph.nodes).flat()); 23 | const relations = getUniqueRelations( 24 | graphs.map(graph => graph.relations).flat(), 25 | ); 26 | return { nodes, relations }; 27 | } 28 | 29 | export function updateChangeStatusFromDiff(base: Graph, head: Graph): void { 30 | const { nodes: baseNodes, relations: baseRelations } = base; 31 | const { nodes: headNodes, relations: headRelations } = head; 32 | 33 | headNodes.forEach(current => { 34 | for (const baseNode of baseNodes) { 35 | if (!isSameNode(baseNode, current)) { 36 | baseNode.changeStatus = 'deleted'; 37 | break; 38 | } 39 | } 40 | }); 41 | 42 | baseNodes.forEach(current => { 43 | for (const headNode of headNodes) { 44 | if (!isSameNode(headNode, current)) { 45 | headNode.changeStatus = 'created'; 46 | break; 47 | } 48 | } 49 | }); 50 | 51 | headRelations.forEach(current => { 52 | for (const baseRelation of baseRelations) { 53 | if ( 54 | !isSameRelation(baseRelation, current) && 55 | baseRelation.kind === 'depends_on' 56 | ) { 57 | baseRelation.changeStatus = 'deleted'; 58 | } 59 | } 60 | }); 61 | 62 | baseRelations.forEach(current => { 63 | for (const headRelation of headRelations) { 64 | if ( 65 | !isSameRelation(headRelation, current) && 66 | headRelation.kind === 'depends_on' 67 | ) { 68 | headRelation.changeStatus = 'created'; 69 | break; 70 | } 71 | } 72 | }); 73 | } 74 | -------------------------------------------------------------------------------- /src/feature/mermaid/mermaidify.fileNameToMermaidId.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, expect, test } from 'vitest'; 2 | import { setupConfig } from '../../setting/config'; 3 | import { fileNameToMermaidId } from './mermaidify'; 4 | 5 | beforeAll(() => setupConfig()); 6 | 7 | test.each([ 8 | [ 9 | 'dummy_project/src/otherFiles/children/:id.json', 10 | 'dummy//project/src/otherFiles/children/:id.json', 11 | ], 12 | [ 13 | 'dummy_project/src/otherFiles/children/{id}.json', 14 | 'dummy//project/src/otherFiles/children///id//.json', 15 | ], 16 | ['/graph/style/graph/class/end', '/_graph__/style_/_graph__/class_/end_'], 17 | ])('fileNameToMermaidId', (fileName, expected) => { 18 | expect(fileNameToMermaidId(fileName)).toEqual(expected); 19 | }); 20 | -------------------------------------------------------------------------------- /src/feature/mermaid/mermaidify.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { Graph, Node } from '../graph/models'; 3 | import { getConfig } from '../../setting/config'; 4 | import type { OptionValues } from '../../setting/model'; 5 | 6 | /** ディレクトリツリーを表現するオブジェクト */ 7 | interface DirAndNodesTree { 8 | currentDir: string; 9 | nodes: Node[]; 10 | children: DirAndNodesTree[]; 11 | } 12 | type Options = Omit & { 13 | rootDir: string; 14 | }; 15 | 16 | const indent = ' '; 17 | const CLASSNAME_DIR = 'dir'; 18 | const CLASSNAME_HIGHLIGHT = 'highlight'; 19 | const CLASSNAME_CREATED = 'created'; 20 | const CLASSNAME_MODIFIED = 'modified'; 21 | const CLASSNAME_DELETED = 'deleted'; 22 | 23 | export function writeGraph( 24 | write: (str: string) => void, 25 | graph: Graph, 26 | options: Options, 27 | ) { 28 | write('```mermaid\n'); 29 | mermaidify(str => write(str), graph, options); 30 | write('```\n'); 31 | write('\n'); 32 | } 33 | 34 | export function mermaidify( 35 | write: (arg: string) => void, 36 | graph: Graph, 37 | options: Pick, 38 | ) { 39 | // フローチャートの方向を指定 40 | if (options.LR) { 41 | write(`flowchart LR\n`); 42 | } else if (options.TB) { 43 | write(`flowchart TB\n`); 44 | } else { 45 | write(`flowchart\n`); 46 | } 47 | 48 | // 抽象化フラグが立っている場合は、クラス定義を追加 49 | if (options.abstraction) 50 | write(`${indent}classDef ${CLASSNAME_DIR} fill:#0000,stroke:#999\n`); 51 | 52 | // ハイライトフラグが立っている場合は、クラス定義を追加 53 | if (options.highlight) 54 | write(`${indent}classDef ${CLASSNAME_HIGHLIGHT} fill:yellow,color:black\n`); 55 | 56 | // created のノードがある場合は、クラス定義を追加 57 | if (graph.nodes.some(node => node.changeStatus === 'created')) 58 | write( 59 | `${indent}classDef ${CLASSNAME_CREATED} fill:cyan,stroke:#999,color:black\n`, 60 | ); 61 | 62 | // modified のノードがある場合は、クラス定義を追加 63 | if (graph.nodes.some(node => node.changeStatus === 'modified')) 64 | write( 65 | `${indent}classDef ${CLASSNAME_MODIFIED} fill:yellow,stroke:#999,color:black\n`, 66 | ); 67 | 68 | // deleted のノードがある場合は、クラス定義を追加 69 | if (graph.nodes.some(node => node.changeStatus === 'deleted')) 70 | write( 71 | `${indent}classDef ${CLASSNAME_DELETED} fill:dimgray,stroke:#999,color:black,stroke-dasharray: 4 4,stroke-width:2px;\n`, 72 | ); 73 | 74 | const dirAndNodesTree = createDirAndNodesTree(graph); 75 | writeFileNodesWithSubgraph(write, dirAndNodesTree); 76 | writeRelations(write, graph); 77 | 78 | // TODO: いつか復活させる 79 | // if (options.mermaidLink) { 80 | // writeFileLink(write, dirAndNodesTree, options.rootDir); 81 | // } 82 | } 83 | 84 | /** 85 | * Graph からディレクトリツリーを再現した DirAndNodesTree の配列を生成する 86 | */ 87 | function createDirAndNodesTree(graph: Graph): DirAndNodesTree[] { 88 | function getDirectoryPath(filePath: string) { 89 | const array = filePath.split('/'); 90 | if (array.includes('node_modules')) { 91 | // node_modules より深いディレクトリ階層の情報は捨てる 92 | // node_modules 内の node の name はパッケージ名のようなものになっているのでそれで良い 93 | return 'node_modules'; 94 | } else if (array.length === 1) { 95 | // トップレベルのファイルの場合 96 | return undefined; 97 | } else { 98 | // 末尾のファイル名は不要 99 | return path.join(...array.slice(0, array.length - 1)); 100 | } 101 | } 102 | 103 | const allDir = graph.nodes 104 | .map(({ path }) => getDirectoryPath(path)) 105 | .map(dirPath => { 106 | if (!dirPath) return undefined; 107 | const dirArray = dirPath.split('/'); 108 | return dirArray.reduce((prev, current) => { 109 | const prevValue = prev.at(-1); 110 | if (prevValue) { 111 | prev.push(path.join(prevValue, current)); 112 | } else { 113 | prev.push(current); 114 | } 115 | return prev; 116 | }, new Array()); 117 | }) 118 | .flat() 119 | .reduce((pre, current) => { 120 | if (!current) return pre; 121 | // 重複除去 122 | if (pre.some(filePath => filePath === current)) return pre; 123 | pre.push(current); 124 | return pre; 125 | }, new Array()); 126 | 127 | interface DirAndNodes { 128 | currentDir: string; 129 | dirHierarchy: string[]; 130 | nodes: Node[]; 131 | } 132 | 133 | const dirAndNodes: DirAndNodes[] = allDir.map(currentDir => ({ 134 | currentDir, 135 | dirHierarchy: currentDir.split('/'), 136 | nodes: graph.nodes.filter( 137 | node => getDirectoryPath(node.path) === currentDir, 138 | ), 139 | })); 140 | 141 | function isChild(parentDirHierarchy: string[], candidate: string[]) { 142 | if (parentDirHierarchy.length !== candidate.length - 1) return false; 143 | return parentDirHierarchy.every( 144 | (tmpdirname, i) => tmpdirname === candidate[i], 145 | ); 146 | } 147 | 148 | function createDirAndNodesRecursive({ 149 | currentDir, 150 | nodes, 151 | dirHierarchy, 152 | }: DirAndNodes): DirAndNodesTree[] { 153 | if ( 154 | nodes.length === 0 && 155 | dirAndNodes.filter(item => isChild(dirHierarchy, item.dirHierarchy)) 156 | .length <= 1 157 | ) { 158 | return dirAndNodes 159 | .filter(item => isChild(dirHierarchy, item.dirHierarchy)) 160 | .map(createDirAndNodesRecursive) 161 | .flat(); 162 | } 163 | return [ 164 | { 165 | currentDir, 166 | nodes, 167 | children: dirAndNodes 168 | .filter(item => isChild(dirHierarchy, item.dirHierarchy)) 169 | .map(createDirAndNodesRecursive) 170 | .flat(), 171 | }, 172 | ]; 173 | } 174 | 175 | const dirAndNodesTree = dirAndNodes 176 | .filter(dirAndNode => dirAndNode.dirHierarchy.length === 1) 177 | .map(createDirAndNodesRecursive) 178 | .flat(); 179 | return dirAndNodesTree; 180 | } 181 | 182 | function writeRelations(write: (arg: string) => void, graph: Graph) { 183 | graph.relations 184 | .map(relation => ({ 185 | ...relation, 186 | from: { 187 | ...relation.from, 188 | mermaidId: fileNameToMermaidId(relation.from.path), 189 | }, 190 | to: { 191 | ...relation.to, 192 | mermaidId: fileNameToMermaidId(relation.to.path), 193 | }, 194 | })) 195 | .forEach(relation => { 196 | if (relation.kind === 'rename_to') { 197 | write( 198 | ` ${relation.from.mermaidId}-.->|"rename to"|${relation.to.mermaidId}`, 199 | ); 200 | } else if (relation.changeStatus === 'deleted') { 201 | write(` ${relation.from.mermaidId}-.->${relation.to.mermaidId}`); 202 | } else { 203 | write(` ${relation.from.mermaidId}-->${relation.to.mermaidId}`); 204 | } 205 | write('\n'); 206 | }); 207 | } 208 | 209 | export function fileNameToMermaidId(fileName: string): string { 210 | return getConfig().reservedMermaidKeywords.reduce( 211 | (prev, [from, to]) => prev.replaceAll(from, to), 212 | fileName.split(/@|\[|\]|-|>|<|{|}|\(|\)|=|&|\|~|,|"|%|\^|\*|_/).join('//'), 213 | ); 214 | } 215 | 216 | function fileNameToMermaidName(fileName: string): string { 217 | return fileName.split(/"/).join('//'); 218 | } 219 | 220 | function writeFileNodesWithSubgraph( 221 | write: (arg: string) => void, 222 | trees: DirAndNodesTree[], 223 | ) { 224 | trees.forEach(tree => addGraph(write, tree)); 225 | } 226 | 227 | function addGraph( 228 | write: (arg: string) => void, 229 | tree: DirAndNodesTree, 230 | indentNumber = 0, 231 | parent?: string, 232 | ) { 233 | let _indent = indent; 234 | for (let i = 0; i < indentNumber; i++) { 235 | _indent = _indent + indent; 236 | } 237 | write( 238 | `${_indent}subgraph ${fileNameToMermaidId( 239 | tree.currentDir, 240 | )}["${fileNameToMermaidName( 241 | parent ? tree.currentDir.replace(parent, '') : tree.currentDir, 242 | )}"]`, 243 | ); 244 | write('\n'); 245 | tree.nodes 246 | .map(node => ({ ...node, mermaidId: fileNameToMermaidId(node.path) })) 247 | .forEach(node => { 248 | const classString = (function () { 249 | if (node.highlight) { 250 | return `:::${CLASSNAME_HIGHLIGHT}`; 251 | } else if (node.isDirectory) { 252 | return `:::${CLASSNAME_DIR}`; 253 | } 254 | switch (node.changeStatus) { 255 | case 'created': 256 | return `:::${CLASSNAME_CREATED}`; 257 | case 'modified': 258 | return `:::${CLASSNAME_MODIFIED}`; 259 | case 'deleted': 260 | return `:::${CLASSNAME_DELETED}`; 261 | default: 262 | return ''; 263 | } 264 | })(); 265 | write( 266 | `${_indent}${indent}${node.mermaidId}["${fileNameToMermaidName( 267 | node.name, 268 | )}"]${classString}`, 269 | ); 270 | write('\n'); 271 | }); 272 | tree.children.forEach(child => 273 | addGraph(write, child, indentNumber + 1, tree.currentDir), 274 | ); 275 | write(`${_indent}end`); 276 | write('\n'); 277 | } 278 | 279 | // TODO: いつか復活させる 280 | // function writeFileLink( 281 | // write: (arg: string) => void, 282 | // trees: DirAndNodesTree[], 283 | // rootDir: string, 284 | // ) { 285 | // trees.forEach(tree => addLink(write, tree, rootDir)); 286 | // } 287 | 288 | // TODO: いつか復活させる 289 | // function addLink( 290 | // write: (arg: string) => void, 291 | // tree: DirAndNodesTree, 292 | // rootDir: string, 293 | // ): void { 294 | // tree.nodes 295 | // .map(node => ({ ...node, mermaidId: fileNameToMermaidId(node.path) })) 296 | // .forEach(node => { 297 | // write( 298 | // `${indent}click ${node.mermaidId} href "vscode://file/${path.join( 299 | // rootDir, 300 | // node.path, 301 | // )}" _blank`, 302 | // ); 303 | // write('\n'); 304 | // }); 305 | // tree.children.forEach(child => addLink(write, child, rootDir)); 306 | // } 307 | -------------------------------------------------------------------------------- /src/feature/metric/HierarchicalMetricsAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import type { AstVisitor, Leave, VisitProps } from '../util/AstVisitor'; 2 | import type { HierarchicalMetris } from './HierarchicalMetris'; 3 | import type Metrics from './Metrics'; 4 | import type { MetricsScope } from './metricsModels'; 5 | import type { VisitorFactory } from './VisitorFactory'; 6 | 7 | export type AnalyzeProps = VisitProps; 8 | 9 | export default abstract class HierarchicalMetricsAnalyzer 10 | implements AstVisitor, Metrics> 11 | { 12 | constructor( 13 | protected name: string, 14 | protected scope: MetricsScope, 15 | param?: { 16 | topLevelDepth?: number; 17 | visitorFactory?: VisitorFactory>; 18 | }, 19 | ) { 20 | this.topLevelDepth = param?.topLevelDepth ?? 1; 21 | this.#visitorFactory = param?.visitorFactory; 22 | } 23 | #visitorFactory?: VisitorFactory>; 24 | protected topLevelDepth: number; 25 | 26 | visit(props: VisitProps) { 27 | const leave = this.analyze(props); 28 | 29 | const additionalVisitor = this.#visitorFactory?.createAdditionalVisitor( 30 | props.node, 31 | props.depth, 32 | ); 33 | 34 | return { 35 | leave: leave ?? undefined, 36 | additionalVisitors: [additionalVisitor].filter(v => !!v), 37 | }; 38 | } 39 | 40 | get metrics(): HierarchicalMetris { 41 | return { 42 | name: this.name, 43 | scope: this.scope, 44 | score: this.score, 45 | children: this.#visitorFactory?.additionalVisitors 46 | .filter(v => !!v) 47 | .map(v => v.metrics), 48 | }; 49 | } 50 | 51 | protected abstract analyze(props: AnalyzeProps): Leave | void; 52 | protected abstract score: T; 53 | } 54 | -------------------------------------------------------------------------------- /src/feature/metric/HierarchicalMetris.ts: -------------------------------------------------------------------------------- 1 | import type { Tree } from '../../utils/Tree'; 2 | import type { MetricsScope } from './metricsModels'; 3 | 4 | export type HierarchicalMetris = Tree<{ 5 | name: string; 6 | scope: MetricsScope; 7 | score: T; 8 | }>; 9 | -------------------------------------------------------------------------------- /src/feature/metric/Metrics.ts: -------------------------------------------------------------------------------- 1 | export default interface Metrics { 2 | get metrics(): T; 3 | } 4 | -------------------------------------------------------------------------------- /src/feature/metric/VisitorFactory.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { AstVisitor } from '../util/AstVisitor'; 3 | import * as astUtils from '../util/astUtils'; 4 | import type { MetricsScope } from './metricsModels'; 5 | 6 | export interface VisitorFactory { 7 | createAdditionalVisitor(node: ts.Node, depth: number): T | undefined; 8 | additionalVisitors: T[]; 9 | } 10 | 11 | export class TopLevelVisitorFactory 12 | implements VisitorFactory 13 | { 14 | constructor( 15 | private readonly topLevelDepth: number, 16 | private readonly factoryMethods: { 17 | createFunctionVisitor: (name: string, scope: MetricsScope) => T; 18 | createArrowFunctionVisitor: (name: string, scope: MetricsScope) => T; 19 | createIIFEVisitor: (name: string, scope: MetricsScope) => T; 20 | createClassVisitor: (name: string, scope: MetricsScope) => T; 21 | createObjectLiteralExpressionVisitor: ( 22 | name: string, 23 | scope: MetricsScope, 24 | ) => T; 25 | createTypeAliasDeclarationVisitor: ( 26 | name: string, 27 | scope: MetricsScope, 28 | ) => T; 29 | createInterfaceDeclarationVisitor: ( 30 | name: string, 31 | scope: MetricsScope, 32 | ) => T; 33 | }, 34 | ) {} 35 | 36 | createAdditionalVisitor(node: ts.Node, depth: number): T | undefined { 37 | if (astUtils.isTopLevelFunction(this.topLevelDepth, depth, node)) { 38 | const visitor = this.factoryMethods.createFunctionVisitor( 39 | astUtils.getFunctionName(node), 40 | 'function', 41 | ); 42 | this.#addVisitor(visitor); 43 | return visitor; 44 | } 45 | if (astUtils.isTopLevelArrowFunction(this.topLevelDepth, depth, node)) { 46 | const visitor = this.factoryMethods.createArrowFunctionVisitor( 47 | astUtils.getArrowFunctionName(node), 48 | 'function', 49 | ); 50 | this.#addVisitor(visitor); 51 | return visitor; 52 | } 53 | if (astUtils.isTopLevelIIFE(this.topLevelDepth, depth, node)) { 54 | const visitor = this.factoryMethods.createIIFEVisitor( 55 | astUtils.getAnonymousFunctionName(), 56 | 'function', 57 | ); 58 | this.#addVisitor(visitor); 59 | return visitor; 60 | } 61 | if (astUtils.isTopLevelClass(this.topLevelDepth, depth, node)) { 62 | const visitor = this.factoryMethods.createClassVisitor( 63 | astUtils.getClassName(node), 64 | 'class', 65 | ); 66 | this.#addVisitor(visitor); 67 | return visitor; 68 | } 69 | if ( 70 | astUtils.isTopLevelObjectLiteralExpression( 71 | this.topLevelDepth, 72 | depth, 73 | node, 74 | ) 75 | ) { 76 | const visitor = this.factoryMethods.createObjectLiteralExpressionVisitor( 77 | astUtils.getObjectName(node), 78 | 'object', 79 | ); 80 | this.#addVisitor(visitor); 81 | return visitor; 82 | } 83 | if (astUtils.isTopLevelTypeAlias(this.topLevelDepth, depth, node)) { 84 | const visitor = this.factoryMethods.createTypeAliasDeclarationVisitor( 85 | astUtils.getTypeAliasName(node), 86 | 'type', 87 | ); 88 | this.#addVisitor(visitor); 89 | return visitor; 90 | } 91 | if (astUtils.isTopLevelInterface(this.topLevelDepth, depth, node)) { 92 | const visitor = this.factoryMethods.createInterfaceDeclarationVisitor( 93 | astUtils.getInterfaceName(node), 94 | 'interface', 95 | ); 96 | this.#addVisitor(visitor); 97 | return visitor; 98 | } 99 | return undefined; 100 | } 101 | 102 | additionalVisitors: T[] = []; 103 | #addVisitor(visitor: T) { 104 | this.additionalVisitors.push(visitor); 105 | } 106 | } 107 | 108 | // テストは CognitiveComplexity.children.spec.ts で行う 109 | export class ClassVisitorFactory 110 | implements VisitorFactory 111 | { 112 | constructor( 113 | private readonly factoryMethods: { 114 | createGetAccessorVisitor: (name: string, scope: MetricsScope) => T; 115 | createSetAccessorVisitor: (name: string, scope: MetricsScope) => T; 116 | createMethodVisitor: (name: string, scope: MetricsScope) => T; 117 | createConstructorVisitor: (name: string, scope: MetricsScope) => T; 118 | }, 119 | ) {} 120 | 121 | createAdditionalVisitor(node: ts.Node): T | undefined { 122 | if (ts.isGetAccessor(node)) { 123 | const visitor = this.factoryMethods.createGetAccessorVisitor( 124 | astUtils.getGetAccessorName(node), 125 | 'method', 126 | ); 127 | this.#addVisitor(visitor); 128 | return visitor; 129 | } 130 | if (ts.isSetAccessor(node)) { 131 | const visitor = this.factoryMethods.createSetAccessorVisitor( 132 | astUtils.getSetAccessorName(node), 133 | 'method', 134 | ); 135 | this.#addVisitor(visitor); 136 | return visitor; 137 | } 138 | if (ts.isMethodDeclaration(node)) { 139 | const visitor = this.factoryMethods.createMethodVisitor( 140 | astUtils.getMethodName(node), 141 | 'method', 142 | ); 143 | this.#addVisitor(visitor); 144 | return visitor; 145 | } 146 | if (ts.isConstructorDeclaration(node)) { 147 | const visitor = this.factoryMethods.createConstructorVisitor( 148 | astUtils.getConstructorName(), 149 | 'method', 150 | ); 151 | this.#addVisitor(visitor); 152 | return visitor; 153 | } 154 | return undefined; 155 | } 156 | 157 | additionalVisitors: T[] = []; 158 | #addVisitor(visitor: T) { 159 | this.additionalVisitors.push(visitor); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/feature/metric/architecture.md: -------------------------------------------------------------------------------- 1 | # TypeScript Metrics 2 | 3 | 以下を参考に、 **Maintainability Index** (保守容易性指数)を計測する。 4 | 5 | - [learn.microsoft.com コードメトリクス値](https://learn.microsoft.com/ja-jp/visualstudio/code-quality/code-metrics-values?view=vs-2022) 6 | - [learn.microsoft.com 保守容易性指数の範囲と意味](https://learn.microsoft.com/ja-jp/visualstudio/code-quality/code-metrics-maintainability-index-range-and-meaning?view=vs-2022) 7 | - [IBM Application Discovery and Delivery Intelligence for IBM Z 保守容易性指標レポート](https://www.ibm.com/docs/ja/addi/6.1.1?topic=reports-maintainability-index-report) 8 | 9 | ## Maintainability Index 10 | 11 | > コードの保守の相対的な容易さを表す 0 から 100 の範囲の指数値を計算します。 値が大きいほど、保守容易性が向上します。 色分けされた評価を使用して、コード内の問題点をすばやく特定できます。 緑色の評価は 20 から 100 の範囲であり、コードの保守容易性が優れていることを示します。 黄色の評価は 10 から 19 の範囲であり、コードの保守容易性が中程度であることを示します。 赤色の評価は 0 から 9 の範囲であり、保守容易性が低いことを示します。 12 | 13 | 以下の計算式で求められる。 14 | 15 | ``` 16 | Maintainability Index = MAX(0,(171 - 5.2 * ln(Halstead Volume) - 0.23 * (Cyclomatic Complexity) - 16.2 * ln(Lines of Code))*100 / 171) 17 | ``` 18 | 19 | ## Halstead Volume 20 | 21 | > Halstead ボリューム は次のように計算されます。 22 | > 23 | > ``` 24 | > V = N * log2(n) 25 | > ``` 26 | > 27 | > N はプログラムの長さを表し、次のように計算されます。 28 | > 29 | > ``` 30 | > N = N1 + N2 31 | > 32 | > N1 = 演算子の総数 33 | > N2 = オペランドの総数 34 | > ``` 35 | > 36 | > n は語彙サイズを表し、次のように計算されます。 37 | > 38 | > ``` 39 | > n = n1 + n2 40 | > 41 | > n1 = 相異なる演算子の数 42 | > n2 = 相異なるオペランドの数 43 | > ``` 44 | 45 | ### TypeScript Graph における Volume の計測 **Semantic Syntax Volume** 46 | 47 | Halstead Volume はソースコード中のオペランドと演算子を用いて計測する。 48 | しかし TypeScript Graph においてはオペランドと、演算子の代わりとして意味のある構文ノードを用いて Volume を計測することとする。 49 | この計測方法を **Semantic Syntax Volume** と呼ぶこととする。 50 | 51 | #### 意味のある構文ノード 52 | 53 | 意味のある構文ノードとは、人が TypeScript のコードを読む際に認識すべき意味を持つ構文のノードである。 54 | 具体的には TypeScript の AST において出現する、オペランドといくつかの構文を除く全ての構文である。 55 | オペランドといくつかの構文についての詳細は `SemanticSyntaxVolume.ts` の `isOperand` 及び `isIgnoredSyntaxKind` を参照のこと。 56 | 57 | #### Halstead Volume との差異 58 | 59 | ここまでで述べたように計測の方法において Halstead Volume と Semantic Syntax Volume には差異がある。 60 | しかし、それによって得られる数値の質としては、どちらも「人がプログラムから受け取る情報量を表している」という点において差異はないと考える。 61 | 62 | ## Cognitive Complexity 63 | 64 | [SonarSource](https://www.sonarsource.com)社のG. Ann Campbell氏によるホワイトペーパー「[A new way of measuring understandability](https://www.sonarsource.com/docs/CognitiveComplexity.pdf)」を元にして実装している。なお、このプロジェクトは SonarSource 社と提携はしていない。 65 | 66 | ### SonarSource 社の Cognitive Complexity との差異 67 | 68 | - Class の代わりに用いられる宣言的な function におけるオブジェクトへの関数の代入について、SonarSource 社のものは「宣言的であるかどうか」を判定し宣言的でない場合のみネストレベルをインクリメントするが、本プロジェクトにおいては宣言的かどうかの判定をせずネストレベルをインクリメントする。 69 | -------------------------------------------------------------------------------- /src/feature/metric/calculateCodeMetrics.ts: -------------------------------------------------------------------------------- 1 | import type { OptionValues } from '../../setting/model'; 2 | import type ProjectTraverser from '../util/ProjectTraverser'; 3 | import type { Tree } from '../../utils/Tree'; 4 | import type { RawMetrics } from './functions/convertRawToCodeMetrics'; 5 | import { convertRawToCodeMetrics } from './functions/convertRawToCodeMetrics'; 6 | import type { CodeMetrics } from './metricsModels'; 7 | import { createCyclomaticComplexityAnalyzer } from './cyclomaticComplexity'; 8 | import { createSemanticSyntaxVolumeAnalyzer } from './semanticSyntaxVolume'; 9 | import { createCognitiveComplexityAnalyzer } from './cognitiveComplexity'; 10 | 11 | export function calculateCodeMetrics( 12 | commandOptions: Pick, 13 | traverser: ProjectTraverser, 14 | filter: (source: string) => boolean, 15 | ): Tree[] { 16 | if (!commandOptions.metrics) return []; 17 | return traverser 18 | .traverse( 19 | filter, 20 | source => 21 | createCyclomaticComplexityAnalyzer( 22 | // TODO: getFilePath は至るところで使われるのでユーティリティ関数化するべき 23 | traverser.getFilePath(source.fileName), 24 | ), 25 | source => 26 | createSemanticSyntaxVolumeAnalyzer( 27 | traverser.getFilePath(source.fileName), 28 | ), 29 | source => 30 | createCognitiveComplexityAnalyzer( 31 | traverser.getFilePath(source.fileName), 32 | ), 33 | ) 34 | .map( 35 | ([ 36 | { metrics: cyclomaticComplexity }, 37 | { metrics: semanticSyntaxVolume }, 38 | { metrics: cognitiveComplexity }, 39 | ]) => 40 | ({ 41 | cyclomaticComplexity, 42 | semanticSyntaxVolume, 43 | cognitiveComplexity, 44 | }) satisfies RawMetrics, 45 | ) 46 | .map(convertRawToCodeMetrics); 47 | } 48 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexity.children.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import * as ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import { createCognitiveComplexityAnalyzer } from '../cognitiveComplexity'; 6 | import type { CognitiveComplexityMetrics } from './CognitiveComplexityMetrics'; 7 | 8 | interface OperatorTest { 9 | perspective: string; 10 | tests: [string, CognitiveComplexityMetrics]; 11 | } 12 | 13 | test.each([ 14 | { 15 | perspective: '全体のスコアとトップレベルの関数のスコアを計測できる', 16 | tests: [ 17 | `function x() { if(z) {} } function y() { if(z) {} if(z) {} }`, 18 | { 19 | name: 'sample.tsx', 20 | scope: 'file', 21 | score: 3, 22 | children: [ 23 | { 24 | name: 'x', 25 | scope: 'function', 26 | score: 1, 27 | }, 28 | { 29 | name: 'y', 30 | scope: 'function', 31 | score: 2, 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | { 38 | perspective: '全体のスコアとトップレベルのアロー関数のスコアを計測できる', 39 | tests: [ 40 | `function x() { if(z) {} } const y = () => { if(z) {} if(z) {} };`, 41 | { 42 | name: 'sample.tsx', 43 | scope: 'file', 44 | score: 3, 45 | children: [ 46 | { 47 | name: 'x', 48 | scope: 'function', 49 | score: 1, 50 | }, 51 | { 52 | name: 'y', 53 | scope: 'function', 54 | score: 2, 55 | }, 56 | ], 57 | }, 58 | ], 59 | }, 60 | { 61 | perspective: '全体のスコアとトップレベルの無名関数のスコアを計測できる', 62 | tests: [ 63 | `(function () { if(z) {} })() const y = () => { if(z) {} if(z) {} };`, 64 | { 65 | name: 'sample.tsx', 66 | scope: 'file', 67 | score: 3, 68 | children: [ 69 | { 70 | name: 'anonymous function', 71 | scope: 'function', 72 | score: 1, 73 | }, 74 | { 75 | name: 'y', 76 | scope: 'function', 77 | score: 2, 78 | }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | { 84 | perspective: 85 | '全体のスコアとトップレベルのクラスとそのメソッドのスコアを計測できる', 86 | tests: [ 87 | ` 88 | class X { 89 | constructor() { 90 | if (z) { 91 | } 92 | } 93 | method() { 94 | if (z) { 95 | } 96 | } 97 | #a() { 98 | if (z) { 99 | } 100 | } 101 | private privateMethod() { 102 | if (z) { 103 | } 104 | } 105 | public publicMethod() { 106 | if (z) { 107 | } 108 | } 109 | get b() { 110 | if (z) { 111 | } 112 | if (z) { 113 | } 114 | return true; 115 | } 116 | set b(value: boolean) { 117 | if (z) { 118 | } 119 | } 120 | } 121 | const y = () => { 122 | if (z) { 123 | } 124 | if (z) { 125 | } 126 | }; 127 | `, 128 | { 129 | name: 'sample.tsx', 130 | scope: 'file', 131 | score: 10, 132 | children: [ 133 | { 134 | name: 'X', 135 | scope: 'class', 136 | score: 8, 137 | children: [ 138 | { 139 | name: 'constructor', 140 | scope: 'method', 141 | score: 1, 142 | }, 143 | { 144 | name: 'method', 145 | scope: 'method', 146 | score: 1, 147 | }, 148 | { 149 | name: '#a', 150 | scope: 'method', 151 | score: 1, 152 | }, 153 | { 154 | name: 'privateMethod', 155 | scope: 'method', 156 | score: 1, 157 | }, 158 | { 159 | name: 'publicMethod', 160 | scope: 'method', 161 | score: 1, 162 | }, 163 | { 164 | name: 'get b', 165 | scope: 'method', 166 | score: 2, 167 | }, 168 | { 169 | name: 'set b', 170 | scope: 'method', 171 | score: 1, 172 | }, 173 | ], 174 | }, 175 | { 176 | name: 'y', 177 | scope: 'function', 178 | score: 2, 179 | }, 180 | ], 181 | }, 182 | ], 183 | }, 184 | { 185 | perspective: '全体のスコアとトップレベルのオブジェクトのスコアを計測できる', 186 | tests: [ 187 | `function x() { if(z) {} } const y = {z:() => { if(z) {} if(z) {} }};`, 188 | { 189 | name: 'sample.tsx', 190 | scope: 'file', 191 | score: 3, 192 | children: [ 193 | { 194 | name: 'x', 195 | scope: 'function', 196 | score: 1, 197 | }, 198 | { 199 | name: 'y', 200 | scope: 'object', 201 | score: 2, 202 | }, 203 | ], 204 | }, 205 | ], 206 | }, 207 | { 208 | perspective: '関数を返すトップレベルの関数のスコアを計測できる', 209 | tests: [ 210 | `function a():()=>void { return () => { if(z) {} }; }`, 211 | { 212 | name: 'sample.tsx', 213 | scope: 'file', 214 | score: 2, 215 | children: [ 216 | { 217 | name: 'a', 218 | scope: 'function', 219 | score: 2, 220 | }, 221 | ], 222 | }, 223 | ], 224 | }, 225 | { 226 | perspective: '関数を返すトップレベルのアロー関数のスコアを計測できる', 227 | tests: [ 228 | `const a:()=>()=>void=()=>()=>{if(z){}}`, 229 | { 230 | name: 'sample.tsx', 231 | scope: 'file', 232 | score: 2, 233 | children: [ 234 | { 235 | name: 'a', 236 | scope: 'function', 237 | score: 2, 238 | }, 239 | ], 240 | }, 241 | ], 242 | }, 243 | ] satisfies OperatorTest[])( 244 | `$perspective`, 245 | ({ tests: [sourceCode, expected] }) => { 246 | const source = ts.createSourceFile( 247 | 'sample.tsx', 248 | sourceCode, 249 | ts.ScriptTarget.ESNext, 250 | // parent を使うことがあるので true 251 | true, 252 | ts.ScriptKind.TS, 253 | ); 254 | const astLogger = new AstLogger(); 255 | const cognitiveComplexity = createCognitiveComplexityAnalyzer('sample.tsx'); 256 | const astTraverser = new AstTraverser(source, [ 257 | astLogger, 258 | cognitiveComplexity, 259 | ]); 260 | astTraverser.traverse(); 261 | console.log(astLogger.log); 262 | expect(cognitiveComplexity.metrics).toMatchObject(expected); 263 | }, 264 | ); 265 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexity.nestlevel.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import * as ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import { createCognitiveComplexityAnalyzer } from '../cognitiveComplexity'; 6 | 7 | interface OperatorTest { 8 | perspective: string; 9 | tests: [string, number]; 10 | } 11 | 12 | test.each([ 13 | { 14 | perspective: 'トップレベルの関数定義はネストレベルをインクリメントしない', 15 | tests: [`function x() { if(z) {} }`, 1], 16 | }, 17 | { 18 | perspective: '関数定義内の関数定義はネストレベルをインクリメントする', 19 | tests: [`function x() { if(true) {} function y() { if(true) {}} }`, 3], 20 | }, 21 | { 22 | perspective: 23 | 'トップレベルのアロー関数定義はネストレベルをインクリメントしない', 24 | tests: [`const x = () => { if(z) {} }`, 1], 25 | }, 26 | { 27 | perspective: '関数定義内のアロー関数定義はネストレベルをインクリメントする', 28 | tests: [ 29 | `const x = () => { if(true) {} const y = () => { if(true) {}} }`, 30 | 3, 31 | ], 32 | }, 33 | { 34 | perspective: 35 | '関数定義内のアロー関数定義はネストレベルをインクリメントする2', 36 | tests: [`const x = ()=>()=>()=>()=>{if(true){}}`, 4], 37 | }, 38 | { 39 | perspective: 40 | 'トップレベルの無名関数定義はネストレベルをインクリメントしない', 41 | tests: [`(function() { if(z) {} })()`, 1], 42 | }, 43 | { 44 | perspective: '関数定義内の無名関数定義はネストレベルをインクリメントする', 45 | tests: [`(function() { if(true) {} (function () { if(true) {}})() })()`, 3], 46 | }, 47 | { 48 | perspective: 49 | 'トップレベルのジェネレータ関数はネストレベルをインクリメントしない', 50 | tests: [`function* generator() { if(z) {} }`, 1], 51 | }, 52 | { 53 | perspective: 54 | 'トップレベルではないジェネレータ関数はネストレベルをインクリメントする', 55 | tests: [`function a() {function* generator() { if(z) {} }}`, 2], 56 | }, 57 | { 58 | perspective: 'トップレベルの非同期関数はネストレベルをインクリメントしない', 59 | tests: [`async function a() { if(z) {} }`, 1], 60 | }, 61 | { 62 | perspective: 63 | 'トップレベルではない非同期関数はネストレベルをインクリメントする', 64 | tests: [`async function a() {async function b() { if(z) {} }}`, 2], 65 | }, 66 | { 67 | perspective: 68 | 'トップレベルの非同期ジェネレータはネストレベルをインクリメントしない', 69 | tests: [`async function* a() { if(z) {} }`, 1], 70 | }, 71 | { 72 | perspective: 73 | 'トップレベルではない非同期ジェネレータはネストレベルをインクリメントする', 74 | tests: [`async function* a() {async function* b() { if(z) {} }}`, 2], 75 | }, 76 | { 77 | perspective: 78 | 'トップレベルのオブジェクトはネストレベルをインクリメントしない、オブジェクトのプロパティに定義された関数はネストレベルをインクリメントしない', 79 | tests: [ 80 | ` 81 | const obj = { 82 | x: function () { 83 | if (true) { 84 | } 85 | }, 86 | y() { 87 | if (true) { 88 | } 89 | }, 90 | z: () => { 91 | if (true) { 92 | } 93 | }, 94 | }; 95 | `, 96 | 3, 97 | ], 98 | }, 99 | { 100 | perspective: 101 | 'トップレベルではないオブジェクトはネストレベルをインクリメントする、オブジェクトのプロパティに定義された関数はネストレベルをインクリメントしない', 102 | tests: [ 103 | ` 104 | const obj = { 105 | obj2: { 106 | x: function () { 107 | if (true) { 108 | } 109 | }, 110 | y() { 111 | if (true) { 112 | } 113 | }, 114 | z: () => { 115 | if (true) { 116 | } 117 | }, 118 | } 119 | }; 120 | `, 121 | 6, 122 | ], 123 | }, 124 | { 125 | perspective: 126 | 'トップレベルのクラスのメソッド定義はネストレベルをインクリメントしない', 127 | tests: [ 128 | ` 129 | class A { 130 | method() { 131 | if (true) {} 132 | } 133 | } 134 | `, 135 | 1, 136 | ], 137 | }, 138 | { 139 | perspective: 140 | 'トップレベルのクラスの#メソッド定義はネストレベルをインクリメントしない', 141 | tests: [ 142 | ` 143 | class A { 144 | #method() { 145 | if (true) {} 146 | } 147 | } 148 | `, 149 | 1, 150 | ], 151 | }, 152 | { 153 | perspective: 154 | 'トップレベルのクラスの private メソッド定義はネストレベルをインクリメントしない', 155 | tests: [ 156 | ` 157 | class A { 158 | private method() { 159 | if (true) {} 160 | } 161 | } 162 | `, 163 | 1, 164 | ], 165 | }, 166 | { 167 | perspective: 168 | 'トップレベルのクラスの public メソッド定義はネストレベルをインクリメントしない', 169 | tests: [ 170 | ` 171 | class A { 172 | public method() { 173 | if (true) {} 174 | } 175 | } 176 | `, 177 | 1, 178 | ], 179 | }, 180 | { 181 | perspective: 182 | 'トップレベルのクラスの getter/setter 定義はネストレベルをインクリメントしない', 183 | tests: [ 184 | ` 185 | class A { 186 | get a() { 187 | if (true) {} 188 | return false 189 | } 190 | set a(b: boolean) { 191 | if (b) {} 192 | } 193 | } 194 | `, 195 | 2, 196 | ], 197 | }, 198 | { 199 | perspective: 200 | 'トップレベルではないクラスのメソッド定義はネストレベルをインクリメントする', 201 | tests: [ 202 | ` 203 | class A { 204 | method() { 205 | class B { 206 | method() { 207 | if (true) {} 208 | } 209 | } 210 | } 211 | } 212 | `, 213 | 2, 214 | ], 215 | }, 216 | { 217 | perspective: 218 | 'トップレベルではないクラスの getter/setter 定義はネストレベルをインクリメントする', 219 | tests: [ 220 | ` 221 | class A { 222 | method() { 223 | class B { 224 | get a() { 225 | if (true) {} 226 | return false 227 | } 228 | set a(b: boolean) { 229 | if (b) {} 230 | } 231 | } 232 | } 233 | } 234 | `, 235 | 4, 236 | ], 237 | }, 238 | { 239 | perspective: 240 | 'トップレベルのクラスの constructor 定義はネストレベルをインクリメントしない', 241 | tests: [ 242 | ` 243 | class A { 244 | constructor() { 245 | if (true) {} 246 | } 247 | } 248 | `, 249 | 1, 250 | ], 251 | }, 252 | { 253 | perspective: 254 | 'トップレベルではないクラスの constructor 定義はネストレベルをインクリメントする', 255 | tests: [ 256 | ` 257 | class A { 258 | method() { 259 | class B { 260 | constructor() { 261 | if (true) {} 262 | } 263 | } 264 | } 265 | } 266 | `, 267 | 2, 268 | ], 269 | }, 270 | { 271 | perspective: 272 | 'オブジェクトのいかなるプロパティもネストレベルをインクリメントしない', 273 | tests: [ 274 | ` 275 | const obj2 = { 276 | set property2(value: boolean) { 277 | if (true) { 278 | } 279 | }, 280 | property3(b: boolean) { 281 | if (true) { 282 | } 283 | }, 284 | *generator1(b: boolean) { 285 | if (true) { 286 | } 287 | }, 288 | async property4(b: boolean) { 289 | if (true) { 290 | } 291 | }, 292 | async *generator2(b: boolean) { 293 | if (true) { 294 | } 295 | }, 296 | // 算出されたキーも使用可能: 297 | get [property1]() { 298 | if (true) { 299 | } 300 | return true; 301 | }, 302 | set [property1](value: boolean) { 303 | if (true) { 304 | } 305 | }, 306 | [property2](b: boolean) { 307 | if (true) { 308 | } 309 | }, 310 | *[generator1](b: boolean) { 311 | if (true) { 312 | } 313 | }, 314 | async [property3](b: boolean) { 315 | if (true) { 316 | } 317 | }, 318 | async *[generator2](b: boolean) { 319 | if (true) { 320 | } 321 | }, 322 | }; 323 | `, 324 | 11, 325 | ], 326 | }, 327 | ] satisfies OperatorTest[])( 328 | `$perspective`, 329 | ({ tests: [sourceCode, expected] }) => { 330 | const source = ts.createSourceFile( 331 | 'sample.tsx', 332 | sourceCode, 333 | ts.ScriptTarget.ESNext, 334 | // parent を使うことがあるので true 335 | true, 336 | ts.ScriptKind.TS, 337 | ); 338 | const astLogger = new AstLogger(); 339 | const analyzer = createCognitiveComplexityAnalyzer('sample.tsx'); 340 | const astTraverser = new AstTraverser(source, [astLogger, analyzer]); 341 | astTraverser.traverse(); 342 | console.log(astLogger.log); 343 | expect(analyzer.metrics.score).toEqual(expected); 344 | }, 345 | ); 346 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexity.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import * as ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import { createCognitiveComplexityAnalyzer } from '../cognitiveComplexity'; 6 | 7 | interface OperatorTest { 8 | perspective: string; 9 | tests: [string, number]; 10 | } 11 | 12 | test.each([ 13 | { 14 | perspective: 'オプショナルチェーン', 15 | tests: ['const x = x?.y?.z?.foo', 0], 16 | }, 17 | { 18 | perspective: 'Null 合体演算子 QuestionQuestionToken', 19 | tests: ['true ?? true; true ?? false ?? true', 0], 20 | }, 21 | { 22 | perspective: 'Null 合体代入', 23 | tests: ['a??=1;a??=2;a=3;', 0], 24 | }, 25 | { 26 | perspective: '論理和代入', 27 | tests: ['a||=1;a||=2;a=3', 0], 28 | }, 29 | { 30 | perspective: 'if...else if', 31 | tests: ['if(x){1;}else if(y){2;}else{3;}', 3], 32 | }, 33 | { 34 | perspective: 'if の入れ子', 35 | tests: ['if(x){if(y){2;}}', 3], 36 | }, 37 | { 38 | perspective: 'if の入れ子に else', 39 | tests: ['if(x){if(y){2;}else{}}', 4], 40 | }, 41 | { 42 | perspective: 'if の入れ子に else if', 43 | tests: ['if(x){if(y){2;}else if(z){}else{}}', 5], 44 | }, 45 | { 46 | perspective: 'switch 文', 47 | tests: ['switch(x){case 1:1;break;case 2:2;break;default:3;}', 1], 48 | }, 49 | { 50 | perspective: 'case の中に if 文', 51 | tests: [ 52 | ` 53 | switch (x) { // +1 nest++ 54 | case 1: 55 | if (y) { // +2 nest++ 56 | } // nest-- 57 | break; 58 | case 2: 59 | 2; 60 | break; 61 | default: 62 | 3; 63 | } // nest-- 64 | `, 65 | 3, 66 | ], 67 | }, 68 | { 69 | perspective: 'case の中に switch 文', 70 | tests: [ 71 | ` 72 | switch (x) { 73 | case 1: 74 | switch (y) { 75 | case 1: 76 | 1; 77 | break; 78 | } 79 | break; 80 | case 2: 81 | 2; 82 | break; 83 | default: 84 | 3; 85 | } 86 | `, 87 | 3, 88 | ], 89 | }, 90 | { 91 | perspective: 'for ループ', 92 | tests: ['for(let i=0;i<5;i++){if(i===3){continue;}}', 3], 93 | }, 94 | { 95 | perspective: 'for...in ループ', 96 | tests: ['for(const prop in obj){if(i===3){continue;}}', 3], 97 | }, 98 | { 99 | perspective: 'while ループ', 100 | tests: ['let i=0;while(i<5){i++;if(i===3){break;}}', 3], 101 | }, 102 | { 103 | perspective: 'do...while ループ', 104 | tests: ['let i=0;do{i++;if(i===3){break;}}while(i<5);', 3], 105 | }, 106 | { 107 | perspective: 'for await...of ループ', 108 | tests: [ 109 | 'for await(const element of asyncIterable){if(element){break;}}', 110 | 3, 111 | ], 112 | }, 113 | { 114 | perspective: 'try...catch', 115 | tests: [ 116 | ` 117 | try { 118 | if (x) { // +1 nest++ 119 | throw new Error(''); 120 | } // nest-- 121 | } catch (e) { // +1 nest++ 122 | if (x) { // +2 nest++ 123 | return; 124 | } // nest-- 125 | } finally { // nest-- 126 | if (x) { // +1 nest++ 127 | return; 128 | } 129 | }`, 130 | 5, 131 | ], 132 | }, 133 | { 134 | perspective: 'ラベルはインクリメント対象とする', 135 | tests: [ 136 | ` 137 | outer: for (let i = 0; i < 5; i++) { // +1 nest++ 138 | inner: for (let j = 0; j < 5; j++) { // +2 nest++ 139 | if (i === j) { // +3 nest++ 140 | if (i === 4) { // +4 nest++ 141 | break outer; // +1 142 | } else { 143 | continue outer; // +1 144 | } 145 | } 146 | } 147 | } 148 | `, 149 | 13, 150 | ], 151 | }, 152 | { 153 | perspective: 'Conditional Type', 154 | tests: [ 155 | 'type A = T extends U ? X : Y extends string ? true : false;', 156 | 3, 157 | ], 158 | }, 159 | { 160 | perspective: 161 | 'Conditional Expression の項はネストレベルが上がる(1番目の項の場合)', 162 | tests: ['const x = a ? b ? c : d : e;', 3], 163 | }, 164 | { 165 | perspective: 166 | 'Conditional Expression の項はネストレベルが上がる(2番目の項の場合)', 167 | tests: ['const x = a ? b : c ? d : e;', 3], 168 | }, 169 | { 170 | perspective: 'if の condition に三項演算子', 171 | tests: [ 172 | 'if (true && true && (true ? true ? true : false : false) && true) {1}', 173 | 7, 174 | ], 175 | }, 176 | { 177 | perspective: 'if の condition に三項演算子(論理和)', 178 | tests: [ 179 | 'if (true || true || (true ? true ? true : false : false) || true) {1}', 180 | 7, 181 | ], 182 | }, 183 | { 184 | perspective: '論理積の連続', 185 | tests: [ 186 | ` 187 | if (a === 0 && a <= 0 && 0 <= a && (d && e)) { 188 | 1; 189 | }`, 190 | // ` 191 | // if (a // +1 for "if" 192 | // && a < 0 && 0 < a // +1 193 | // && (d && e)) // カッコで括られた中に論理積があればさらに +1する。カッコがなくても同じ結果になるがそこまで考慮できない。 194 | // { 195 | // 1; 196 | // }`, 197 | 3, 198 | ], 199 | }, 200 | { 201 | perspective: '論理和の連続', 202 | tests: [ 203 | ` 204 | if (a === 0 || a <= 0 || 0 <= a || (d || e)) { 205 | 1; 206 | }`, 207 | // ` 208 | // if (a // +1 for "if" 209 | // || a < 0 || 0 < a // +1 210 | // || (d || e)) // カッコで括られた中に論理和があればさらに +1する。カッコがなくても同じ結果になるがそこまで考慮できない。 211 | // { 212 | // 1; 213 | // }`, 214 | 3, 215 | ], 216 | }, 217 | { 218 | perspective: '論理積の連続', 219 | tests: [ 220 | ` 221 | if (a // +1 for "if" 222 | && b && c // +1 223 | || d || e // +1 224 | && f) // +1 225 | { 226 | 1; 227 | }`, 228 | 4, 229 | ], 230 | }, 231 | { 232 | perspective: 'else および else if はネストレベルに関係なく +1 その1', 233 | tests: [ 234 | `if (1) if (1) return; if (1) {if(2){}} else if (1) {return};`, 235 | // ` 236 | // if (w) { 237 | // if (x) { 238 | // x 239 | // } else if (y) { 240 | // y 241 | // } else { 242 | // z 243 | // } 244 | // }`, 245 | 7, 246 | ], 247 | }, 248 | { 249 | perspective: 'else および else if はネストレベルに関係なく +1 その2', 250 | tests: [ 251 | `if (w) { if (x) { x } else if (y) { y } else { z }}`, 252 | // ` 253 | // if (w) { 254 | // if (x) { 255 | // x 256 | // } else if (y) { 257 | // y 258 | // } else { 259 | // z 260 | // } 261 | // }`, 262 | 5, 263 | ], 264 | }, 265 | ] satisfies OperatorTest[])( 266 | `$perspective`, 267 | ({ tests: [sourceCode, expected] }) => { 268 | const source = ts.createSourceFile( 269 | 'sample.tsx', 270 | sourceCode, 271 | ts.ScriptTarget.ESNext, 272 | // parent を使うことがあるので true 273 | true, 274 | ts.ScriptKind.TS, 275 | ); 276 | const astLogger = new AstLogger(); 277 | const analyzer = createCognitiveComplexityAnalyzer('sample.tsx'); 278 | const astTraverser = new AstTraverser(source, [astLogger, analyzer]); 279 | astTraverser.traverse(); 280 | console.log(astLogger.log); 281 | expect(analyzer.metrics.score).toEqual(expected); 282 | }, 283 | ); 284 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexityAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import { allPass, anyPass, isNot } from 'remeda'; 3 | import type { Leave } from '../../util/AstVisitor'; 4 | import type { TopLevelMatcher } from '../../util/astUtils'; 5 | import { 6 | isTopLevelArrowFunction, 7 | isTopLevelClass, 8 | isTopLevelFunction, 9 | isTopLevelIIFE, 10 | isTopLevelObjectLiteralExpression, 11 | } from '../../util/astUtils'; 12 | import type { AnalyzeProps } from '../HierarchicalMetricsAnalyzer'; 13 | import HierarchicalMetricsAnalyzer from '../HierarchicalMetricsAnalyzer'; 14 | import type { Score } from './CognitiveComplexityMetrics'; 15 | 16 | type NodeMatcher = (node: ts.Node) => boolean; 17 | 18 | function isElseOrElseIfStatement(node: ts.Node): boolean { 19 | if ( 20 | (ts.isIfStatement(node) || ts.isBlock(node)) && 21 | node.parent && 22 | ts.isIfStatement(node.parent) 23 | ) { 24 | return node.parent.elseStatement === node; 25 | } 26 | return false; 27 | } 28 | 29 | function hasLabel(node: ts.Node): boolean { 30 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 31 | return !!(node as any)['label']; 32 | } 33 | 34 | /** nest level に影響を受けたインクリメントを行う node の判定 */ 35 | const incrementScoreMachers: NodeMatcher[] = [ 36 | ts.isConditionalExpression, 37 | allPass([ts.isIfStatement, isNot(isElseOrElseIfStatement)]), 38 | ts.isSwitchStatement, 39 | ts.isForStatement, 40 | ts.isForInStatement, 41 | ts.isForOfStatement, 42 | ts.isWhileStatement, 43 | ts.isDoStatement, 44 | ts.isCatchClause, 45 | ts.isConditionalTypeNode, 46 | ts.isCatchClause, 47 | ]; 48 | 49 | /** nest level に影響を受けないインクリメントを行う node の判定 */ 50 | const simpleIncrementScoreMachers: NodeMatcher[] = [ 51 | allPass([anyPass([ts.isIfStatement, ts.isBlock]), isElseOrElseIfStatement]), 52 | allPass([ts.isBreakOrContinueStatement, hasLabel]), 53 | ]; 54 | 55 | const incrementNestMachers: NodeMatcher[] = [ 56 | ts.isIfStatement, 57 | ts.isConditionalExpression, 58 | ts.isSwitchStatement, 59 | ts.isForStatement, 60 | ts.isForInStatement, 61 | ts.isForOfStatement, 62 | ts.isWhileStatement, 63 | ts.isDoStatement, 64 | ts.isCatchClause, 65 | ts.isConditionalTypeNode, 66 | ts.isFunctionDeclaration, 67 | ts.isArrowFunction, 68 | ts.isFunctionExpression, 69 | ts.isClassDeclaration, 70 | ts.isObjectLiteralExpression, 71 | ]; 72 | 73 | const skipNestIncrementAtTopLevelMatchers: TopLevelMatcher[] = [ 74 | isTopLevelFunction, 75 | isTopLevelArrowFunction, 76 | isTopLevelIIFE, 77 | isTopLevelClass, 78 | isTopLevelObjectLiteralExpression, 79 | (_topLevelDepth, _currentDepth, node) => 80 | // オブジェクトに定義されたアローファンクションはネストレベルをインクリメントしない 81 | // 0:SourceFile>1:FirstStatement>2:VariableDeclarationList>3:VariableDeclaration>4:ObjectLiteralExpression>5:PropertyAssignment>6:ArrowFunction 82 | ts.isArrowFunction(node) && 83 | ts.isObjectLiteralExpression(node.parent.parent), 84 | (_topLevelDepth, _currentDepth, node) => 85 | // オブジェクトに定義された関数定義はネストレベルをインクリメントしない 86 | // 0:SourceFile>1:FirstStatement>2:VariableDeclarationList>3:VariableDeclaration>4:ObjectLiteralExpression>5:PropertyAssignment>6:FunctionExpression 87 | ts.isFunctionExpression(node) && 88 | ts.isObjectLiteralExpression(node.parent.parent), 89 | ]; 90 | 91 | export default abstract class CognitiveComplexityAnalyzer extends HierarchicalMetricsAnalyzer { 92 | protected analyze({ node, depth }: AnalyzeProps): Leave | void { 93 | this.#trackLogicalToken(node); 94 | if (incrementScoreMachers.some(matcher => matcher(node))) { 95 | this.#incrementScore(); 96 | // console.log( 'increment: ', node.getText(node.getSourceFile()), this.#nestLevel,); 97 | } 98 | if (simpleIncrementScoreMachers.some(matcher => matcher(node))) { 99 | this.#simpleIncrementScore(); 100 | // console.log('simple increment: ', node.getText(node.getSourceFile()), 1); 101 | } 102 | if ( 103 | !skipNestIncrementAtTopLevelMatchers.some(matcher => 104 | matcher(this.topLevelDepth, depth, node), 105 | ) && 106 | incrementNestMachers.some(matcher => matcher(node)) 107 | ) { 108 | this.#enterNest(); 109 | // console.log('enter: ',getText(node, node.getSourceFile()),this.#nestLevel,); 110 | return () => { 111 | // console.log('exit: ',getText(node, node.getSourceFile()),this.#nestLevel,); 112 | this.#exitNest(); 113 | }; 114 | } 115 | } 116 | 117 | protected score: Score = 0; 118 | #incrementScore() { 119 | this.score += this.#nestLevel; 120 | } 121 | #simpleIncrementScore() { 122 | this.score++; 123 | } 124 | 125 | #nestLevel = 1; 126 | #enterNest() { 127 | this.#nestLevel++; 128 | } 129 | #exitNest() { 130 | this.#nestLevel--; 131 | } 132 | 133 | /** 134 | * AmpersandAmpersandToken が出現したらそれを保持し、 135 | * Identifier の出現の場合はそれを保持し続け、 136 | * それ以外の SyntaxKind を持つ node が出現したらそれをクリアする。 137 | * 論理和においても同様です。 138 | */ 139 | #trackLogicalToken(node: ts.Node) { 140 | if ( 141 | node.kind !== ts.SyntaxKind.AmpersandAmpersandToken && 142 | node.kind !== ts.SyntaxKind.BarBarToken 143 | ) { 144 | return; 145 | } 146 | let parentFlg = false; 147 | // 親の兄弟ノードが自分の SyntaxKind と異なる場合、自分がその論理演算子の最後のノードであると判断し、スコアのインクリメントを行う 148 | node.parent.parent.forEachChild(n => { 149 | if (n === node.parent) { 150 | parentFlg = true; 151 | return; 152 | } 153 | if (parentFlg) { 154 | parentFlg = false; 155 | // console.log('parent next: ', getText(n, n.getSourceFile())); 156 | if (n.kind !== node.kind) { 157 | this.#simpleIncrementScore(); 158 | } 159 | } 160 | }); 161 | if (parentFlg) { 162 | // 親の兄弟がいなかった場合は自分がその論理演算子の最後のノードであると判断し、スコアのインクリメントを行う 163 | this.#simpleIncrementScore(); 164 | } 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexityForClass.ts: -------------------------------------------------------------------------------- 1 | import type { MetricsScope } from '../metricsModels'; 2 | import { ClassVisitorFactory } from '../VisitorFactory'; 3 | import CognitiveComplexityAnalyzer from './CognitiveComplexityAnalyzer'; 4 | import CognitiveComplexityForNormalNode from './CognitiveComplexityForNormalNode'; 5 | 6 | const create = (name: string, scope: MetricsScope) => 7 | new CognitiveComplexityForNormalNode(name, scope); 8 | 9 | export default class CognitiveComplexityForClass extends CognitiveComplexityAnalyzer { 10 | constructor(name: string, scope: MetricsScope) { 11 | super(name, scope, { 12 | visitorFactory: new ClassVisitorFactory({ 13 | createGetAccessorVisitor: create, 14 | createSetAccessorVisitor: create, 15 | createMethodVisitor: create, 16 | createConstructorVisitor: create, 17 | }), 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexityForNormalNode.ts: -------------------------------------------------------------------------------- 1 | import CognitiveComplexityAnalyzer from './CognitiveComplexityAnalyzer'; 2 | 3 | /** NormalNode とは、ソースコード及びトップレベルの Class 以外を想定している。 */ 4 | export default class CognitiveComplexityForNormalNode extends CognitiveComplexityAnalyzer {} 5 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexityForSourceCode.ts: -------------------------------------------------------------------------------- 1 | import { TopLevelVisitorFactory } from '../VisitorFactory'; 2 | import type { MetricsScope } from '../metricsModels'; 3 | import CognitiveComplexityAnalyzer from './CognitiveComplexityAnalyzer'; 4 | import CognitiveComplexityForNormalNode from './CognitiveComplexityForNormalNode'; 5 | import CognitiveComplexityForClass from './CognitiveComplexityForClass'; 6 | 7 | const createNormal = (name: string, scope: MetricsScope) => 8 | new CognitiveComplexityForNormalNode(name, scope); 9 | const createClassVisitor = (name: string, scope: MetricsScope) => 10 | new CognitiveComplexityForClass(name, scope); 11 | 12 | export default class CognitiveComplexityForSourceCode extends CognitiveComplexityAnalyzer { 13 | constructor(name: string) { 14 | super(name, 'file', { 15 | visitorFactory: new TopLevelVisitorFactory( 16 | 1, 17 | { 18 | createFunctionVisitor: createNormal, 19 | createArrowFunctionVisitor: createNormal, 20 | createIIFEVisitor: createNormal, 21 | createObjectLiteralExpressionVisitor: createNormal, 22 | createInterfaceDeclarationVisitor: createNormal, 23 | createTypeAliasDeclarationVisitor: createNormal, 24 | createClassVisitor, 25 | }, 26 | ), 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/CognitiveComplexityMetrics.ts: -------------------------------------------------------------------------------- 1 | import type { HierarchicalMetris } from '../HierarchicalMetris'; 2 | 3 | export type Score = number; 4 | export type CognitiveComplexityMetrics = HierarchicalMetris; 5 | -------------------------------------------------------------------------------- /src/feature/metric/cognitiveComplexity/index.ts: -------------------------------------------------------------------------------- 1 | import type CognitiveComplexityAnalyzer from './CognitiveComplexityAnalyzer'; 2 | import CognitiveComplexityForSourceCode from './CognitiveComplexityForSourceCode'; 3 | export { CognitiveComplexityMetrics } from './CognitiveComplexityMetrics'; 4 | 5 | export function createCognitiveComplexityAnalyzer( 6 | ...params: ConstructorParameters 7 | ): CognitiveComplexityAnalyzer { 8 | return new CognitiveComplexityForSourceCode(...params); 9 | } 10 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexity.children.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import * as ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import type { CyclomaticComplexityMetrics } from '.'; 6 | import { createCyclomaticComplexityAnalyzer } from '.'; 7 | 8 | interface OperatorTest { 9 | perspective: string; 10 | tests: [string, CyclomaticComplexityMetrics]; 11 | } 12 | 13 | test.each([ 14 | { 15 | perspective: '全体のスコアとトップレベルの関数のスコアを計測できる', 16 | tests: [ 17 | `function x() { if(z) {} } function y() { if(z) {} if(z) {} }`, 18 | { 19 | name: 'sample.tsx', 20 | score: 4, 21 | scope: 'file', 22 | children: [ 23 | { 24 | name: 'x', 25 | score: 2, 26 | scope: 'function', 27 | }, 28 | { 29 | name: 'y', 30 | score: 3, 31 | scope: 'function', 32 | }, 33 | ], 34 | }, 35 | ], 36 | }, 37 | { 38 | perspective: '全体のスコアとトップレベルのアロー関数のスコアを計測できる', 39 | tests: [ 40 | `function x() { if(z) {} } const y = () => { if(z) {} if(z) {} };`, 41 | { 42 | name: 'sample.tsx', 43 | score: 4, 44 | scope: 'file', 45 | children: [ 46 | { 47 | name: 'x', 48 | score: 2, 49 | scope: 'function', 50 | }, 51 | { 52 | name: 'y', 53 | score: 3, 54 | scope: 'function', 55 | }, 56 | ], 57 | }, 58 | ], 59 | }, 60 | { 61 | perspective: '全体のスコアとトップレベルの無名関数のスコアを計測できる', 62 | tests: [ 63 | `(function () { if(z) {} })() const y = () => { if(z) {} if(z) {} };`, 64 | { 65 | name: 'sample.tsx', 66 | score: 4, 67 | scope: 'file', 68 | children: [ 69 | { 70 | name: 'anonymous function', 71 | score: 2, 72 | scope: 'function', 73 | }, 74 | { 75 | name: 'y', 76 | score: 3, 77 | scope: 'function', 78 | }, 79 | ], 80 | }, 81 | ], 82 | }, 83 | { 84 | perspective: 85 | '全体のスコアとトップレベルのクラスとそのメソッドのスコアを計測できる', 86 | tests: [ 87 | ` 88 | class X { 89 | constructor() { 90 | if (z) { 91 | } 92 | } 93 | method() { 94 | if (z) { 95 | } 96 | } 97 | #a() { 98 | if (z) { 99 | } 100 | } 101 | private privateMethod() { 102 | if (z) { 103 | } 104 | } 105 | public publicMethod() { 106 | if (z) { 107 | } 108 | } 109 | get b() { 110 | if (z) { 111 | } 112 | if (z) { 113 | } 114 | return true; 115 | } 116 | set b(value: boolean) { 117 | if (z) { 118 | } 119 | } 120 | } 121 | const y = () => { 122 | if (z) { 123 | } 124 | if (z) { 125 | } 126 | }; 127 | `, 128 | { 129 | name: 'sample.tsx', 130 | score: 11, 131 | scope: 'file', 132 | children: [ 133 | { 134 | name: 'X', 135 | score: 9, 136 | scope: 'class', 137 | children: [ 138 | { 139 | name: 'constructor', 140 | score: 2, 141 | scope: 'method', 142 | }, 143 | { 144 | name: 'method', 145 | score: 2, 146 | scope: 'method', 147 | }, 148 | { 149 | name: '#a', 150 | score: 2, 151 | scope: 'method', 152 | }, 153 | { 154 | name: 'privateMethod', 155 | score: 2, 156 | scope: 'method', 157 | }, 158 | { 159 | name: 'publicMethod', 160 | score: 2, 161 | scope: 'method', 162 | }, 163 | { 164 | name: 'get b', 165 | score: 3, 166 | scope: 'method', 167 | }, 168 | { 169 | name: 'set b', 170 | score: 2, 171 | scope: 'method', 172 | }, 173 | ], 174 | }, 175 | { 176 | name: 'y', 177 | score: 3, 178 | scope: 'function', 179 | }, 180 | ], 181 | }, 182 | ], 183 | }, 184 | { 185 | perspective: '全体のスコアとトップレベルのオブジェクトのスコアを計測できる', 186 | tests: [ 187 | `function x() { if(z) {} } const y = {z:() => { if(z) {} if(z) {} }};`, 188 | { 189 | name: 'sample.tsx', 190 | score: 4, 191 | scope: 'file', 192 | children: [ 193 | { 194 | name: 'x', 195 | score: 2, 196 | scope: 'function', 197 | }, 198 | { 199 | name: 'y', 200 | score: 3, 201 | scope: 'object', 202 | }, 203 | ], 204 | }, 205 | ], 206 | }, 207 | ] satisfies OperatorTest[])( 208 | `$perspective`, 209 | ({ tests: [sourceCode, expected] }) => { 210 | const source = ts.createSourceFile( 211 | 'sample.tsx', 212 | sourceCode, 213 | ts.ScriptTarget.ESNext, 214 | // parent を使うことがあるので true 215 | true, 216 | ts.ScriptKind.TS, 217 | ); 218 | const astLogger = new AstLogger(); 219 | const cyclomaticComplexity = 220 | createCyclomaticComplexityAnalyzer('sample.tsx'); 221 | const astTraverser = new AstTraverser(source, [ 222 | astLogger, 223 | cyclomaticComplexity, 224 | ]); 225 | astTraverser.traverse(); 226 | console.log(astLogger.log); 227 | expect(cyclomaticComplexity.metrics).toEqual(expected); 228 | }, 229 | ); 230 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexity.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import * as ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import { createCyclomaticComplexityAnalyzer } from '.'; 6 | 7 | interface OperatorTest { 8 | perspective: string; 9 | tests: [string, number]; 10 | } 11 | 12 | test.each([ 13 | { 14 | perspective: '? の区別', 15 | tests: ['a ? b : c?.d(e ?? f)', 4], 16 | }, 17 | { 18 | perspective: 'オプショナルチェーン', 19 | tests: ['const x = a.b.c.x?.y?.z?.foo', 4], 20 | }, 21 | { 22 | perspective: 'オプショナルチェーンからのプロパティアクセス', 23 | tests: ['a?.b?.c.d', 3], 24 | }, 25 | { 26 | perspective: 'Null 合体演算子 バイナリー論理演算子 QuestionQuestionToken', 27 | tests: ['true ?? true; true ?? false ?? true', 4], 28 | }, 29 | { 30 | perspective: 'Null 合体代入', 31 | tests: ['a??=1;a??=2;a=3;', 3], 32 | }, 33 | { 34 | perspective: '論理和代入', 35 | tests: ['a||=1;a||=2;a=3', 3], 36 | }, 37 | { 38 | perspective: 'if else 文', 39 | tests: ['if(x){1;}else if(y){2;}else{3;}', 3], 40 | }, 41 | { 42 | perspective: 'switch 文', 43 | tests: ['switch(x){case 1:1;break;case 2:2;break;default:3;}', 3], 44 | }, 45 | { 46 | perspective: 'for ループ', 47 | tests: ['for(let i=0;i<5;i++){if(i===3){continue;}console.log(i);}', 3], 48 | }, 49 | { 50 | perspective: 'for...in ループ', 51 | tests: ['for(const prop in obj){console.log(`${prop}:${obj[prop]}`);}', 2], 52 | }, 53 | { 54 | perspective: 'while ループ', 55 | tests: ['let i=0;while(i<5){console.log(i);i++;}', 2], 56 | }, 57 | { 58 | perspective: 'do...while ループ', 59 | tests: ['let i=0;do{console.log(i);i++;}while(i<5);', 2], 60 | }, 61 | { 62 | perspective: 'for await...of ループ', 63 | tests: [ 64 | 'for await(const element of asyncIterable){console.log(element);}', 65 | 2, 66 | ], 67 | }, 68 | { 69 | perspective: 'try...catch', 70 | tests: ['try{throw new Error("");}catch(e){e;}finally{1;}', 2], 71 | }, 72 | { 73 | perspective: 'ラベル', 74 | tests: [ 75 | 'outer:for(let i=0;i<5;i++){inner:for(let j=0;j<5;j++){if(i===j){if(i===4){break outer;}else{continue outer;}}}}', 76 | 5, 77 | ], 78 | }, 79 | { 80 | perspective: 'Conditional Type', 81 | tests: ['type A = T extends U ? X : Y;', 2], 82 | }, 83 | ] satisfies OperatorTest[])( 84 | `$perspective`, 85 | ({ tests: [sourceCode, expected] }) => { 86 | const source = ts.createSourceFile( 87 | 'sample.tsx', 88 | sourceCode, 89 | ts.ScriptTarget.ESNext, 90 | // parent を使うことがあるので true 91 | true, 92 | ts.ScriptKind.TS, 93 | ); 94 | const astLogger = new AstLogger(); 95 | const volume = createCyclomaticComplexityAnalyzer('sample.tsx'); 96 | const astTraverser = new AstTraverser(source, [astLogger, volume]); 97 | astTraverser.traverse(); 98 | console.log(astLogger.log); 99 | expect(volume.metrics.score).toEqual(expected); 100 | }, 101 | ); 102 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexityAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import type { AnalyzeProps } from '../HierarchicalMetricsAnalyzer'; 3 | import HierarchicalMetricsAnalyzer from '../HierarchicalMetricsAnalyzer'; 4 | import type { Score } from './CyclomaticComplexityMetrics'; 5 | 6 | function kindMatcher(kind: ts.SyntaxKind) { 7 | return (node: ts.Node) => node.kind === kind; 8 | } 9 | 10 | const cyclomaticNodeMatchers: ((node: ts.Node) => boolean)[] = [ 11 | ts.isConditionalExpression, 12 | ts.isQuestionDotToken, 13 | ts.isNullishCoalesce, 14 | kindMatcher(ts.SyntaxKind.QuestionQuestionEqualsToken), 15 | kindMatcher(ts.SyntaxKind.BarBarEqualsToken), 16 | ts.isIfStatement, 17 | ts.isCaseClause, 18 | ts.isForStatement, 19 | ts.isForInStatement, 20 | ts.isForOfStatement, 21 | ts.isWhileStatement, 22 | ts.isDoStatement, 23 | ts.isCatchClause, 24 | ts.isConditionalTypeNode, 25 | ]; 26 | 27 | export default abstract class CyclomaticComplexityAnalyzer extends HierarchicalMetricsAnalyzer { 28 | protected analyze({ node }: AnalyzeProps) { 29 | if (cyclomaticNodeMatchers.some(matcher => matcher(node))) this.#addScore(); 30 | } 31 | 32 | protected score: Score = 1; 33 | #addScore() { 34 | this.score++; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexityForClass.ts: -------------------------------------------------------------------------------- 1 | import type { MetricsScope } from '../metricsModels'; 2 | import { ClassVisitorFactory } from '../VisitorFactory'; 3 | import CyclomaticComplexityAnalyzer from './CyclomaticComplexityAnalyzer'; 4 | import CyclomaticComplexityForNormalNode from './CyclomaticComplexityForNormalNode'; 5 | 6 | const create = (name: string, scope: MetricsScope) => 7 | new CyclomaticComplexityForNormalNode(name, scope); 8 | 9 | export default class CyclomaticComplexityForClass extends CyclomaticComplexityAnalyzer { 10 | constructor(name: string, scope: MetricsScope) { 11 | super(name, scope, { 12 | visitorFactory: new ClassVisitorFactory({ 13 | createGetAccessorVisitor: create, 14 | createSetAccessorVisitor: create, 15 | createMethodVisitor: create, 16 | createConstructorVisitor: create, 17 | }), 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexityForNormalNode.ts: -------------------------------------------------------------------------------- 1 | import CyclomaticComplexityAnalyzer from './CyclomaticComplexityAnalyzer'; 2 | 3 | /** NormalNode とは、ソースコード及びトップレベルの Class 以外を想定している。 */ 4 | export default class CyclomaticComplexityForNormalNode extends CyclomaticComplexityAnalyzer {} 5 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexityForSourceCode.ts: -------------------------------------------------------------------------------- 1 | import { TopLevelVisitorFactory } from '../VisitorFactory'; 2 | import type { MetricsScope } from '../metricsModels'; 3 | import CyclomaticComplexityAnalyzer from './CyclomaticComplexityAnalyzer'; 4 | import CyclomaticComplexityForNormalNode from './CyclomaticComplexityForNormalNode'; 5 | import CyclomaticComplexityForClass from './CyclomaticComplexityForClass'; 6 | 7 | const createNormal = (name: string, scope: MetricsScope) => 8 | new CyclomaticComplexityForNormalNode(name, scope); 9 | const createClassVisitor = (name: string, scope: MetricsScope) => 10 | new CyclomaticComplexityForClass(name, scope); 11 | 12 | export default class CyclomaticComplexityForSourceCode extends CyclomaticComplexityAnalyzer { 13 | constructor(name: string) { 14 | super(name, 'file', { 15 | visitorFactory: new TopLevelVisitorFactory( 16 | 1, 17 | { 18 | createFunctionVisitor: createNormal, 19 | createArrowFunctionVisitor: createNormal, 20 | createIIFEVisitor: createNormal, 21 | createObjectLiteralExpressionVisitor: createNormal, 22 | createInterfaceDeclarationVisitor: createNormal, 23 | createTypeAliasDeclarationVisitor: createNormal, 24 | createClassVisitor, 25 | }, 26 | ), 27 | }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/CyclomaticComplexityMetrics.ts: -------------------------------------------------------------------------------- 1 | import type { HierarchicalMetris } from '../HierarchicalMetris'; 2 | 3 | export type Score = number; 4 | export type CyclomaticComplexityMetrics = HierarchicalMetris; 5 | -------------------------------------------------------------------------------- /src/feature/metric/cyclomaticComplexity/index.ts: -------------------------------------------------------------------------------- 1 | import type CyclomaticComplexityAnalyzer from './CyclomaticComplexityAnalyzer'; 2 | import CyclomaticComplexityForSourceCode from './CyclomaticComplexityForSourceCode'; 3 | export { CyclomaticComplexityMetrics } from './CyclomaticComplexityMetrics'; 4 | 5 | export function createCyclomaticComplexityAnalyzer( 6 | ...params: ConstructorParameters 7 | ): CyclomaticComplexityAnalyzer { 8 | return new CyclomaticComplexityForSourceCode(...params); 9 | } 10 | -------------------------------------------------------------------------------- /src/feature/metric/functions/calculateMaintainabilityIndex.ts: -------------------------------------------------------------------------------- 1 | import type { Tree } from '../../../utils/Tree'; 2 | import type { CognitiveComplexityMetrics } from '../cognitiveComplexity'; 3 | import type { CyclomaticComplexityMetrics } from '../cyclomaticComplexity'; 4 | import type { MetricsScope } from '../metricsModels'; 5 | import type { SemanticSyntaxVolumeMetrics } from '../semanticSyntaxVolume'; 6 | 7 | interface RawMetrics { 8 | semanticSyntaxVolume: SemanticSyntaxVolumeMetrics; 9 | cyclomaticComplexity: CyclomaticComplexityMetrics; 10 | cognitiveComplexity: CognitiveComplexityMetrics; 11 | scope: MetricsScope; 12 | name: string; 13 | filePath: string; 14 | } 15 | 16 | export interface RawMetricsWithMaintainabilityIndex extends RawMetrics { 17 | maintainabilityIndex: number; 18 | } 19 | 20 | export function calculateMaintainabilityIndex({ 21 | filePath, 22 | name, 23 | scope, 24 | semanticSyntaxVolume, 25 | cognitiveComplexity, 26 | cyclomaticComplexity, 27 | children, 28 | }: Tree): Tree { 29 | const isClassOrFile = (scope === 'class' || scope === 'file') && children; 30 | const divisor = isClassOrFile ? 2 : 1; 31 | const semanticSyntaxVolumeScore = semanticSyntaxVolume.score.volume / divisor; 32 | const cyclomaticComplexityScore = cyclomaticComplexity.score / divisor; 33 | const cognitiveComplexityScore = cognitiveComplexity.score / divisor; 34 | const linesScore = semanticSyntaxVolume.score.lines / divisor; 35 | const MAGIC_NUMBER = 171; 36 | const maintainabilityIndex = Math.min( 37 | 100, 38 | Math.max( 39 | 0, 40 | ((MAGIC_NUMBER - 41 | 5.2 * Math.log(semanticSyntaxVolumeScore) - 42 | 0.115 * cyclomaticComplexityScore - 43 | 0.115 * cognitiveComplexityScore - 44 | 16.2 * Math.log(linesScore)) * 45 | 100) / 46 | MAGIC_NUMBER, 47 | ), 48 | ); 49 | return { 50 | filePath, 51 | name, 52 | scope, 53 | semanticSyntaxVolume, 54 | cognitiveComplexity, 55 | cyclomaticComplexity, 56 | maintainabilityIndex, 57 | children: children?.map(calculateMaintainabilityIndex), 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /src/feature/metric/functions/convertRawToCodeMetrics.ts: -------------------------------------------------------------------------------- 1 | import { pipe, zipWith } from 'remeda'; 2 | import type { CognitiveComplexityMetrics } from '../cognitiveComplexity'; 3 | import type { CyclomaticComplexityMetrics } from '../cyclomaticComplexity'; 4 | import type { 5 | CodeMetrics, 6 | MetricsScope, 7 | MetricsScoreState, 8 | } from '../metricsModels'; 9 | import type { SemanticSyntaxVolumeMetrics } from '../semanticSyntaxVolume'; 10 | import type { Tree } from '../../../utils/Tree'; 11 | import type { RawMetricsWithMaintainabilityIndex } from './calculateMaintainabilityIndex'; 12 | import { calculateMaintainabilityIndex } from './calculateMaintainabilityIndex'; 13 | 14 | export interface RawMetrics { 15 | semanticSyntaxVolume: SemanticSyntaxVolumeMetrics; 16 | cyclomaticComplexity: CyclomaticComplexityMetrics; 17 | cognitiveComplexity: CognitiveComplexityMetrics; 18 | } 19 | 20 | interface ZippedRawMetrics extends RawMetrics { 21 | name: string; 22 | scope: MetricsScope; 23 | } 24 | 25 | interface ZippedRawMetricsWithFilePath extends ZippedRawMetrics { 26 | filePath: string; 27 | } 28 | 29 | export function convertRawToCodeMetrics({ 30 | semanticSyntaxVolume, 31 | cognitiveComplexity, 32 | cyclomaticComplexity, 33 | }: RawMetrics): Tree { 34 | return pipe( 35 | { 36 | semanticSyntaxVolume, 37 | cognitiveComplexity, 38 | cyclomaticComplexity, 39 | }, 40 | zipChildren, 41 | addFilePath, 42 | calculateMaintainabilityIndex, 43 | convertCalculatedToCodeMetrics, 44 | ); 45 | } 46 | 47 | function addFilePath( 48 | zippedRawMetrics: Tree, 49 | filePath?: string, 50 | ): Tree { 51 | return { 52 | ...zippedRawMetrics, 53 | filePath: filePath ?? zippedRawMetrics.name, 54 | children: zippedRawMetrics.children?.map(c => 55 | addFilePath(c, filePath ?? zippedRawMetrics.name), 56 | ), 57 | }; 58 | } 59 | 60 | function convertCalculatedToCodeMetrics({ 61 | filePath, 62 | name, 63 | scope, 64 | semanticSyntaxVolume, 65 | cognitiveComplexity, 66 | cyclomaticComplexity, 67 | maintainabilityIndex, 68 | children, 69 | }: Tree): Tree { 70 | return { 71 | filePath, 72 | name, 73 | scope, 74 | scores: [ 75 | { 76 | name: 'Maintainability Index', 77 | value: maintainabilityIndex, 78 | state: getMarker(scope)(maintainabilityIndex), 79 | betterDirection: 'higher', 80 | }, 81 | { 82 | name: 'Cyclomatic Complexity', 83 | value: cyclomaticComplexity.score, 84 | state: 'normal', 85 | betterDirection: 'lower', 86 | }, 87 | { 88 | name: 'Cognitive Complexity', 89 | value: cognitiveComplexity.score, 90 | state: 'normal', 91 | betterDirection: 'lower', 92 | }, 93 | { 94 | name: 'lines', 95 | value: semanticSyntaxVolume.score.lines, 96 | state: 'normal', 97 | betterDirection: 'lower', 98 | }, 99 | { 100 | name: 'semantic syntax volume', 101 | value: semanticSyntaxVolume.score.volume, 102 | state: 'normal', 103 | betterDirection: 'lower', 104 | }, 105 | { 106 | name: 'total operands', 107 | value: semanticSyntaxVolume.score.operandsTotal, 108 | state: 'normal', 109 | betterDirection: 'lower', 110 | }, 111 | { 112 | name: 'unique operands', 113 | value: semanticSyntaxVolume.score.operandsUnique, 114 | state: 'normal', 115 | betterDirection: 'lower', 116 | }, 117 | { 118 | name: 'total semantic syntax', 119 | value: semanticSyntaxVolume.score.semanticSyntaxTotal, 120 | state: 'normal', 121 | betterDirection: 'lower', 122 | }, 123 | { 124 | name: 'unique semantic syntax', 125 | value: semanticSyntaxVolume.score.semanticSyntaxUnique, 126 | state: 'normal', 127 | betterDirection: 'lower', 128 | }, 129 | ] as const, 130 | children: children?.map(convertCalculatedToCodeMetrics), 131 | }; 132 | } 133 | 134 | function zipChildren({ 135 | semanticSyntaxVolume, 136 | cognitiveComplexity, 137 | cyclomaticComplexity, 138 | }: RawMetrics): Tree { 139 | return { 140 | name: cognitiveComplexity.name, // cognitiveComplexity じゃなくてもいい 141 | scope: cognitiveComplexity.scope, // cognitiveComplexity じゃなくてもいい 142 | cognitiveComplexity, 143 | semanticSyntaxVolume, 144 | cyclomaticComplexity, 145 | children: zipHierarchicalMetris( 146 | semanticSyntaxVolume.children, 147 | cyclomaticComplexity.children, 148 | cognitiveComplexity.children, 149 | )?.map(zipChildren), 150 | }; 151 | } 152 | 153 | function zipHierarchicalMetris( 154 | semanticSyntaxVolumeChildren?: SemanticSyntaxVolumeMetrics[], 155 | cyclomaticComplexityChildren?: CyclomaticComplexityMetrics[], 156 | cognitiveComplexityChildren?: CognitiveComplexityMetrics[], 157 | ): RawMetrics[] | undefined { 158 | return semanticSyntaxVolumeChildren && 159 | cyclomaticComplexityChildren && 160 | cognitiveComplexityChildren 161 | ? zipWith( 162 | ( 163 | cognitiveComplexity: CognitiveComplexityMetrics, 164 | doubleProp: Pick< 165 | RawMetrics, 166 | 'semanticSyntaxVolume' | 'cyclomaticComplexity' 167 | >, 168 | ) => { 169 | return { 170 | cognitiveComplexity, 171 | ...doubleProp, 172 | } satisfies RawMetrics; 173 | }, 174 | )( 175 | cognitiveComplexityChildren, 176 | zipWith( 177 | ( 178 | semanticSyntaxVolume: SemanticSyntaxVolumeMetrics, 179 | cyclomaticComplexity: CyclomaticComplexityMetrics, 180 | ) => { 181 | return { 182 | semanticSyntaxVolume, 183 | cyclomaticComplexity, 184 | } satisfies Pick< 185 | RawMetrics, 186 | 'semanticSyntaxVolume' | 'cyclomaticComplexity' 187 | >; 188 | }, 189 | )(semanticSyntaxVolumeChildren, cyclomaticComplexityChildren), 190 | ) 191 | : undefined; 192 | } 193 | 194 | function getMarker(scope: MetricsScope) { 195 | switch (scope) { 196 | case 'class': 197 | return getClassMIState; 198 | case 'file': 199 | return getFileMIState; 200 | default: 201 | return getMIState; 202 | } 203 | } 204 | 205 | function getMIState(score: number): MetricsScoreState { 206 | if (score < 10) return 'critical'; 207 | if (score < 20) return 'alert'; 208 | return 'normal'; 209 | } 210 | 211 | function getClassMIState(score: number): MetricsScoreState { 212 | if (score < 10) return 'critical'; 213 | if (score < 20) return 'alert'; 214 | return 'normal'; 215 | } 216 | 217 | function getFileMIState(score: number): MetricsScoreState { 218 | if (score < 10) return 'critical'; 219 | if (score < 20) return 'alert'; 220 | return 'normal'; 221 | } 222 | -------------------------------------------------------------------------------- /src/feature/metric/functions/getIconByState.ts: -------------------------------------------------------------------------------- 1 | import type { MetricsScoreState } from '../metricsModels'; 2 | 3 | export function getIconByState(state: MetricsScoreState): string { 4 | switch (state) { 5 | case 'critical': 6 | return '💥'; 7 | case 'alert': 8 | return '🧨'; 9 | default: 10 | return ''; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/feature/metric/functions/getMetricsRawData.ts: -------------------------------------------------------------------------------- 1 | import { readFileSync } from 'fs'; 2 | import ts from 'typescript'; 3 | import AstTraverser from '../../util/AstTraverser'; 4 | import { createCyclomaticComplexityAnalyzer } from '../cyclomaticComplexity'; 5 | import { createCognitiveComplexityAnalyzer } from '../cognitiveComplexity'; 6 | import { createSemanticSyntaxVolumeAnalyzer } from '../semanticSyntaxVolume'; 7 | 8 | export function getMetricsRawData(path: string) { 9 | const sourceCode = readFileSync(path, 'utf-8'); 10 | const source = ts.createSourceFile( 11 | path, 12 | sourceCode, 13 | ts.ScriptTarget.ESNext, 14 | // parent を使うことがあるので true 15 | true, 16 | ts.ScriptKind.TS, 17 | ); 18 | const cyclomaticComplexity = createCyclomaticComplexityAnalyzer(path); 19 | const semanticSyntaxVolume = createSemanticSyntaxVolumeAnalyzer(path); 20 | const cognitiveComplexityAnalyzer = createCognitiveComplexityAnalyzer(path); 21 | const astTraverser = new AstTraverser(source, [ 22 | semanticSyntaxVolume, 23 | cyclomaticComplexity, 24 | cognitiveComplexityAnalyzer, 25 | ]); 26 | astTraverser.traverse(); 27 | return { 28 | semanticSyntaxVolume: semanticSyntaxVolume.metrics, 29 | cyclomaticComplexity: cyclomaticComplexity.metrics, 30 | cognitiveComplexity: cognitiveComplexityAnalyzer.metrics, 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/feature/metric/functions/toSortedMetrics.ts: -------------------------------------------------------------------------------- 1 | import type { Tree } from '../../../utils/Tree'; 2 | import type { CodeMetrics } from '../metricsModels'; 3 | 4 | export function toSortedMetrics>>( 5 | list: T[], 6 | ): T[] { 7 | const newList = list 8 | .toSorted( 9 | (a, b) => 10 | (a.scores.find(s => s.name === 'Maintainability Index')?.value ?? 0) - 11 | (b.scores.find(s => s.name === 'Maintainability Index')?.value ?? 0), 12 | ) 13 | .map( 14 | // 新規オブジェクトとして登録する。後続処理で children を変更するが、それを引数で受け取った値に影響させたくないため。 15 | m => ({ ...m }), 16 | ) 17 | .map(m => { 18 | if (m.children) { 19 | m.children = toSortedMetrics(m.children); 20 | } 21 | return m; 22 | }); 23 | return newList; 24 | } 25 | -------------------------------------------------------------------------------- /src/feature/metric/functions/updateMetricsName.ts: -------------------------------------------------------------------------------- 1 | import type { Tree } from '../../../utils/Tree'; 2 | import type { MetricsScope } from '../metricsModels'; 3 | 4 | /** 5 | * 各ノードの名前を更新する。 6 | * 7 | * - ノードのスコープがファイルの場合は `-` に変更 8 | * - クラスの子ノードの場合は `クラス名.メソッド名` に変更 9 | * - それ以外はそのまま 10 | */ 11 | export function updateMetricsName< 12 | T extends Tree<{ 13 | name: string; 14 | scope: MetricsScope; 15 | }>, 16 | >(metrics: T, classname?: string): T { 17 | return { 18 | // T を拡張した型にも対応するためスプレッド構文でもマージすること 19 | ...metrics, 20 | name: classname 21 | ? `${classname}.${metrics.name}` 22 | : metrics.scope === 'file' 23 | ? '-' 24 | : metrics.name, 25 | children: metrics.children?.map(c => 26 | updateMetricsName( 27 | c, 28 | metrics.scope === 'class' ? metrics.name : undefined, 29 | ), 30 | ), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/feature/metric/metricsModels.ts: -------------------------------------------------------------------------------- 1 | // feature/metric の外からも参照される型を定義する 2 | 3 | export type MetricsScope = 4 | | 'file' 5 | | 'function' 6 | | 'class' 7 | | 'method' 8 | | 'object' 9 | | 'type' 10 | | 'interface'; 11 | 12 | export type MetricsScoreState = 'critical' | 'alert' | 'normal'; 13 | 14 | export interface Score { 15 | /** 計測した値の名前。 Maintainability Index など。 */ 16 | name: string; 17 | /** 計測した値 */ 18 | value: number; 19 | /** 判定結果 */ 20 | state: MetricsScoreState; 21 | /** 値が高いほど良いか低いほど良いか */ 22 | betterDirection: 'higher' | 'lower' | 'none'; 23 | } 24 | 25 | export interface CodeMetrics { 26 | filePath: string; 27 | /** クラス名や関数名など */ 28 | name: string; 29 | scope: MetricsScope; 30 | scores: Score[]; 31 | } 32 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/SemanticSyntaxVolume.tsx.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from 'vitest'; 2 | import ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import { type SemanticSyntaxVolumeMetrics } from './SemanticSyntaxVolumeAnalyzer'; 6 | import { createSemanticSyntaxVolumeAnalyzer } from '.'; 7 | 8 | const button = `\ 9 | function Button({ flag }: { flag: boolean }) { 10 | return ( 11 | 12 | 21 |
22 |
23 | ); 24 | }\ 25 | `; 26 | interface OperatorTest { 27 | perspective: string; 28 | tests: [ 29 | string, 30 | Omit, 31 | ][]; 32 | } 33 | describe.each([ts.ScriptKind.TSX])(`%s`, scriptKind => { 34 | describe.each([ 35 | { 36 | perspective: '大きいコンポーネント', 37 | tests: [ 38 | [ 39 | button, 40 | { 41 | operandsTotal: 39, 42 | operandsUnique: 24, 43 | semanticSyntaxTotal: 49, 44 | semanticSyntaxUnique: 25, 45 | }, 46 | ], 47 | ], 48 | }, 49 | { 50 | perspective: '小さいコンポーネント', 51 | tests: [ 52 | [ 53 | '', 54 | { 55 | operandsTotal: 4, 56 | operandsUnique: 4, 57 | semanticSyntaxTotal: 3, 58 | semanticSyntaxUnique: 2, 59 | }, 60 | ], 61 | [ 62 | '2', 63 | { 64 | operandsTotal: 6, 65 | operandsUnique: 5, 66 | semanticSyntaxTotal: 5, 67 | semanticSyntaxUnique: 4, 68 | }, 69 | ], 70 | [ 71 | 'function A() { return <>hellohi; }', 72 | { 73 | operandsTotal: 7, 74 | operandsUnique: 4, 75 | semanticSyntaxTotal: 12, 76 | semanticSyntaxUnique: 9, 77 | }, 78 | ], 79 | [ 80 | 'const A = () => <>hellohi', 81 | { 82 | operandsTotal: 7, 83 | operandsUnique: 4, 84 | semanticSyntaxTotal: 12, 85 | semanticSyntaxUnique: 9, 86 | }, 87 | ], 88 | [ 89 | 'const A = () => } />', 90 | { 91 | operandsTotal: 6, 92 | operandsUnique: 6, 93 | semanticSyntaxTotal: 11, 94 | semanticSyntaxUnique: 6, 95 | }, 96 | ], 97 | [ 98 | 'const A = (a:boolean) => {a&&
}
', 99 | { 100 | operandsTotal: 6, 101 | operandsUnique: 4, 102 | semanticSyntaxTotal: 12, 103 | semanticSyntaxUnique: 12, 104 | }, 105 | ], 106 | ], 107 | }, 108 | ] satisfies OperatorTest[])( 109 | `${ts.ScriptKind[scriptKind]} $perspective`, 110 | ({ perspective, tests }) => { 111 | test.each(tests)(`${perspective} %s`, (sourceCode, expected) => { 112 | const source = ts.createSourceFile( 113 | 'sample.tsx', 114 | sourceCode, 115 | ts.ScriptTarget.ESNext, 116 | // parent を使うことがあるので true 117 | true, 118 | scriptKind, 119 | ); 120 | const astLogger = new AstLogger(); 121 | const volume = createSemanticSyntaxVolumeAnalyzer('sample.tsx'); 122 | const astTraverser = new AstTraverser(source, [astLogger, volume]); 123 | astTraverser.traverse(); 124 | console.log(astLogger.log); 125 | console.log(volume.volume); 126 | expect(volume.metrics.score).toEqual(expect.objectContaining(expected)); 127 | }); 128 | }, 129 | ); 130 | }); 131 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/SemanticSyntaxVolume.type.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect, describe } from 'vitest'; 2 | import ts from 'typescript'; 3 | import AstLogger from '../../util/AstLogger'; 4 | import AstTraverser from '../../util/AstTraverser'; 5 | import type { SemanticSyntaxVolumeMetrics } from './SemanticSyntaxVolumeAnalyzer'; 6 | import { createSemanticSyntaxVolumeAnalyzer } from '.'; 7 | 8 | interface OperatorTest { 9 | perspective: string; 10 | tests: [ 11 | string, 12 | Omit, 13 | ][]; 14 | } 15 | describe.each([ts.ScriptKind.TS, ts.ScriptKind.TSX])(`%s`, scriptKind => { 16 | describe.each([ 17 | { 18 | perspective: '型の定義', 19 | tests: [ 20 | [ 21 | 'interface A {a:boolean; b:number; c:string;}', 22 | { 23 | operandsTotal: 4, 24 | operandsUnique: 4, 25 | semanticSyntaxTotal: 7, 26 | semanticSyntaxUnique: 5, 27 | }, 28 | ], 29 | [ 30 | 'type A = {a:boolean, b:number, c:string};', 31 | { 32 | operandsTotal: 4, 33 | operandsUnique: 4, 34 | semanticSyntaxTotal: 8, 35 | semanticSyntaxUnique: 6, 36 | }, 37 | ], 38 | [ 39 | '/*array*/ type A = string[];', 40 | { 41 | operandsTotal: 1, 42 | operandsUnique: 1, 43 | semanticSyntaxTotal: 3, 44 | semanticSyntaxUnique: 3, 45 | }, 46 | ], 47 | [ 48 | '/*tuple*/ type A = [string, number];', 49 | { 50 | operandsTotal: 1, 51 | operandsUnique: 1, 52 | semanticSyntaxTotal: 4, 53 | semanticSyntaxUnique: 4, 54 | }, 55 | ], 56 | [ 57 | '/*enum*/ enum Color {Red = 1,Green,Blue,}let c: Color = Color.Green;', 58 | { 59 | operandsTotal: 9, 60 | operandsUnique: 6, 61 | semanticSyntaxTotal: 7, 62 | semanticSyntaxUnique: 5, 63 | }, 64 | ], 65 | [ 66 | '/*any and unknown*/ try{throw 1;}catch(e){(e as unknown as any).toString();}', 67 | { 68 | operandsTotal: 4, 69 | operandsUnique: 3, 70 | semanticSyntaxTotal: 12, 71 | semanticSyntaxUnique: 10, 72 | }, 73 | ], 74 | [ 75 | 'type A = {a:null, b:undefined};', 76 | { 77 | operandsTotal: 3, 78 | operandsUnique: 3, 79 | semanticSyntaxTotal: 7, 80 | semanticSyntaxUnique: 6, 81 | }, 82 | ], 83 | [ 84 | 'function e(m:string):never{throw new Error(m);}', 85 | { 86 | operandsTotal: 4, 87 | operandsUnique: 3, 88 | semanticSyntaxTotal: 7, 89 | semanticSyntaxUnique: 7, 90 | }, 91 | ], 92 | [ 93 | 'function e(m:string){throw new Error(m);}', 94 | { 95 | operandsTotal: 4, 96 | operandsUnique: 3, 97 | semanticSyntaxTotal: 6, 98 | semanticSyntaxUnique: 6, 99 | }, 100 | ], 101 | [ 102 | 'type A = B & C | B & D', 103 | { 104 | operandsTotal: 5, 105 | operandsUnique: 4, 106 | semanticSyntaxTotal: 8, 107 | semanticSyntaxUnique: 4, 108 | }, 109 | ], 110 | [ 111 | 'type TodoPreview = Pick;', 112 | { 113 | operandsTotal: 5, 114 | operandsUnique: 5, 115 | semanticSyntaxTotal: 6, 116 | semanticSyntaxUnique: 4, 117 | }, 118 | ], 119 | [ 120 | 'type TodoPreview = {title:string,completed:boolean};', 121 | { 122 | operandsTotal: 3, 123 | operandsUnique: 3, 124 | semanticSyntaxTotal: 6, 125 | semanticSyntaxUnique: 5, 126 | }, 127 | ], 128 | ], 129 | }, 130 | ] satisfies OperatorTest[])( 131 | `${ts.ScriptKind[scriptKind]} $perspective`, 132 | ({ perspective, tests }) => { 133 | test.each(tests)(`${perspective} %s`, (sourceCode, expected) => { 134 | const source = ts.createSourceFile( 135 | 'sample.tsx', 136 | sourceCode, 137 | ts.ScriptTarget.ESNext, 138 | // parent を使うことがあるので true 139 | true, 140 | scriptKind, 141 | ); 142 | const astLogger = new AstLogger(); 143 | const volume = createSemanticSyntaxVolumeAnalyzer('sample.tsx'); 144 | const astTraverser = new AstTraverser(source, [astLogger, volume]); 145 | astTraverser.traverse(); 146 | console.log(astLogger.log); 147 | console.log(volume.volume); 148 | expect(volume.metrics.score).toEqual(expect.objectContaining(expected)); 149 | }); 150 | }, 151 | ); 152 | }); 153 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/SemanticSyntaxVolumeAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import type { AnalyzeProps } from '../HierarchicalMetricsAnalyzer'; 3 | import HierarchicalMetricsAnalyzer from '../HierarchicalMetricsAnalyzer'; 4 | import type { HierarchicalMetris } from '../HierarchicalMetris'; 5 | 6 | export interface SemanticSyntaxVolumeScores { 7 | /** 構文のボリューム */ 8 | volume: number; 9 | /** 演算子の総数 */ 10 | semanticSyntaxTotal: number; 11 | /** ユニークな演算子の数 */ 12 | semanticSyntaxUnique: number; 13 | /** オペランドの総数 */ 14 | operandsTotal: number; 15 | /** ユニークなオペランドの数 */ 16 | operandsUnique: number; 17 | /** 対象の行数 */ 18 | lines: number; 19 | } 20 | 21 | export type SemanticSyntaxVolumeMetrics = 22 | HierarchicalMetris; 23 | 24 | /** `a++` や `a--` を見分けるための型 */ 25 | type PostOperator = `Post${ts.SyntaxKind}`; 26 | /** `++a` や `--a` を見分けるための型 */ 27 | type PreOperator = `Pre${ts.SyntaxKind}`; 28 | /** `const` や `let` を見分けるための型 */ 29 | type VariableDeclarationAndFlags = `${ts.SyntaxKind}/${ts.NodeFlags}`; 30 | type SemanticSyntaxKind = 31 | | PostOperator 32 | | PreOperator 33 | | VariableDeclarationAndFlags; 34 | 35 | const isOperand = (kind: ts.SyntaxKind): boolean => { 36 | const operandSyntaxKinds: ts.SyntaxKind[] = [ 37 | ts.SyntaxKind.Identifier, 38 | ts.SyntaxKind.PrivateIdentifier, 39 | ts.SyntaxKind.FirstLiteralToken, 40 | ts.SyntaxKind.StringLiteral, 41 | ts.SyntaxKind.NumericLiteral, 42 | ts.SyntaxKind.BigIntLiteral, 43 | ts.SyntaxKind.RegularExpressionLiteral, 44 | ts.SyntaxKind.TrueKeyword, 45 | ts.SyntaxKind.FalseKeyword, 46 | // JSX におけるオペランド 47 | ts.SyntaxKind.JsxText, 48 | // テンプレートリテラルにおけるオペランド 49 | ts.SyntaxKind.FirstTemplateToken, 50 | ts.SyntaxKind.NoSubstitutionTemplateLiteral, 51 | ts.SyntaxKind.TemplateHead, 52 | ts.SyntaxKind.TemplateMiddle, 53 | // ts.SyntaxKind.TemplateSpan, // 2}` などが該当するためオペランドではない 54 | ts.SyntaxKind.LastTemplateToken, 55 | ]; 56 | return operandSyntaxKinds.includes(kind); 57 | }; 58 | 59 | /** 60 | * AST の Syntax のうち、人がソースコードを読んだ際に認識する意味のあるまとまり(ノード)をカウントしたい。 61 | * そのため、認識するまとまりとして重複するものを除去する。 62 | */ 63 | function isIgnoredSyntaxKind(kind: ts.SyntaxKind): boolean { 64 | const ignoredSyntaxKinds: ts.SyntaxKind[] = [ 65 | ts.SyntaxKind.SourceFile, 66 | ts.SyntaxKind.FirstStatement, 67 | ts.SyntaxKind.EndOfFileToken, 68 | ts.SyntaxKind.VariableDeclaration, // VariableDeclarationList と Identifier でカウントする 69 | ts.SyntaxKind.ExpressionStatement, // ExpressionStatement の中には XxxExpression が含まれるので無視して良い 70 | ]; 71 | return ignoredSyntaxKinds.includes(kind); 72 | } 73 | 74 | export default abstract class SemanticSyntaxVolume extends HierarchicalMetricsAnalyzer { 75 | protected analyze({ node, sourceFile }: AnalyzeProps) { 76 | this.#setLineCountFromNode(node); 77 | if (isIgnoredSyntaxKind(node.kind)) return; 78 | if (isOperand(node.kind)) { 79 | this.#handleOperand(node, sourceFile); 80 | } else { 81 | this.#handleSemanticSyntaxNode(node); 82 | } 83 | } 84 | 85 | /** 86 | * 解析対象とするソースコードの行数を格納する。 87 | * ソースコードは、ファイル全体となる場合や1つの関数となる場合など様々なので、 88 | * 解析開始時点で一番最初に解析対象となったノードの行数を格納する。 89 | */ 90 | #lineCount = -1; 91 | #setLineCountFromNode(node: ts.Node) { 92 | if (this.#lineCount !== -1) return; 93 | this.#lineCount = node.getText(node.getSourceFile()).split('\n').length; 94 | } 95 | get lines(): number { 96 | return this.#lineCount; 97 | } 98 | 99 | readonly #uniqueSemanticSyntaxKinds = new Set(); 100 | #totalSemanticSyntax = 0; 101 | 102 | readonly #uniqueOperands = new Set(); 103 | #totalOperands = 0; 104 | 105 | #addSemanticSyntaxKind(kind: SemanticSyntaxKind) { 106 | this.#uniqueSemanticSyntaxKinds.add(kind); 107 | this.#totalSemanticSyntax++; 108 | } 109 | 110 | #addOperand(operand: string) { 111 | this.#uniqueOperands.add(operand); 112 | this.#totalOperands++; 113 | } 114 | 115 | #handleOperand(node: ts.Node, sourceFile: ts.SourceFile) { 116 | if (!isOperand(node.kind)) return; 117 | this.#addOperand(node.getText(sourceFile)); 118 | } 119 | 120 | #handleSemanticSyntaxNode(node: ts.Node) { 121 | if (ts.isPostfixUnaryExpression(node)) { 122 | this.#addSemanticSyntaxKind(`Post${node.operator}`); 123 | } else if (ts.isPrefixUnaryExpression(node)) { 124 | this.#addSemanticSyntaxKind(`Pre${node.operator}`); 125 | } else if (ts.isVariableDeclarationList(node)) { 126 | // 変数定義をカウントしたくない場合はここをコメントアウトする 127 | this.#addSemanticSyntaxKind(`${node.kind}/${node.flags}`); 128 | } else if (ts.isDotDotDotToken(node)) { 129 | // DotDotDotToken と SpreadElement は同じ演算子としてカウントする 130 | // もし別でカウントしたい事例があれば考える 131 | this.#addSemanticSyntaxKind( 132 | `${ts.SyntaxKind.SpreadElement}/${node.flags}`, 133 | ); 134 | } else if (ts.isAsteriskToken(node)) { 135 | // parent が FunctionDeclaration の場合は乗算ではなくジェネレータ関数なのでカウントしない 136 | // parent が YieldExpression の場合は乗算ではなく yield* 演算子なのでカウントしない 137 | // yield と yield* は同じ演算子としてカウントする 138 | // 後続処理があるので早期リターンしない 139 | if ( 140 | !ts.isFunctionDeclaration(node.parent) && 141 | !ts.isYieldExpression(node.parent) 142 | ) { 143 | this.#addSemanticSyntaxKind(`${node.kind}/${node.flags}`); 144 | } 145 | } else { 146 | this.#addSemanticSyntaxKind( 147 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 148 | (node as any)['operator'] ?? `${node.kind}/${node.flags}`, 149 | ); 150 | } 151 | 152 | if (ts.isIfStatement(node) && node.elseStatement) { 153 | // else をカウントする 154 | this.#addSemanticSyntaxKind(`${ts.SyntaxKind.ElseKeyword}/${node.flags}`); 155 | } 156 | if (ts.isTryStatement(node) && node.finallyBlock) { 157 | // finally をカウントする 158 | this.#addSemanticSyntaxKind( 159 | `${ts.SyntaxKind.FinallyKeyword}/${node.flags}`, 160 | ); 161 | } 162 | } 163 | 164 | protected get score(): SemanticSyntaxVolumeScores { 165 | return { 166 | volume: this.volume, 167 | semanticSyntaxTotal: this.#totalSemanticSyntax, 168 | semanticSyntaxUnique: this.#uniqueSemanticSyntaxKinds.size, 169 | operandsTotal: this.#totalOperands, 170 | operandsUnique: this.#uniqueOperands.size, 171 | lines: this.lines, 172 | }; 173 | } 174 | 175 | get volume(): number { 176 | const N = this.#totalSemanticSyntax + this.#totalOperands; 177 | const n = this.#uniqueSemanticSyntaxKinds.size + this.#uniqueOperands.size; 178 | return N * Math.log2(n); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/SemanticSyntaxVolumeForClass.ts: -------------------------------------------------------------------------------- 1 | import type { MetricsScope } from '../metricsModels'; 2 | import { ClassVisitorFactory } from '../VisitorFactory'; 3 | import SemanticSyntaxVolume from './SemanticSyntaxVolumeAnalyzer'; 4 | import SemanticSyntaxVolumeForNormalNode from './SemanticSyntaxVolumeForNormalNode'; 5 | 6 | const create = (name: string, scope: MetricsScope) => 7 | new SemanticSyntaxVolumeForNormalNode(name, scope); 8 | 9 | export default class SemanticSyntaxVolumeForClass extends SemanticSyntaxVolume { 10 | constructor(name: string, scope: MetricsScope) { 11 | super(name, scope, { 12 | visitorFactory: new ClassVisitorFactory({ 13 | createGetAccessorVisitor: create, 14 | createSetAccessorVisitor: create, 15 | createMethodVisitor: create, 16 | createConstructorVisitor: create, 17 | }), 18 | }); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/SemanticSyntaxVolumeForNormalNode.ts: -------------------------------------------------------------------------------- 1 | import SemanticSyntaxVolume from './SemanticSyntaxVolumeAnalyzer'; 2 | 3 | /** NormalNode とは、ソースコード及びトップレベルの Class 以外を想定している。 */ 4 | export default class SemanticSyntaxVolumeForNormalNode extends SemanticSyntaxVolume {} 5 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/SemanticSyntaxVolumeForSourceCode.ts: -------------------------------------------------------------------------------- 1 | import type { MetricsScope } from '../metricsModels'; 2 | import { TopLevelVisitorFactory } from '../VisitorFactory'; 3 | import SemanticSyntaxVolume from './SemanticSyntaxVolumeAnalyzer'; 4 | import SemanticSyntaxVolumeForClass from './SemanticSyntaxVolumeForClass'; 5 | import SemanticSyntaxVolumeForNormalNode from './SemanticSyntaxVolumeForNormalNode'; 6 | 7 | const createNormal = (name: string, scope: MetricsScope) => 8 | new SemanticSyntaxVolumeForNormalNode(name, scope); 9 | const createClassVisitor = (name: string, scope: MetricsScope) => 10 | new SemanticSyntaxVolumeForClass(name, scope); 11 | 12 | export default class SemanticSyntaxVolumeForSourceCode extends SemanticSyntaxVolume { 13 | constructor(name: string) { 14 | super(name, 'file', { 15 | visitorFactory: new TopLevelVisitorFactory(1, { 16 | createFunctionVisitor: createNormal, 17 | createArrowFunctionVisitor: createNormal, 18 | createIIFEVisitor: createNormal, 19 | createObjectLiteralExpressionVisitor: createNormal, 20 | createInterfaceDeclarationVisitor: createNormal, 21 | createTypeAliasDeclarationVisitor: createNormal, 22 | createClassVisitor, 23 | }), 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/feature/metric/semanticSyntaxVolume/index.ts: -------------------------------------------------------------------------------- 1 | import type SemanticSyntaxVolumeAnalyzer from './SemanticSyntaxVolumeAnalyzer'; 2 | import SemanticSyntaxVolumeForSourceCode from './SemanticSyntaxVolumeForSourceCode'; 3 | export { SemanticSyntaxVolumeMetrics } from './SemanticSyntaxVolumeAnalyzer'; 4 | 5 | export function createSemanticSyntaxVolumeAnalyzer( 6 | ...params: ConstructorParameters 7 | ): SemanticSyntaxVolumeAnalyzer { 8 | return new SemanticSyntaxVolumeForSourceCode(...params); 9 | } 10 | -------------------------------------------------------------------------------- /src/feature/util/AstLogger.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { AstVisitor, VisitProps } from './AstVisitor'; 3 | 4 | export default class AstLogger implements AstVisitor { 5 | visit({ node, depth, sourceFile }: VisitProps): void { 6 | this.#addLog(this.#no++, depth, node, sourceFile); 7 | } 8 | 9 | /** visit したノードのインデックス番号を記録するカウンター */ 10 | #no = 0; 11 | /** visit したノード1つにつき1行データを登録する */ 12 | #logList: string[] = [ 13 | 'No. | depth | code | SyntaxKind | NodeFlags', 14 | '--|--|--|--|--', 15 | ]; 16 | 17 | #addLog(no: number, depth: number, node: ts.Node, sourceFile: ts.SourceFile) { 18 | this.#logList.push( 19 | [ 20 | no.toString().padStart(3, ' '), 21 | depth.toString().padEnd(depth, '>'), 22 | this.#getText(node, sourceFile), 23 | this.#getSyntaxKindText(node), 24 | ts.NodeFlags[node.flags], 25 | ].join(' | '), 26 | ); 27 | } 28 | 29 | #getText(node: ts.Node, sourceFile: ts.SourceFile) { 30 | return node 31 | .getText(sourceFile) 32 | .replaceAll(/\r?\n */g, ' ') 33 | .replaceAll('|', '\\|'); 34 | } 35 | 36 | #getSyntaxKindText(node: ts.Node) { 37 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 38 | const operator = (node as any)['operator']; 39 | return `${ts.SyntaxKind[node.kind]}${operator ? ` (${ts.SyntaxKind[operator]})` : ''}`; 40 | } 41 | 42 | /** 収集したログ文字列を取得する */ 43 | get log() { 44 | return this.#logList.join('\n'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/feature/util/AstTraverser.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { AstVisitor, VisitResult } from './AstVisitor'; 3 | 4 | export default class AstTraverser { 5 | readonly #sourceFile: ts.SourceFile; 6 | 7 | constructor(sourceFile: ts.SourceFile, visitors: AstVisitor[]) { 8 | this.#sourceFile = sourceFile; 9 | visitors.forEach(this.#setVisitor.bind(this)); 10 | } 11 | readonly #visitors = new Map(); 12 | #setVisitor(visitor: AstVisitor) { 13 | const key = Symbol(); 14 | this.#visitors.set(key, visitor); 15 | return key; 16 | } 17 | 18 | #traverse(node: ts.Node, depth: number) { 19 | const visitResults: VisitResult[] = []; 20 | let additionalVisitorIds: symbol[] = []; 21 | 22 | for (const visitor of this.#visitors.values()) { 23 | const result = visitor.visit({ 24 | node, 25 | depth, 26 | sourceFile: this.#sourceFile, 27 | }); 28 | if (result) visitResults.push(result); 29 | additionalVisitorIds = additionalVisitorIds.concat( 30 | result?.additionalVisitors?.map(this.#setVisitor.bind(this)) ?? [], 31 | ); 32 | // additionalVisitor について当該ノードに対する visit を行い結果を保持する。 33 | // 結果の保持は additionalVisitor の leave の処理を行うために必要であるが、 34 | // additionalVisitor が返却する additionalVisitors は無視する。 35 | visitResults.concat( 36 | result?.additionalVisitors 37 | ?.map(visitor => 38 | visitor.visit({ node, depth, sourceFile: this.#sourceFile }), 39 | ) 40 | .filter(r => !!r) ?? [], 41 | ); 42 | } 43 | 44 | const nextDepth = depth + 1; 45 | if (ts.isJsxElement(node)) { 46 | this.#traverse(node.openingElement, nextDepth); 47 | this.#traverse(node.closingElement, nextDepth); 48 | node.children.forEach(node => this.#traverse(node, nextDepth)); 49 | } else if ( 50 | ts.isJsxOpeningElement(node) || 51 | ts.isJsxSelfClosingElement(node) 52 | ) { 53 | this.#traverse(node.tagName, nextDepth); 54 | node.attributes.forEachChild(node => this.#traverse(node, nextDepth)); 55 | } else if (ts.isJsxClosingElement(node)) { 56 | this.#traverse(node.tagName, nextDepth); 57 | } else { 58 | ts.forEachChild(node, node => this.#traverse(node, nextDepth)); 59 | } 60 | 61 | // 後処理 62 | visitResults 63 | .map(result => result.leave) 64 | .filter(fn => !!fn) 65 | .forEach(fn => fn({ node, depth, sourceFile: this.#sourceFile })); 66 | additionalVisitorIds.forEach(id => this.#visitors.delete(id)); 67 | } 68 | 69 | traverse() { 70 | this.#traverse(this.#sourceFile, 0); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/feature/util/AstVisitor.ts: -------------------------------------------------------------------------------- 1 | import type ts from 'typescript'; 2 | 3 | export interface VisitProps { 4 | node: ts.Node; 5 | depth: number; 6 | sourceFile: ts.SourceFile; 7 | } 8 | 9 | export type Leave = (props: VisitProps) => void; 10 | 11 | export interface VisitResult { 12 | /** 13 | * 当該ノードの子孫ノードの解析が全て終わり兄弟ノードまたは親ノードの解析へと移行する際に実行する処理。 14 | */ 15 | leave?: Leave; 16 | /** 17 | * 当該ノードとその子孫ノードに対して Visitor を追加したい場合に指定する。 18 | * additinalVisitor は当該ノードに対する visit を行うが、その結果返却される additionalVisitors は無視する。 19 | */ 20 | additionalVisitors?: AstVisitor[]; 21 | } 22 | 23 | type Visit = (props: VisitProps) => void | VisitResult; 24 | 25 | export interface AstVisitor { 26 | visit: Visit; 27 | } 28 | -------------------------------------------------------------------------------- /src/feature/util/ProjectTraverser.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | import type { AstVisitor } from './AstVisitor'; 3 | import AstTraverser from './AstTraverser'; 4 | 5 | type AstVisitorFactory = ( 6 | sourceFile: ts.SourceFile, 7 | tsConfig: ts.ParsedCommandLine, 8 | system: ts.System, 9 | ) => T; 10 | 11 | export default class ProjectTraverser { 12 | constructor(tsconfig: ts.ParsedCommandLine, system: ts.System = ts.sys) { 13 | this.#system = system; 14 | this.#tsconfig = tsconfig; 15 | const program = ts.createProgram( 16 | tsconfig.fileNames, 17 | tsconfig.options, 18 | ts.createCompilerHost(tsconfig.options, true), 19 | ); 20 | 21 | this.#sourceFiles = program 22 | .getSourceFiles() 23 | .filter(sourceFile => !sourceFile.fileName.includes('node_modules')); 24 | } 25 | 26 | #sourceFiles: ts.SourceFile[]; 27 | #system: ts.System; 28 | #tsconfig: ts.ParsedCommandLine; 29 | 30 | /** 31 | * 通常 ts.SourceFile の fileName は `/usr/ysk8/dev/typescript-graph/src/foo/bar` なのでそれを `src/foo/bar` に加工して返す。 32 | * 前提として、options に rootDir が指定されている必要がある。 33 | * 34 | * TODO: getFilePath は至るところで使われるのでユーティリティ関数化するべき 35 | */ 36 | public getFilePath(fileName: string): string { 37 | return this.#tsconfig.options.rootDir 38 | ? fileName.replace(this.#tsconfig.options.rootDir + '/', '') 39 | : fileName; 40 | } 41 | 42 | traverse< 43 | T1 extends AstVisitor, 44 | T2 extends AstVisitor = never, 45 | T3 extends AstVisitor = never, 46 | T4 extends AstVisitor = never, 47 | T5 extends AstVisitor = never, 48 | >( 49 | filter: (filePath: string) => boolean, 50 | factory1: AstVisitorFactory, 51 | factory2?: AstVisitorFactory, 52 | factory3?: AstVisitorFactory, 53 | factory4?: AstVisitorFactory, 54 | factory5?: AstVisitorFactory, 55 | ): [T1, T2, T3, T4, T5][] { 56 | return this.#sourceFiles 57 | .filter(sourceFile => filter(this.getFilePath(sourceFile.fileName))) 58 | .map(sourceFile => { 59 | const visitors = [ 60 | factory1(sourceFile, this.#tsconfig, this.#system), 61 | factory2?.(sourceFile, this.#tsconfig, this.#system), 62 | factory3?.(sourceFile, this.#tsconfig, this.#system), 63 | factory4?.(sourceFile, this.#tsconfig, this.#system), 64 | factory5?.(sourceFile, this.#tsconfig, this.#system), 65 | ]; 66 | 67 | new AstTraverser(sourceFile, visitors.filter(Boolean)).traverse(); 68 | return visitors; 69 | }) 70 | .filter(Boolean); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/feature/util/astUtils.ts: -------------------------------------------------------------------------------- 1 | import ts from 'typescript'; 2 | 3 | export type TopLevelMatcher = ( 4 | topLevelDepth: number, 5 | currentDepth: number, 6 | node: ts.Node, 7 | ) => boolean; 8 | 9 | /** トップレベルに定義された関数かどうかを判定する */ 10 | export function isTopLevelFunction( 11 | topLevelDepth: number, 12 | currentDepth: number, 13 | node: ts.Node, 14 | ): node is ts.FunctionDeclaration { 15 | // 0:SourceFile>1:FunctionDeclaration 16 | return currentDepth === topLevelDepth && ts.isFunctionDeclaration(node); 17 | } 18 | 19 | /** トップレベルに定義されたアロー関数かどうかを判定する */ 20 | export function isTopLevelArrowFunction( 21 | topLevelDepth: number, 22 | currentDepth: number, 23 | node: ts.Node, 24 | ): node is ts.ArrowFunction { 25 | // 0:SourceFile>1:FirstStatement>2:VariableDeclarationList>3:VariableDeclaration>4:ArrowFunction 26 | return ( 27 | currentDepth - 3 === topLevelDepth && 28 | ts.isArrowFunction(node) && 29 | ts.isVariableDeclaration(node.parent) 30 | ); 31 | } 32 | 33 | /** トップレベルに定義されたIIFE(即時実行関数式)かどうかを判定する */ 34 | export function isTopLevelIIFE( 35 | topLevelDepth: number, 36 | currentDepth: number, 37 | node: ts.Node, 38 | ): node is ts.FunctionExpression { 39 | // 0:SourceFile>1:ExpressionStatement>2:CallExpression>3:ParenthesizedExpression>4:FunctionExpression 40 | return ( 41 | currentDepth - 3 === topLevelDepth && 42 | ts.isFunctionExpression(node) && 43 | ts.isParenthesizedExpression(node.parent) 44 | ); 45 | } 46 | 47 | /** トップレベルに定義されたクラスかどうかを判定する */ 48 | export function isTopLevelClass( 49 | topLevelDepth: number, 50 | currentDepth: number, 51 | node: ts.Node, 52 | ): node is ts.ClassDeclaration { 53 | // 0:SourceFile>1:ClassDeclaration 54 | return currentDepth === topLevelDepth && ts.isClassDeclaration(node); 55 | } 56 | 57 | /** トップレベルに定義されたオブジェクトかどうかを判定する */ 58 | export function isTopLevelObjectLiteralExpression( 59 | topLevelDepth: number, 60 | currentDepth: number, 61 | node: ts.Node, 62 | ): node is ts.ObjectLiteralExpression { 63 | // 0:SourceFile>1:FirstStatement>2:VariableDeclarationList>3:VariableDeclaration>4:ObjectLiteralExpression 64 | return ( 65 | currentDepth - 3 <= topLevelDepth && 66 | ts.isObjectLiteralExpression(node) && 67 | ts.isVariableDeclaration(node.parent) 68 | ); 69 | } 70 | 71 | /** トップレベルに定義された TypeAlias かどうかを判定する */ 72 | export function isTopLevelTypeAlias( 73 | topLevelDepth: number, 74 | currentDepth: number, 75 | node: ts.Node, 76 | ): node is ts.TypeAliasDeclaration { 77 | // 0:SourceFile>1:TypeAliasDeclaration 78 | return currentDepth === topLevelDepth && ts.isTypeAliasDeclaration(node); 79 | } 80 | 81 | /** トップレベルに定義された interface かどうかを判定する */ 82 | export function isTopLevelInterface( 83 | topLevelDepth: number, 84 | currentDepth: number, 85 | node: ts.Node, 86 | ): node is ts.InterfaceDeclaration { 87 | // 0:SourceFile>1:InterfaceDeclaration 88 | return currentDepth === topLevelDepth && ts.isInterfaceDeclaration(node); 89 | } 90 | 91 | const ANONYMOUS_FUNCTION_NAME = 'anonymous function'; 92 | 93 | export function getFunctionName(node: ts.FunctionDeclaration): string { 94 | return ( 95 | node 96 | .getChildren() 97 | .find(n => ts.isIdentifier(n)) 98 | ?.getText(node.getSourceFile()) ?? ANONYMOUS_FUNCTION_NAME 99 | ); 100 | } 101 | 102 | export function getArrowFunctionName(node: ts.ArrowFunction): string { 103 | return ( 104 | node.parent 105 | .getChildren() 106 | .find(n => ts.isIdentifier(n)) 107 | ?.getText(node.getSourceFile()) ?? ANONYMOUS_FUNCTION_NAME 108 | ); 109 | } 110 | 111 | export function getAnonymousFunctionName(): string { 112 | return ANONYMOUS_FUNCTION_NAME; 113 | } 114 | 115 | export function getClassName(node: ts.ClassDeclaration): string { 116 | return ( 117 | node 118 | .getChildren() 119 | .find(n => ts.isIdentifier(n)) 120 | ?.getText(node.getSourceFile()) ?? 'anonymous class' 121 | ); 122 | } 123 | 124 | export function getConstructorName(): string { 125 | return 'constructor'; 126 | } 127 | 128 | export function getMethodName(node: ts.MethodDeclaration): string { 129 | return ( 130 | node 131 | .getChildren() 132 | .find(n => ts.isIdentifier(n) || ts.isPrivateIdentifier(n)) 133 | ?.getText(node.getSourceFile()) ?? 'anonymous method' 134 | ); 135 | } 136 | 137 | export function getGetAccessorName(node: ts.GetAccessorDeclaration): string { 138 | const name = 139 | node 140 | .getChildren() 141 | .find(n => ts.isIdentifier(n)) 142 | ?.getText(node.getSourceFile()) ?? 'anonymous get accessor'; 143 | return `get ${name}`; 144 | } 145 | 146 | export function getSetAccessorName(node: ts.SetAccessorDeclaration): string { 147 | const name = 148 | node 149 | .getChildren() 150 | .find(n => ts.isIdentifier(n)) 151 | ?.getText(node.getSourceFile()) ?? 'anonymous set accessor'; 152 | return `set ${name}`; 153 | } 154 | 155 | export function getObjectName(node: ts.ObjectLiteralExpression): string { 156 | return ( 157 | node.parent 158 | .getChildren() 159 | .find(n => ts.isIdentifier(n)) 160 | ?.getText(node.getSourceFile()) ?? 'anonymous object' 161 | ); 162 | } 163 | 164 | export function getTypeAliasName(node: ts.TypeAliasDeclaration): string { 165 | return ( 166 | node 167 | .getChildren() 168 | .find(n => ts.isIdentifier(n)) 169 | ?.getText(node.getSourceFile()) ?? 'anonymous type' 170 | ); 171 | } 172 | 173 | export function getInterfaceName(node: ts.InterfaceDeclaration): string { 174 | return ( 175 | node 176 | .getChildren() 177 | .find(n => ts.isIdentifier(n)) 178 | ?.getText(node.getSourceFile()) ?? 'anonymous interface' 179 | ); 180 | } 181 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './feature/graph/models'; 2 | export { abstraction } from './feature/graph/abstraction'; 3 | export { filterGraph } from './feature/graph/filterGraph'; 4 | export { mergeGraph } from './feature/graph/utils'; 5 | export { mermaidify } from './feature/mermaid/mermaidify'; 6 | export { resolveTsconfig, type Tsconfig } from './utils/tsc-util'; 7 | export { default as ProjectTraverser } from './feature/util/ProjectTraverser'; 8 | export { GraphAnalyzer } from './feature/graph/GraphAnalyzer'; 9 | 10 | // TODO: metric 関連 11 | -------------------------------------------------------------------------------- /src/reset.d.ts: -------------------------------------------------------------------------------- 1 | import '@total-typescript/ts-reset'; 2 | -------------------------------------------------------------------------------- /src/setting/config/.config.spec.ts: -------------------------------------------------------------------------------- 1 | import { beforeAll, expect, test } from 'vitest'; 2 | import type { TsgConfigScheme } from '.'; 3 | import { getConfig, setupConfig } from '.'; 4 | 5 | beforeAll(() => setupConfig('./src/setting/config/rcSamples/valid.tsgrc.json')); 6 | 7 | test('getConfig', () => { 8 | expect(getConfig()).toEqual({ 9 | reservedMermaidKeywords: [ 10 | ['/graph/', '/_graph_/'], 11 | ['style', 'style_'], 12 | ['graph', 'graph_'], 13 | ['class', 'class_'], 14 | ['end', 'end_'], 15 | ['foo', 'foo_'], 16 | ], 17 | exclude: ['node_modules'], 18 | } satisfies TsgConfigScheme); 19 | }); 20 | -------------------------------------------------------------------------------- /src/setting/config/.readRuntimeConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { expect, test } from 'vitest'; 3 | import { readRuntimeConfig } from '.'; 4 | 5 | test('ファイルが存在しない場合は{}を返す', () => { 6 | expect( 7 | readRuntimeConfig(path.join(process.cwd(), './src/conf/dummy')), 8 | ).toEqual({}); 9 | }); 10 | test('JSONでない場合は{}を返す', () => { 11 | expect( 12 | readRuntimeConfig( 13 | path.join( 14 | process.cwd(), 15 | './src/setting/config/rcSamples/notJson.tsgrc.text', 16 | ), 17 | ), 18 | ).toEqual({}); 19 | }); 20 | test('不正なスキーマの場合は{}を返す', () => { 21 | expect( 22 | readRuntimeConfig( 23 | path.join( 24 | process.cwd(), 25 | './src/setting/config/rcSamples/invalid.tsgrc.json', 26 | ), 27 | ), 28 | ).toEqual({}); 29 | }); 30 | test('正しいスキーマの場合はそれを返す', () => { 31 | expect( 32 | readRuntimeConfig( 33 | path.join( 34 | process.cwd(), 35 | './src/setting/config/rcSamples/valid.tsgrc.json', 36 | ), 37 | ), 38 | ).toEqual({ 39 | reservedMermaidKeywords: [['foo', 'foo_']], 40 | exclude: ['node_modules'], 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/setting/config/.setupConfig.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from 'vitest'; 2 | import { setupConfig, getConfig } from '.'; 3 | 4 | test('appConfigとrcConfigをマージする', () => { 5 | setupConfig('src/setting/config/rcSamples/valid.tsgrc.json'); 6 | expect(getConfig()).toEqual({ 7 | reservedMermaidKeywords: [ 8 | ['/graph/', '/_graph_/'], 9 | ['style', 'style_'], 10 | ['graph', 'graph_'], 11 | ['class', 'class_'], 12 | ['end', 'end_'], 13 | ['foo', 'foo_'], 14 | ], 15 | exclude: ['node_modules'], 16 | }); 17 | }); 18 | test('rcFilePathのファイルが不正な場合は appConfig のみ返る', () => { 19 | setupConfig('src/setting/config/rcSamples/invalid.tsgrc.json'); 20 | expect(getConfig()).toEqual({ 21 | reservedMermaidKeywords: [ 22 | ['/graph/', '/_graph_/'], 23 | ['style', 'style_'], 24 | ['graph', 'graph_'], 25 | ['class', 'class_'], 26 | ['end', 'end_'], 27 | ], 28 | exclude: [], 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/setting/config/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "reservedMermaidKeywords": [ 3 | ["/graph/", "/_graph_/"], 4 | ["style", "style_"], 5 | ["graph", "graph_"], 6 | ["class", "class_"], 7 | ["end", "end_"] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /src/setting/config/index.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync } from 'fs'; 2 | import path from 'path'; 3 | import { z } from 'zod'; 4 | import tsgConfig from './config.json'; 5 | 6 | // TODO: 他の設定も追加する 7 | const tsgConfigScheme = z.object({ 8 | reservedMermaidKeywords: z.array(z.tuple([z.string(), z.string()])), 9 | exclude: z.array(z.string()).optional(), 10 | }); 11 | const tsgRcScheme = tsgConfigScheme.deepPartial(); 12 | 13 | /** typescript-graph 内部で使用するコンフィグ */ 14 | export type TsgConfigScheme = z.infer; 15 | /** typescript-graph ユーザーが指定するランタイムコンフィグ */ 16 | export type TsgRcScheme = z.infer; 17 | 18 | let mergedConfig: TsgConfigScheme | undefined = undefined; 19 | 20 | export function readRuntimeConfig( 21 | filePath: string = path.join(process.cwd(), '.tsgrc.json'), 22 | ): TsgRcScheme { 23 | // ファイルがない場合は空オブジェクトを返す 24 | if (!existsSync(filePath)) return {}; 25 | try { 26 | return tsgRcScheme.parse(JSON.parse(readFileSync(filePath, 'utf-8'))); 27 | } catch (e) { 28 | // ファイルがJSONでない場合はその旨のエラーログを吐き、空オブジェクトを返す 29 | console.error(e); 30 | return {}; 31 | } 32 | } 33 | 34 | function mergeConfig( 35 | config: TsgConfigScheme, 36 | rc: TsgRcScheme, 37 | ): TsgConfigScheme { 38 | return { 39 | ...config, 40 | ...rc, 41 | reservedMermaidKeywords: [ 42 | ...config.reservedMermaidKeywords, 43 | ...(rc.reservedMermaidKeywords ?? []), 44 | ], 45 | exclude: [...(config.exclude ?? []), ...(rc.exclude ?? [])], 46 | }; 47 | } 48 | 49 | export function setupConfig( 50 | rcFilePath: string = path.join(process.cwd(), '.tsgrc.json'), 51 | ) { 52 | mergedConfig = mergeConfig( 53 | tsgConfigScheme.parse(tsgConfig), 54 | readRuntimeConfig(rcFilePath), 55 | ); 56 | } 57 | 58 | /** 通常は current working directory の `.tsgrc.json` を読み込みます。その他のファイルを読み込みたい場合は setupConfig に current working directory からの相対パスを指定してください。 */ 59 | export function getConfig(): TsgConfigScheme { 60 | if (!mergedConfig) setupConfig(); 61 | if (!mergedConfig) throw new Error('getConfig() called before setupConfig()'); 62 | return mergedConfig; 63 | } 64 | -------------------------------------------------------------------------------- /src/setting/config/rcSamples/invalid.tsgrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "greeting": "Hello, World!" 3 | } 4 | -------------------------------------------------------------------------------- /src/setting/config/rcSamples/notJson.tsgrc.text: -------------------------------------------------------------------------------- 1 | hello -------------------------------------------------------------------------------- /src/setting/config/rcSamples/valid.tsgrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "reservedMermaidKeywords": [["foo", "foo_"]], 3 | "exclude": ["node_modules"] 4 | } 5 | -------------------------------------------------------------------------------- /src/setting/model.ts: -------------------------------------------------------------------------------- 1 | export interface OptionValues { 2 | md?: string; 3 | mermaidLink?: boolean; 4 | dir?: string; 5 | tsconfig?: string; 6 | include?: string[]; 7 | exclude?: string[]; 8 | abstraction?: string[]; 9 | highlight?: string[]; 10 | LR?: boolean; 11 | TB?: boolean; 12 | configFile?: string; 13 | measureInstability?: boolean; 14 | vue?: boolean; 15 | metrics?: boolean; 16 | watchMetrics: boolean | string[]; 17 | } 18 | -------------------------------------------------------------------------------- /src/usecase/generateTsg/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { allPass, anyPass, isNot, map, pipe } from 'remeda'; 3 | import { setupConfig } from '../../setting/config'; 4 | import type { Graph } from '../../feature/graph/models'; 5 | import type { OptionValues } from '../../setting/model'; 6 | import { measureInstability } from '../../feature/graph/instability'; 7 | import type { CodeMetrics } from '../../feature/metric/metricsModels'; 8 | import { resolveTsconfig } from '../../utils/tsc-util'; 9 | import ProjectTraverser from '../../feature/util/ProjectTraverser'; 10 | import { GraphAnalyzer } from '../../feature/graph/GraphAnalyzer'; 11 | import { mergeGraph } from '../../feature/graph/utils'; 12 | import { bind_refineGraph } from '../../feature/graph/refineGraph'; 13 | import { calculateCodeMetrics } from '../../feature/metric/calculateCodeMetrics'; 14 | import { setupVueEnvironment } from '../../utils/vue-util'; 15 | import { writeMarkdownFile } from './writeMarkdownFile'; 16 | 17 | /** word に該当するか */ 18 | const bindMatchFunc = (word: string) => (filePath: string) => 19 | filePath.toLowerCase().includes(word.toLowerCase()); 20 | /** word に完全一致するか */ 21 | const bindExactMatchFunc = (word: string) => (filePath: string) => 22 | filePath === word; 23 | /** 抽象的な判定関数 */ 24 | const judge = (filePath: string) => (f: (filePath: string) => boolean) => 25 | f(filePath); 26 | 27 | const isMatchSome = (words: string[]) => (filePath: string) => 28 | words.map(bindMatchFunc).some(judge(filePath)); 29 | const isExactMatchSome = (words: string[]) => (filePath: string) => 30 | words.map(bindExactMatchFunc).some(judge(filePath)); 31 | 32 | export async function generateTsg( 33 | commandOptions: OptionValues & { executedScript: string }, 34 | ) { 35 | setupConfig( 36 | path.join( 37 | path.resolve(commandOptions.dir ?? './'), 38 | commandOptions.configFile ?? '.tsgrc.json', 39 | ), 40 | ); 41 | const refineGraph = bind_refineGraph(commandOptions); 42 | const [tsconfig, renameGraph] = commandOptions.vue 43 | ? setupVueEnvironment(commandOptions) 44 | : [resolveTsconfig(commandOptions)]; 45 | 46 | const isExactMatchSomeIncludes = isExactMatchSome( 47 | commandOptions.include ?? [], 48 | ); 49 | const isMatchSomeIncludes = isMatchSome(commandOptions.include ?? ['']); 50 | const isNotMatchSomeExcludes = isNot( 51 | isMatchSome(commandOptions.exclude ?? []), 52 | ); 53 | 54 | const traverser = new ProjectTraverser(tsconfig); 55 | 56 | const fullGraph = pipe( 57 | traverser.traverse( 58 | anyPass([isExactMatchSomeIncludes, isNotMatchSomeExcludes]), 59 | GraphAnalyzer.create, 60 | ), 61 | map(([analyzer]) => analyzer.generateGraph()), 62 | mergeGraph, 63 | ); 64 | 65 | const graph = renameGraph 66 | ? renameGraph(refineGraph(fullGraph)) 67 | : refineGraph(fullGraph); 68 | const metrics: CodeMetrics[] = calculateCodeMetrics( 69 | commandOptions, 70 | traverser, 71 | allPass([isMatchSomeIncludes, isNotMatchSomeExcludes]), 72 | ); 73 | // coupling を計測するには全てのノードが必要 74 | const couplingData: ReturnType = getCouplingData( 75 | commandOptions, 76 | fullGraph, 77 | ); 78 | 79 | await writeMarkdownFile( 80 | graph, 81 | { 82 | ...commandOptions, 83 | rootDir: tsconfig.options.rootDir, 84 | }, 85 | couplingData, 86 | metrics, 87 | ); 88 | } 89 | 90 | function getCouplingData(commandOptions: OptionValues, fullGraph: Graph) { 91 | let couplingData: ReturnType = []; 92 | if (commandOptions.measureInstability) { 93 | console.time('coupling'); 94 | couplingData = measureInstability(fullGraph); 95 | console.timeEnd('coupling'); 96 | } 97 | return couplingData; 98 | } 99 | -------------------------------------------------------------------------------- /src/usecase/generateTsg/writeMarkdownFile.ts: -------------------------------------------------------------------------------- 1 | import { createWriteStream } from 'fs'; 2 | import type { measureInstability } from '../../feature/graph/instability'; 3 | import { writeCouplingData } from '../../feature/graph/instability'; 4 | import type { Graph } from '../../feature/graph/models'; 5 | import { writeGraph } from '../../feature/mermaid/mermaidify'; 6 | import type { CodeMetrics } from '../../feature/metric/metricsModels'; 7 | import type { OptionValues } from '../../setting/model'; 8 | import { writeMetrics } from './writeMetricsTable'; 9 | 10 | type Options = OptionValues & { 11 | rootDir: string; 12 | executedScript?: string; 13 | }; 14 | 15 | export function writeMarkdownFile( 16 | graph: Graph, 17 | options: Options, 18 | couplingData: ReturnType, 19 | metrics: CodeMetrics[], 20 | ) { 21 | const markdownTitle = options.md ?? 'typescript-graph'; 22 | return new Promise((resolve, reject) => { 23 | const filename = markdownTitle.endsWith('.md') 24 | ? markdownTitle 25 | : `./${markdownTitle}.md`; 26 | const ws = createWriteStream(filename); 27 | ws.on('finish', resolve); 28 | ws.on('error', reject); 29 | 30 | const write = (str: string) => ws.write(str); 31 | writeTitle(write); 32 | writeExecutedScript(write, options.executedScript); 33 | writeGraph(write, graph, options); 34 | writeCouplingData(write, couplingData); 35 | writeMetrics(write, metrics); 36 | ws.end(); 37 | 38 | console.log(filename); 39 | }); 40 | } 41 | 42 | export function writeTitle(write: (str: string) => void) { 43 | write('# TypeScript Graph\n'); 44 | write('\n'); 45 | } 46 | 47 | export function writeExecutedScript( 48 | write: (str: string) => void, 49 | executedScript: string | undefined, 50 | ) { 51 | if (executedScript) { 52 | write('```bash\n'); 53 | write(`${executedScript}\n`); 54 | write('```\n'); 55 | } 56 | write('\n'); 57 | } 58 | -------------------------------------------------------------------------------- /src/usecase/generateTsg/writeMetricsTable.ts: -------------------------------------------------------------------------------- 1 | import { unTree } from '../../utils/Tree'; 2 | import { updateMetricsName } from '../../feature/metric/functions/updateMetricsName'; 3 | import type { CodeMetrics } from '../../feature/metric/metricsModels'; 4 | import { toSortedMetrics } from '../../feature/metric/functions/toSortedMetrics'; 5 | import { getIconByState } from '../../feature/metric/functions/getIconByState'; 6 | 7 | export function writeMetrics( 8 | write: (str: string) => void, 9 | metrics: CodeMetrics[], 10 | ) { 11 | if (metrics.length === 0) return; 12 | const flatten = toSortedMetrics(metrics) 13 | .map(m => updateMetricsName(m)) 14 | .map(unTree) 15 | .flat(); 16 | write('## Code Metrics\n'); 17 | write('\n'); 18 | 19 | writeMetricsTable(write, flatten); 20 | writeMetricsCsv(write, flatten); 21 | writeMetricsTsv(write, flatten); 22 | } 23 | 24 | function writeMetricsTable( 25 | write: (str: string) => void, 26 | flatten: CodeMetrics[], 27 | ) { 28 | write('\n'); 29 | write( 30 | `${flatten[0].scores.map(({ name }) => ``).join('')}\n`, 31 | ); 32 | write(`\n`); 33 | flatten.forEach(m => { 34 | write( 35 | `${m.scores 36 | .map(({ value, state }) => ({ 37 | score: Math.round(value * 100) / 100, 38 | state, 39 | })) 40 | .map(v => ``) 41 | .join('')}\n`, 42 | ); 43 | }); 44 | write('
filescopename${name}
${m.filePath}${m.scope}${m.name}${getIconByState(v.state)} ${v.score}
'); 45 | write('\n'); 46 | } 47 | 48 | function writeMetricsCsv(write: (str: string) => void, flatten: CodeMetrics[]) { 49 | write('
\n'); 50 | write('CSV\n'); 51 | write('\n'); 52 | write('```csv\n'); 53 | write( 54 | `file,scope,name,${flatten[0].scores.map(({ name }) => name).join(',')}\n`, 55 | ); 56 | flatten.forEach(m => { 57 | write( 58 | `${m.filePath},${m.scope},${m.name},${m.scores.map(({ value }) => value).join(',')}\n`, 59 | ); 60 | }); 61 | write('```\n'); 62 | write('\n'); 63 | write('
\n'); 64 | write('\n'); 65 | } 66 | 67 | function writeMetricsTsv(write: (str: string) => void, flatten: CodeMetrics[]) { 68 | write('
\n'); 69 | write('TSV\n'); 70 | write('\n'); 71 | write('```tsv\n'); 72 | write( 73 | `file\tscope\tname\t${flatten[0].scores.map(({ name }) => name).join('\t')}\n`, 74 | ); 75 | flatten.forEach(m => { 76 | write( 77 | `${m.filePath}\t${m.scope}\t${m.name}\t${m.scores.map(({ value }) => value).join('\t')}\n`, 78 | ); 79 | }); 80 | write('```\n'); 81 | write('\n'); 82 | write('
\n'); 83 | write('\n'); 84 | } 85 | -------------------------------------------------------------------------------- /src/usecase/watchMetrics.ts: -------------------------------------------------------------------------------- 1 | import { watch } from 'chokidar'; 2 | import { pipe, piped, tap } from 'remeda'; 3 | import { Table } from 'console-table-printer'; 4 | import chalk from 'chalk'; 5 | import { isTsFile } from '../utils/tsc-util'; 6 | import type { OptionValues } from '../setting/model'; 7 | import type { 8 | CodeMetrics, 9 | Score, 10 | MetricsScope, 11 | } from '../feature/metric/metricsModels'; 12 | import { getMetricsRawData } from '../feature/metric/functions/getMetricsRawData'; 13 | import { convertRawToCodeMetrics } from '../feature/metric/functions/convertRawToCodeMetrics'; 14 | import { unTree } from '../utils/Tree'; 15 | import { updateMetricsName } from '../feature/metric/functions/updateMetricsName'; 16 | import { getIconByState } from '../feature/metric/functions/getIconByState'; 17 | 18 | type ScoreWithDiff = Score & { 19 | diff?: number; 20 | }; 21 | type FlattenMatericsWithDiff = CodeMetrics & { 22 | scores: ScoreWithDiff[]; 23 | status?: 'added' | 'deleted'; 24 | }; 25 | 26 | export function watchMetrics(opt: Pick) { 27 | const target = 28 | typeof opt.watchMetrics === 'boolean' ? ['./'] : opt.watchMetrics; 29 | 30 | // Initialize watcher. 31 | const watcher = watch(target, { 32 | ignored: (path, stats) => 33 | (!!stats?.isFile() && !isTsFile(path)) || path.includes('node_modules'), 34 | persistent: true, 35 | }); 36 | 37 | watcher 38 | .on( 39 | 'add', 40 | piped( 41 | tap(path => console.log('start watching', path)), 42 | saveInitialMetrics, 43 | ), 44 | ) 45 | .on( 46 | 'change', 47 | piped( 48 | tap(path => console.log('change', path)), 49 | consoleMetrics, 50 | ), 51 | ); 52 | } 53 | 54 | const MAINTAINABILITY_COLUMN = 'Maintainability'; 55 | const CYCLOMATIC_COMPLEXITY_COLUMN = 'Cyclomatic'; 56 | const COGNITIVE_COMPLEXITY_COLUMN = 'Cognitive'; 57 | 58 | const COLUMNS = [ 59 | { name: 'name', alignment: 'left' }, 60 | { name: 'scope', alignment: 'left' }, 61 | { name: MAINTAINABILITY_COLUMN, alignment: 'right' }, 62 | { name: CYCLOMATIC_COMPLEXITY_COLUMN, alignment: 'right' }, 63 | { name: COGNITIVE_COMPLEXITY_COLUMN, alignment: 'right' }, 64 | ] as const; 65 | 66 | /** 初回メトリクス登録時も差分取得時にも共通で使用するメトリクスの取得と整形処理 */ 67 | const getMetrics = piped( 68 | getMetricsRawData, 69 | convertRawToCodeMetrics, 70 | updateMetricsName, 71 | unTree, 72 | ); 73 | 74 | function consoleMetrics(path: string) { 75 | try { 76 | const metrics: { 77 | name: string; 78 | scope: MetricsScope; 79 | Maintainability: string; 80 | Cyclomatic: string; 81 | Cognitive: string; 82 | }[] = pipe( 83 | path, 84 | getMetrics, 85 | injectScoreDiffToOneFileData, 86 | convertToWatchData, 87 | ); 88 | const p = new Table({ 89 | columns: [...COLUMNS], 90 | rows: metrics, 91 | }); 92 | p.printTable(); 93 | } catch (e) { 94 | console.error(e); 95 | } 96 | } 97 | 98 | function convertToWatchData(codeMetrics: FlattenMatericsWithDiff[]) { 99 | return codeMetrics.map(m => ({ 100 | name: m.name, 101 | scope: m.scope, 102 | [MAINTAINABILITY_COLUMN]: 103 | getDiffString(m.scores[0]) + 104 | getChalkedValue(m.scores[0], round(m.scores[0].value).toFixed(2)), 105 | [CYCLOMATIC_COMPLEXITY_COLUMN]: 106 | getDiffString(m.scores[1]) + m.scores[1].value, 107 | [COGNITIVE_COMPLEXITY_COLUMN]: 108 | getDiffString(m.scores[2]) + m.scores[2].value, 109 | })); 110 | } 111 | 112 | function round(value: number) { 113 | return Math.round(value * 100) / 100; 114 | } 115 | 116 | function getChalkedValue( 117 | score: FlattenMatericsWithDiff['scores'][number], 118 | displayValue?: string, 119 | ) { 120 | const icon = getIconByState(score.state); 121 | switch (score.state) { 122 | case 'alert': 123 | return `${icon} ${chalk.yellow(displayValue ?? score.value)}`; 124 | case 'critical': 125 | return `${icon} ${chalk.red(displayValue ?? score.value)}`; 126 | default: 127 | return displayValue ?? score.value; 128 | } 129 | } 130 | 131 | function getDiffString(score: ScoreWithDiff): string { 132 | if (!score.diff) return ''; 133 | const diffFromInit = getChalkedDiff(score.betterDirection, score.diff); 134 | return chalk.gray`(${diffFromInit}) `; 135 | } 136 | 137 | function getChalkedDiff( 138 | betterDirection: ScoreWithDiff['betterDirection'], 139 | diff: number | undefined, 140 | ): string | undefined { 141 | if (diff === undefined) return ''; 142 | if (betterDirection === 'lower' && diff < 0) return chalk.green(`${diff}`); 143 | if (betterDirection === 'lower' && 0 < diff) return chalk.red(`+${diff}`); 144 | if (betterDirection === 'higher' && diff < 0) return chalk.red(`${diff}`); 145 | if (betterDirection === 'higher' && 0 < diff) return chalk.green(`+${diff}`); 146 | return '0'; 147 | } 148 | 149 | const initialMetricsMap = new Map(); 150 | function saveInitialMetrics(path: string) { 151 | const metrics = pipe(path, getMetrics); 152 | initialMetricsMap.set(path, metrics); 153 | } 154 | 155 | /** 引数は1ファイル分を想定している */ 156 | function injectScoreDiffToOneFileData( 157 | oneFileData: CodeMetrics[], 158 | ): FlattenMatericsWithDiff[] { 159 | const initialMetrics = initialMetricsMap.get(oneFileData[0].filePath); 160 | if (!initialMetrics) return oneFileData; 161 | const dataNames = new Set( 162 | oneFileData.map(m => m.name).concat(initialMetrics.map(m => m.name)), 163 | ); 164 | 165 | const scoresWithDiff: FlattenMatericsWithDiff[] = []; 166 | for (const name of dataNames) { 167 | const current = oneFileData.find(flatten => flatten.name === name); 168 | const initial = initialMetrics.find(m => m.name === name); 169 | if (current && initial) { 170 | scoresWithDiff.push({ 171 | ...current, 172 | scores: current.scores.map((score, scoreIndex) => { 173 | return { 174 | ...score, 175 | diff: round( 176 | round(score.value) - round(initial.scores[scoreIndex].value), 177 | ), 178 | }; 179 | }), 180 | }); 181 | } else if (current) { 182 | scoresWithDiff.push({ ...current, status: 'added' }); 183 | } 184 | // else if (initial) { 185 | // scoresWithDiff.push({ 186 | // ...initial, 187 | // name: `${initial?.name} (deleted)`, 188 | // status: 'deleted', 189 | // }); 190 | // } 191 | } 192 | 193 | return scoresWithDiff; 194 | } 195 | -------------------------------------------------------------------------------- /src/utils/Tree.ts: -------------------------------------------------------------------------------- 1 | export type Tree = T & { children?: Tree[] }; 2 | export type UnTree = T extends Tree ? U : never; 3 | 4 | export function unTree(t: Tree | Tree[]): T[] { 5 | return Array.isArray(t) 6 | ? t.map(m => unTree(m)).flat() 7 | : [t, ...(t.children?.flatMap(unTree) ?? [])]; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/tsc-util.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import ts from 'typescript'; 3 | import type { OptionValues } from '../setting/model'; 4 | 5 | export function isTsFile(path: string) { 6 | return [ 7 | '.ts', 8 | '.tsx', 9 | '.d.ts', 10 | '.js', 11 | '.jsx', 12 | '.json', 13 | '.tsbuildinfo', 14 | '.mjs', 15 | '.mts', 16 | '.d.mts', 17 | '.cjs', 18 | '.cts', 19 | '.d.cts', 20 | ].some(ext => path.endsWith(ext)); 21 | } 22 | 23 | export type Tsconfig = ts.ParsedCommandLine & { 24 | options: ts.CompilerOptions & { rootDir: string }; 25 | }; 26 | 27 | /** tsconfig を見つけられない場合はエラーを吐く */ 28 | export function resolveTsconfig( 29 | commandOptions: Pick, 30 | system: ts.System = ts.sys, 31 | ): Tsconfig { 32 | const tsConfigPath = commandOptions.tsconfig 33 | ? path.resolve(commandOptions.tsconfig) 34 | : ts.findConfigFile( 35 | path.resolve(commandOptions.dir ?? './'), 36 | ts.sys.fileExists, 37 | ); 38 | if (!tsConfigPath) { 39 | throw new Error('Could not find a valid "tsconfig.json".'); 40 | } 41 | 42 | const { config } = ts.readConfigFile(tsConfigPath, system.readFile); 43 | const splitedConfigPath = tsConfigPath.split('/'); 44 | const rootDir = splitedConfigPath 45 | .slice(0, splitedConfigPath.length - 1) 46 | .join('/'); 47 | const tsconfig = ts.parseJsonConfigFileContent(config, system, rootDir); 48 | tsconfig.options.rootDir = rootDir; 49 | return tsconfig as Tsconfig; 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/vue-util.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import fs from 'fs'; 3 | import { tmpdir } from 'os'; 4 | import ts from 'typescript'; 5 | import { pipe } from 'remeda'; 6 | import type { Graph, Node } from '../feature/graph/models'; 7 | import type { OptionValues } from '../setting/model'; 8 | import { resolveTsconfig } from './tsc-util'; 9 | import type { Tsconfig } from './tsc-util'; 10 | 11 | type RenameGraph = (graph: Graph) => Graph; 12 | 13 | /** tmpdir にプロジェクトをコピーするなどし解析の準備を整え、その環境に合わせた tsconfig を返却する */ 14 | export function setupVueEnvironment( 15 | opt: Pick, 16 | ): [Tsconfig, RenameGraph] { 17 | const { 18 | raw: config, 19 | options: { rootDir }, 20 | } = resolveTsconfig(opt); 21 | 22 | const relativeRootDir = pipe(path.relative(process.cwd(), rootDir), str => 23 | str === '' ? './' : str, 24 | ); 25 | 26 | // vue と TS ファイルのパスを保持する。その際、すでに *.vue.ts ファイルが存在している場合は対象外とする。 27 | const vueAndTsFilePaths = getVueAndTsFilePathsRecursive( 28 | relativeRootDir, 29 | ).filter(path => !fs.existsSync(`${path}.ts`)); 30 | 31 | const tmpDir = fs.mkdtempSync(path.join(tmpdir(), 'tsg-vue-')); 32 | console.log('tmpDir:', tmpDir); 33 | vueAndTsFilePaths 34 | .map(fullPath => path.relative(process.cwd(), fullPath)) 35 | .forEach(relativeFilePath => { 36 | const tmpFilePath = path.join( 37 | tmpDir, 38 | // *.vue のファイルは *.vue.ts としてコピー 39 | relativeFilePath.endsWith('.vue') 40 | ? relativeFilePath + '.ts' 41 | : relativeFilePath, 42 | ); 43 | if (!tmpFilePath.startsWith(tmpDir)) { 44 | // tmpDir 以外へのコピーを抑止する 45 | return; 46 | } 47 | fs.mkdirSync(path.dirname(tmpFilePath), { recursive: true }); 48 | fs.copyFileSync(relativeFilePath, tmpFilePath); 49 | }); 50 | 51 | const tsconfig = ts.parseJsonConfigFileContent( 52 | config, 53 | ts.sys, 54 | path.join(tmpDir, relativeRootDir), 55 | ); 56 | // rootDir を設定しない場合、 tmpDir/rootDir である場合に `rootDir/` が node についてしまう 57 | tsconfig.options.rootDir = path.join(tmpDir, relativeRootDir); 58 | return [tsconfig as Tsconfig, bind_renameGraph(tmpDir)] as const; 59 | } 60 | 61 | function getVueAndTsFilePathsRecursive( 62 | dir: string, 63 | mut_filePaths: string[] = [], 64 | ): string[] { 65 | const files = fs.readdirSync(dir); 66 | files.forEach(file => { 67 | const filePath = path.join(dir, file); 68 | if ( 69 | fs.statSync(filePath).isDirectory() && 70 | !filePath.includes('node_modules') 71 | ) { 72 | // ディレクトリの場合は再帰的に呼び出す 73 | return getVueAndTsFilePathsRecursive(filePath, mut_filePaths); 74 | } 75 | 76 | if ( 77 | // ts.Extension and vue 78 | [ 79 | '.ts', 80 | '.tsx', 81 | '.d.ts', 82 | '.js', 83 | '.jsx', 84 | '.json', 85 | '.tsbuildinfo', 86 | '.mjs', 87 | '.mts', 88 | '.d.mts', 89 | '.cjs', 90 | '.cts', 91 | '.d.cts', 92 | '.vue', 93 | ].some(ext => filePath.endsWith(ext)) 94 | ) { 95 | mut_filePaths.push(filePath); 96 | } 97 | }); 98 | return mut_filePaths; 99 | } 100 | 101 | function bind_renameNode(tmpDir: string): (node: Node) => Node { 102 | return (node: Node) => ({ 103 | ...node, 104 | path: node.path 105 | .replace('.vue.ts', '.vue') 106 | .replace(`${tmpDir.slice(1)}/`, ''), 107 | name: node.name 108 | .replace('.vue.ts', '.vue') 109 | .replace(`${tmpDir.slice(1)}/`, ''), 110 | }); 111 | } 112 | 113 | function bind_renameGraph(tmpDir: string): RenameGraph { 114 | const renameNode = bind_renameNode(tmpDir); 115 | return (graph: Graph) => ({ 116 | nodes: graph.nodes.map(renameNode), 117 | relations: graph.relations.map(relation => { 118 | return { 119 | ...relation, 120 | from: renameNode(relation.from), 121 | to: renameNode(relation.to), 122 | }; 123 | }), 124 | }); 125 | } 126 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.build.json", 3 | "include": ["./src/**/*"], 4 | "exclude": [] 5 | } 6 | --------------------------------------------------------------------------------