├── .eslintrc.js ├── .github └── workflows │ ├── ci.yml │ ├── deploy.yml │ ├── pr-close.yml │ └── release.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.js ├── package.json ├── release.config.js ├── src ├── docs │ ├── editorial_policy.md │ ├── next.config.js │ ├── package.json │ ├── pages │ │ ├── _meta.json │ │ ├── analysis.md │ │ ├── ci_integration.md │ │ ├── configuration_file.md │ │ ├── index.mdx │ │ ├── index.module.css │ │ ├── quick_start.md │ │ └── supported_technologies.md │ ├── public │ │ └── dashboard.png │ ├── theme.config.jsx │ └── yarn.lock ├── lib │ ├── checkEngine.ts │ ├── cli │ │ ├── collectStats.spec.ts │ │ ├── collectStats.ts │ │ ├── index.ts │ │ ├── inplace │ │ │ └── index.ts │ │ ├── processStats.ts │ │ ├── report │ │ │ ├── additional_styles.css │ │ │ ├── async_exec.ts │ │ │ ├── generate_report_template.sh │ │ │ ├── generate_template.ts │ │ │ ├── index.ts │ │ │ └── prepare_report.ts │ │ ├── resolveStatsConfig.ts │ │ ├── sharedTypes.ts │ │ ├── test │ │ │ ├── grafana_samples.ts │ │ │ └── react_todoapp.js │ │ ├── timelines │ │ │ ├── concurrentQueue.spec.ts │ │ │ ├── concurrentQueue.ts │ │ │ ├── dates.ts │ │ │ ├── getTimelineForOneRepo.ts │ │ │ ├── git.spec.ts │ │ │ ├── git.ts │ │ │ ├── index.ts │ │ │ ├── statsWorker.ts │ │ │ ├── workerPool.ts │ │ │ └── workerTypes.ts │ │ └── util │ │ │ ├── cache.ts │ │ │ ├── cacheVersion.ts │ │ │ ├── defineYargsModule.ts │ │ │ ├── gzip.ts │ │ │ └── stats.ts │ ├── detectHomebrew │ │ ├── detectHomebrew.spec.ts │ │ └── detectHomebrew.ts │ ├── findUsages │ │ ├── findUsages.spec.ts │ │ └── findUsages.ts │ ├── guards.ts │ ├── index.ts │ ├── packageInfo.ts │ ├── resolveDependencies │ │ ├── identifyExports.spec.ts │ │ ├── identifyExports.ts │ │ ├── identifyImports.spec.ts │ │ ├── identifyImports.ts │ │ ├── resolveDependencies.spec.ts │ │ └── resolveDependencies.ts │ ├── resolveModule │ │ ├── resolveModule.spec.ts │ │ └── resolveModule.ts │ └── supportedFileTypes.ts └── tasks │ ├── execute_from_local_registry.sh │ ├── lib.ts │ ├── publish_to_local_registry.sh │ ├── tasks.ts │ └── verdaccio.yml ├── tsconfig-base.json ├── tsconfig-lib-cjs.json ├── tsconfig-lib-esm.json ├── tsconfig-lib-types.json ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { node: true }, 4 | parser: "@typescript-eslint/parser", 5 | plugins: [ 6 | "@typescript-eslint", 7 | ], 8 | extends: [ 9 | "eslint:recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | ], 12 | ignorePatterns: ["src/lambda/build/*"], 13 | rules: { 14 | // Usages of `{}` and `object` look fine in this repo 15 | "@typescript-eslint/ban-types": ["error", { 16 | "extendDefaults": true, 17 | "types": { 18 | "{}": false, 19 | "object": false, 20 | }, 21 | }], 22 | 23 | // Already handled by typescript 24 | "@typescript-eslint/no-unused-vars": "off", 25 | 26 | // I don't like it 27 | "@typescript-eslint/explicit-module-boundary-types": "off", 28 | 29 | // Interface/type delimiter styles, prefer comma 30 | "@typescript-eslint/member-delimiter-style": ["warn", { 31 | "multiline": { 32 | "delimiter": "comma", 33 | "requireLast": true, 34 | }, 35 | "singleline": { 36 | "delimiter": "comma", 37 | "requireLast": false, 38 | }, 39 | "multilineDetection": "brackets", 40 | }], 41 | 42 | // Semicolon 43 | "semi": "off", 44 | "@typescript-eslint/semi": ["error"], 45 | 46 | // Trailing comma 47 | "comma-dangle": "off", 48 | "@typescript-eslint/comma-dangle": ["error", { 49 | "arrays": "always-multiline", 50 | "objects": "always-multiline", 51 | "imports": "always-multiline", 52 | "exports": "always-multiline", 53 | "functions": "always-multiline", 54 | "enums": "always-multiline", 55 | "generics": "always-multiline", 56 | "tuples": "always-multiline", 57 | }], 58 | 59 | // Quotes 60 | "quotes": "off", 61 | "@typescript-eslint/quotes": ["error"], 62 | 63 | // Spacing around type annotations 64 | "@typescript-eslint/type-annotation-spacing": ["error"], 65 | 66 | // Curly spacing 67 | "object-curly-spacing": "off", 68 | "@typescript-eslint/object-curly-spacing": ["error", "always"], 69 | 70 | // Shadowing requires paying close attention during debug 71 | "no-shadow": "off", 72 | "@typescript-eslint/no-shadow": ["error"], 73 | 74 | // No extra parens 75 | "arrow-parens": ["error", "as-needed"], 76 | "no-extra-parens": "off", 77 | "@typescript-eslint/no-extra-parens": ["error", "all", { 78 | "conditionalAssign": true, 79 | "returnAssign": true, 80 | "nestedBinaryExpressions": false, 81 | "enforceForArrowConditionals": true, 82 | "enforceForSequenceExpressions": true, 83 | "enforceForNewInMemberExpressions": true, 84 | "enforceForFunctionPrototypeMethods": true, 85 | }], 86 | 87 | // Spacing in template curlies 88 | "template-curly-spacing": ["error", "always"], 89 | 90 | // Indent 91 | "indent": ["error", 4, { "SwitchCase": 1 , "flatTernaryExpressions": false }], 92 | }, 93 | }; 94 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean installation of node dependencies, cache/restore them, run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: CI 5 | 6 | on: 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | matrix: 15 | node-version: [18.x, 20.x, 22.x, lts/*, latest] 16 | os: [ubuntu-latest, windows-latest] 17 | 18 | runs-on: ${{ matrix.os }} 19 | steps: 20 | - name: Checkout 🛎️ 21 | uses: actions/checkout@v3.5.3 22 | - name: Use Node.js ${{ matrix.node-version }} 🚧 23 | uses: actions/setup-node@v3.7.0 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | cache: ${{ !env.ACT && 'yarn' || '' }} 27 | - name: Install Yarn 🚧 28 | if: ${{ env.ACT }} 29 | run: npm install -g yarn 30 | - name: Run Tests and Build 🦺 31 | # Report template generator does not work on Windows, but also we run it on Ubuntu during release. 32 | run: yarn build ${{ matrix.os == 'windows-latest' && '--no-launchFromLocalRegistry --no-generateReportTemplate' || '' }} 33 | - name: Parse a timeline from a React TodoApp 34 | # Uses a build of the tracker to run an e2e test 35 | run: npx ./build timelines ./src/lib/cli/test/react_todoapp.js 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build docs and deploy it to https://rangle.github.io/radius-tracker/ 2 | # For more information see: https://github.com/marketplace/actions/deploy-to-github-pages 3 | 4 | name: Build and Deploy 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: 11 | 12 | jobs: 13 | build-and-deploy: 14 | runs-on: ubuntu-latest 15 | env: 16 | PR_NUM: ${{ github.event.pull_request.number }} 17 | steps: 18 | - name: Checkout 🛎️ 19 | uses: actions/checkout@v2 20 | - name: Install Yarn 🚧 21 | if: ${{ env.ACT }} 22 | run: npm install -g yarn 23 | - name: Install Rsync 📚 24 | if: ${{ env.ACT }} 25 | run: apt-get update && apt-get install -y rsync 26 | - name: Comment on PR 💬 27 | if: github.ref != 'refs/heads/main' 28 | uses: hasura/comment-progress@v2.2.0 29 | with: 30 | github-token: ${{ secrets.GITHUB_TOKEN }} 31 | repository: ${{ github.repository }} 32 | number: ${{ github.event.number }} 33 | id: deploy-preview 34 | message: "Initializing deploy of documentation site preview... ⏳" 35 | - name: Set BASE_URL for Preview Build 🔨 36 | if: github.ref != 'refs/heads/main' 37 | run: echo BASE_URL="https://rangle.github.io/radius-tracker/pull/${{ env.PR_NUM }}" >> $GITHUB_ENV 38 | - name: Cache build artifacts 💾 39 | if: github.ref == 'refs/heads/main' 40 | uses: actions/cache@v3 41 | id: cache 42 | with: 43 | key: ${{ runner.os }}-${{ hashFiles('src/docs/pages', 'src/docs/next.config.js', 'src/docs/theme.config.jsx') }} 44 | path: src/docs/out 45 | - name: Build Docs Site 📖 46 | if: steps.cache.outputs.cache-hit != 'true' 47 | run: yarn docs-build 48 | - name: Deploy (main branch) 🚀 49 | if: github.ref == 'refs/heads/main' 50 | uses: JamesIves/github-pages-deploy-action@v4.2.5 51 | with: 52 | clean-exclude: pull 53 | branch: gh-pages 54 | folder: src/docs/out 55 | token: ${{secrets.GITHUB_TOKEN}} 56 | - name: Deploy (PR Preview) 🔎 57 | if: github.ref != 'refs/heads/main' 58 | uses: JamesIves/github-pages-deploy-action@v4.2.5 59 | with: 60 | branch: gh-pages 61 | folder: src/docs/out 62 | target-folder: pull/${{ env.PR_NUM }} 63 | token: ${{ secrets.GITHUB_TOKEN }} 64 | clean: false 65 | - name: Update PR comment 💬 66 | if: github.ref != 'refs/heads/main' 67 | uses: hasura/comment-progress@v2.2.0 68 | with: 69 | github-token: ${{ secrets.GITHUB_TOKEN }} 70 | repository: ${{ github.repository }} 71 | number: ${{ github.event.number }} 72 | id: deploy-preview 73 | message: "🚀 Deploy of docs site preview has been started. It will take a minute or two for changes to appear on the site. See changes here: ${{ env.BASE_URL }}" 74 | -------------------------------------------------------------------------------- /.github/workflows/pr-close.yml: -------------------------------------------------------------------------------- 1 | # Removes specific build underneath the pull/ directory in gh-pages branch when the relevant PR closes. 2 | name: Clean-up PR Preview 3 | 4 | on: 5 | pull_request: 6 | types: [closed] 7 | 8 | jobs: 9 | clean-pr-preview: 10 | runs-on: ubuntu-latest 11 | env: 12 | PR_NUM: ${{ github.event.pull_request.number }} 13 | steps: 14 | - name: Checkout 🚧 15 | uses: actions/checkout@v2 16 | - name: Create Empty Directory 🗑️ 17 | run: mkdir ${{ env.PR_NUM }} 18 | - name: Delete Directory 🪓 19 | uses: peaceiris/actions-gh-pages@v3 20 | with: 21 | github_token: ${{ secrets.GITHUB_TOKEN }} 22 | publish_dir: ${{ env.PR_NUM }} 23 | destination_dir: pull/${{ env.PR_NUM }} 24 | - name: Update comment on PR 💬 25 | uses: hasura/comment-progress@v2.2.0 26 | with: 27 | github-token: ${{ secrets.GITHUB_TOKEN }} 28 | repository: ${{ github.repository }} 29 | number: ${{ github.event.number }} 30 | id: deploy-preview 31 | message: "🫰🏼 PR preview build has been deleted" 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # This workflow release a new semantic version of npm package 2 | # For more information see: https://github.com/semantic-release/semantic-release/blob/2b94bb4e0967c705ab92deace342f9fecb02909d/docs/recipes/ci-configurations/github-actions.md 3 | 4 | name: Release 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout 🛎️ 17 | uses: actions/checkout@v2 18 | - uses: actions/setup-node@v4 19 | with: 20 | node-version: "lts/*" 21 | - name: Install Yarn 🚧 22 | if: ${{ env.ACT }} 23 | run: npm install -g yarn 24 | - name: Build Package 🛠 25 | run: yarn build 26 | - name: Release 🚀 27 | run: npx semantic-release@"^24.0.0" --debug 28 | env: 29 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # Build 5 | /build 6 | 7 | # Documentation stie related artifacts 8 | /src/docs/node_modules 9 | /src/docs/.next 10 | /src/docs/out 11 | 12 | # Misc 13 | npm-debug.log* 14 | yarn-error.log* 15 | 16 | # Webstorm config 17 | /.idea 18 | 19 | # TS incremental build data 20 | /.tsbuildinfo 21 | 22 | # Prettier config 23 | /.prettierrc 24 | 25 | # CLI cache 26 | /radius-tracker-cache 27 | 28 | # CLI output 29 | /usages.sqlite 30 | /usages.sqlite.gz 31 | /radius-tracker-report 32 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict = true 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Arseny Smoogly 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Radius Tracker 2 | 3 | > Track every use 4 | > of every component 5 | > in every codebase 6 | > in your organization. 7 | 8 | Radius Tracker generates reports measuring design system adoption, 9 | calculated bottom-up from individual component usage stats automatically collected from your organization repositories. 10 | 11 | 12 | ## Quickstart 13 | 14 | Get the latest stats from a project on your filesystem: 15 | ```sh 16 | npx radius-tracker in-place --targetRe "^@corporation/your-design-system" 17 | ``` 18 | 19 | 20 | ## [Documentation](https://rangle.github.io/radius-tracker/quick_start) 21 | 22 | See the documentation on guidance for [running Tracker,](https://rangle.github.io/radius-tracker/quick_start) 23 | [analyzing the result,](https://rangle.github.io/radius-tracker/analysis) 24 | [configuring Tracker](https://rangle.github.io/radius-tracker/configuration_file) to process 25 | the entire organizational ecosystem, and [integrating into CI](https://rangle.github.io/radius-tracker/ci_integration) 26 | 27 | 28 | ## [Sample report](https://observablehq.com/@smoogly/design-system-metrics) 29 | [Sample Radius Tracker dashboard containing design system metrics covering multiple component sources, projects, and components](https://observablehq.com/@smoogly/design-system-metrics) 30 | 31 | 32 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | modulePathIgnorePatterns: [ "/build"], 3 | }; 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "radius-tracker", 3 | "version": "0.0.8", 4 | "description": "Find usages of React components in the codebase", 5 | "bin": "./cjs/cli/index.js", 6 | "main": "./cjs/index.js", 7 | "module": "./esm/index.js", 8 | "types": "./types/index.d.ts", 9 | "exports": { 10 | ".": { 11 | "import": "./esm/index.js", 12 | "require": "./cjs/index.js", 13 | "types": "./types/index.d.ts" 14 | } 15 | }, 16 | "scripts": { 17 | "test": "yarn task test", 18 | "build": "yarn task build", 19 | "task": "yarn prep && node -r ts-node/register/transpile-only ./src/tasks/tasks.ts", 20 | "cli": "yarn prep && node -r ts-node/register/transpile-only ./src/lib/cli/index.ts", 21 | "lint": "yarn task lint", 22 | "prep": "yarn install --no-audit --frozen-lockfile && node -r ts-node/register/transpile-only src/lib/checkEngine.ts --check-yarn", 23 | "docs-build": "yarn --cwd ./src/docs && yarn --cwd ./src/docs build && touch ./src/docs/out/.nojekyll", 24 | "docs-dev": "yarn --cwd ./src/docs && yarn --cwd ./src/docs dev" 25 | }, 26 | "keywords": [], 27 | "author": { 28 | "name": "Arseny Smoogly", 29 | "email": "arseny@smoogly.ru", 30 | "url": "http://smoogly.ru" 31 | }, 32 | "contributors": [ 33 | { 34 | "name": "Pavel Ivanov", 35 | "email": "pablospaniard@gmail.com", 36 | "url": "https://pablospaniard.dev" 37 | }, 38 | { 39 | "name": "Raven Avalon" 40 | } 41 | ], 42 | "license": "MIT", 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/rangle/radius-tracker.git" 46 | }, 47 | "homepage": "https://github.com/rangle/radius-tracker#readme", 48 | "dependencies": { 49 | "node-worker-threads-pool": "^1.5.1", 50 | "semver": "^7.3.5", 51 | "sql.js": "^1.8.0", 52 | "ts-morph": "17.0.1", 53 | "tslib": "^2.4.0", 54 | "typescript": "^4.5.4", 55 | "yargs": "^17.3.1" 56 | }, 57 | "devDependencies": { 58 | "@babel/core": "^7.16.10", 59 | "@babel/preset-env": "^7.16.11", 60 | "@babel/preset-typescript": "^7.16.7", 61 | "@types/jest": "^27.4.0", 62 | "@types/node": "^17.0.10", 63 | "@types/semver": "^7.3.9", 64 | "@types/sql.js": "^1.4.3", 65 | "@types/yargs": "^17.0.8", 66 | "@typescript-eslint/eslint-plugin": "^5.11.0", 67 | "@typescript-eslint/parser": "^5.11.0", 68 | "eslint": "^8.9.0", 69 | "jest": "^27.4.7", 70 | "tasklauncher": "0.0.10", 71 | "ts-node": "^10.5.0", 72 | "ts-toolbelt": "^9.6.0", 73 | "verdaccio": "^5.26.1", 74 | "verdaccio-memory": "^10.3.2" 75 | }, 76 | "engines": { 77 | "node": ">=14.17.0", 78 | "yarn": ">= 1.22.10 && < 2.x" 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | branches: ["main"], 3 | plugins: [ 4 | ["@semantic-release/commit-analyzer", { 5 | "releaseRules": [ 6 | { "release": "patch" }, 7 | ], 8 | }], 9 | "@semantic-release/release-notes-generator", 10 | ["@semantic-release/npm", { 11 | "pkgRoot": "build", 12 | }], 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /src/docs/editorial_policy.md: -------------------------------------------------------------------------------- 1 | # Editorial policy 2 | 3 | 4 | ## Language variant 5 | 6 | US English variant is used for the documentation. Even though Rangle is of Canadian origin, author is not. 7 | Using American English seems like an acceptable common ground. When in doubt, check Grammarly set to American English. 8 | 9 | 10 | ## Project name 11 | 12 | Within the documentation, the project is referred to as "Tracker" — capitalized, and omitting the article, 13 | except for the main title, and SEO metadata where it is referred to as "Radius Tracker". 14 | Articles should be used as appropriate when referring to Tracker-related things, e.g., "See the Tracker report". 15 | 16 | 17 | ## Spelling common terms 18 | 19 | Terms are not capitalized unless used as a first word in a sentence, and never capitalized mid-word. 20 | 21 | * Regular expression is shortened as "regexp". 22 | * Git is only capitalized if used as a first word in a sentence. Hub in Github is never capitalized. 23 | * Design system is not capitalized. 24 | 25 | 26 | ## Punctuation 27 | 28 | * Punctuation following a link must be included in the link. 29 | -------------------------------------------------------------------------------- /src/docs/next.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const nextra = require("nextra"); 3 | 4 | const isGithubActions = process.env.GITHUB_ACTIONS || false; 5 | const pullRequestNumber = process.env.PR_NUM || false; 6 | 7 | let assetPrefix = "/"; 8 | let basePath = ""; 9 | 10 | if (isGithubActions) { 11 | // trim off / from repository name 12 | const repo = process.env.GITHUB_REPOSITORY.replace(/.*?\//, ""); 13 | 14 | assetPrefix = `/${ repo }/`; 15 | basePath = `/${ repo }`; 16 | 17 | if (pullRequestNumber) { 18 | assetPrefix += `pull/${ pullRequestNumber }/`; 19 | basePath += `/pull/${ pullRequestNumber }`; 20 | } 21 | } 22 | 23 | const withNextra = nextra({ 24 | theme: "nextra-theme-docs", 25 | themeConfig: "./theme.config.jsx", 26 | staticImage: true, 27 | }); 28 | 29 | const notSupportedForStaticExport = undefined; 30 | module.exports = { 31 | ...withNextra({ 32 | assetPrefix, 33 | basePath, 34 | images: { 35 | unoptimized: true, 36 | }, 37 | }), 38 | 39 | env: { 40 | assetPrefix, // Expose asset prefix to manually refer to content in `public` directory 41 | }, 42 | 43 | // Rewrites, redirects & headers are serverside features, 44 | // and not supported in static exports. 45 | // See: https://nextjs.org/docs/messages/export-no-custom-routes 46 | rewrites: notSupportedForStaticExport, 47 | redirects: notSupportedForStaticExport, 48 | headers: notSupportedForStaticExport, 49 | }; 50 | -------------------------------------------------------------------------------- /src/docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": ">=16.0.0" 8 | }, 9 | "scripts": { 10 | "dev": "next", 11 | "build": "next build && next export" 12 | }, 13 | "keywords": [], 14 | "author": "", 15 | "license": "ISC", 16 | "dependencies": { 17 | "next": "^13.1.5", 18 | "nextra": "^2.2.14", 19 | "nextra-theme-docs": "^2.2.14", 20 | "react": "^18.2.0", 21 | "react-dom": "^18.2.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/docs/pages/_meta.json: -------------------------------------------------------------------------------- 1 | { 2 | "index": { 3 | "title": "Radius Tracker", 4 | "theme": { 5 | "breadcrumb": false 6 | } 7 | }, 8 | "quick_start": { 9 | "title": "Quick start", 10 | "theme": { 11 | "breadcrumb": false 12 | } 13 | }, 14 | "analysis": { 15 | "title": "Analysing the output", 16 | "theme": { 17 | "breadcrumb": false 18 | } 19 | }, 20 | "supported_technologies": { 21 | "title": "Supported technologies", 22 | "theme": { 23 | "breadcrumb": false 24 | } 25 | }, 26 | "configuration_file": { 27 | "title": "Configuration file", 28 | "theme": { 29 | "breadcrumb": false 30 | } 31 | }, 32 | "ci_integration": { 33 | "title": "CI Integration", 34 | "theme": { 35 | "breadcrumb": false 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/docs/pages/analysis.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Analyzing the output 3 | description: Analyze the adoption of your design system with the built-in Radius Tracker report or external tools. 4 | Learn to generate a Tracker report from your data. 5 | --- 6 | 7 | # Analyzing the output 8 | 9 | Tracker generates an SQLite database with entries for each detected component usage and, 10 | by default, writes it to `usages.sqlite.gz` in the current directory. You have a few ways to visualize this data. 11 | 12 | 13 | ## Local report 14 | 15 | The easiest way to visualize the Tracker data is by generating a report using 16 | ```sh 17 | npx radius-tracker report 18 | ``` 19 | 20 | To view, host the report files on an http server, 21 | for example, using `npx serve ./radius-tracker-report` on a local machine. 22 | 23 | This report is entirely self-contained without external references. 24 | See the [CI integration guide](./ci_integration) for archiving. 25 | 26 | 27 | ## ObservableHQ 28 | 29 | We are using the [sample ObservableHQ report](https://observablehq.com/@smoogly/design-system-metrics) 30 | as a template for the local reports. To make changes in that report: 31 | 1. Fork the [sample report](https://observablehq.com/@smoogly/design-system-metrics) 32 | 2. Replace the attached database with the Tracker database you generated 33 | 34 | 35 | ## Custom templates for local reports 36 | 37 | You can use a fork of an ObservableHQ report as a template for a local report generator. 38 | See above for forking the default report. Get an export link from `Export → Download code` of a report you want to use. 39 | See [ObservableHQ export documentation](https://observablehq.com/@observablehq/advanced-embeds#cell-291) for details. 40 | 41 | Paste the link into the following command to generate a report template: 42 | ```sh 43 | npx radius-tracker report-generate-template https://your-export-url 44 | ``` 45 | 46 | You can then generate the report using your template: 47 | ```sh 48 | npx radius-tracker report --template=./path/to/template 49 | ``` 50 | 51 | While this is the same mechanism we use to generate the bundled report template, this is an experimental feature. 52 | Report templates are supposed to be self-contained, and the generator is tightly coupled with the default report 53 | to support that. The API of the report generator is unstable, and there's no guarantee that it will work for you. 54 | 55 | 56 | ## Alternative reporting tools 57 | 58 | If you want to run an analysis not covered by the default Tracker report, you can connect the usages database 59 | to various data analysis tools. 60 | 61 | Both Tableau and Power BI support SQLite as a data source using an [SQLite ODBC Driver.](http://www.ch-werner.de/sqliteodbc/) 62 | Take a look at the [documentation for Tableau](https://help.tableau.com/current/pro/desktop/en-us/odbc_customize.htm) 63 | and for [Power BI.](https://learn.microsoft.com/en-us/power-query/connect-using-generic-interfaces#data-sources-accessible-through-odbc) 64 | 65 | Keep track of the [`schemaVersion`](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/cli/processStats.ts#L25) 66 | in the `meta` table — your reports might need to be updated if the schema changes between Tracker version upgrades. 67 | -------------------------------------------------------------------------------- /src/docs/pages/ci_integration.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: CI Integration 3 | description: Integrate Radius Tracker into your team workflows by running it on schedule in CI. 4 | Learn best practices around caching, storing artifacts, and securing the codebase during processing. 5 | --- 6 | 7 | # CI Integration 8 | 9 | We designed Tracker to run on schedule in CI so that the team can routinely review the design system adoption progress. 10 | For example, in Github Actions you can use [the schedule trigger.](https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#schedule) 11 | 12 | Tracker takes a snapshot of the latest state of the codebase and weekly snapshots of each project's history. 13 | Weekly snapshots are aligned with the latest commit as of [midnight on Saturday every week.](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/cli/timelines/getTimelineForOneRepo.ts#L75) 14 | 15 | We recommend scheduling Tracker to run a day or two before the rituals where the team reviews the progress. 16 | 17 | 18 | ## Artifacts 19 | 20 | [Tracker outputs](./analysis) are self-contained and can be archived. 21 | We suggest generating and storing a report for reference using [Upload Artifact](https://github.com/actions/upload-artifact) 22 | Github Action or a similar step in your CI. 23 | 24 | 25 | ## Cache 26 | 27 | Running static code analysis of an entire organizational ecosystem history takes considerable time. 28 | 29 | Tracker writes intermediary results per project per commit into a specified `--cacheDir`. 30 | By default, the cache is written to `radius-tracker-cache/cache` 31 | 32 | Saving and restoring the cache between tracker runs will save significant CPU time 33 | by avoiding the re-processing of historical commits. 34 | 35 | Cache content is versioned with a constant from [`src/lib/cli/util/cacheVersion.ts`](https://github.com/rangle/radius-tracker/blob/c7651f30864b50584587ebd1c75907e11d413a2a/src/lib/cli/util/cacheVersion.ts) 36 | — you can use a hash of that file as the cache key. For example, 37 | in Github Actions you can use `hashFiles('**/cacheVersion.ts')` 38 | 39 | 40 | ## Resource consumption 41 | 42 | Static code analysis is resource-heavy. Tracker can take hours to run, especially without cache, 43 | when processing a project for the first time. Please provide sufficient CPU and Memory, 44 | and ensure that your CI runner doesn't kill the Tracker task too early. 45 | 46 | CPU time is a primary limiting factor for Tracker. It runs multiple child processes, 47 | each analyzing a single commit, to better utilize available CPUs. 48 | 49 | Tracker allocates a minimum of 2GB of Memory per child process, so the number of child processes might be limited 50 | on machines with low total memory. 51 | 52 | On top of potentially significant amounts of cache, Tracker fetches all the projects specified in the config file 53 | and creates a copy per thread. Make sure there is enough disk space for Tracker to run. 54 | 55 | 56 | ## Restricting network access 57 | 58 | Tracker requires no network access to run beyond fetching the git repos. 59 | Consider fetching the project repos to the local filesystem and clamping down the firewall 60 | before running or even installing Tracker to eliminate the potential for leaking the analyzed codebase. 61 | 62 | Alternatively, you can limit network access to only allow outgoing connections to the git hosting. 63 | 64 | 65 | ## Automated project discovery 66 | 67 | Depending on the git hosting platform, you might be able to automatically discover 68 | new UI projects in the organization ecosystem. 69 | 70 | For example, you can use [Github Search API](https://docs.github.com/en/rest/search?apiVersion=2022-11-28#search-code) 71 | to search for `package.json` files containing a reference to your design system or frontend frameworks: 72 | ``` 73 | org:+in:file+filename:package.json+language:json+ 74 | ``` 75 | 76 | You can then programmatically generate [the config file](./configuration_file) using the list of discovered projects. 77 | -------------------------------------------------------------------------------- /src/docs/pages/configuration_file.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Tracker configuration 3 | description: Configuration file for Radius Tracker describes where to find and how to analyze each project 4 | in your organization's ecosystem. Learn to improve tracking performance & remove junk from the output. 5 | --- 6 | 7 | # Tracker configuration 8 | 9 | We designed Tracker to collect historical usage stats from the entire ecosystem of UI projects within the organization. 10 | It requires describing the project ecosystem and setting up Tracker to run on schedule 11 | to update the data and generate a new report. 12 | 13 | You can collect the data by running 14 | ```sh 15 | npx radius-tracker timelines ./path/to/config.js 16 | ``` 17 | 18 | 19 | ## Config file structure 20 | 21 | Tracker config is a .js file with an array of entries for each repo you want to process 22 | ```js 23 | export default [ 24 | { 25 | // Git clone URL. 26 | // This URL can use any protocol git supports, including SSH and local files. 27 | // https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols#_the_protocols 28 | repoUrl: "https://githost.com/company/product", 29 | 30 | // [optional] Repository name to use in reports. Defaults to `repoUrl`. 31 | displayName: "My repo", 32 | 33 | // Regexp testing if an import path comes from the design system. 34 | // See the document below for multi-target configuration, 35 | // and handling file targets. 36 | isTargetModuleOrPath: /^@company\/design-system/, 37 | 38 | // How far into history should Tracker look — a date in the past. 39 | // Conflicts with `maxWeeks` 40 | since: new Date("2021-03-12"), 41 | 42 | // Alternative way to specify how far into history Tracker would look. 43 | // Conflicts with `since` — prefer using `since` instead to set an explicit date. 44 | maxWeeks: 52, 45 | 46 | // [optional] Subproject path denotes where in the monorepo the project code is located 47 | // relative to the repository root. 48 | // Defaults to "/" 49 | subprojectPath: "/", 50 | 51 | // [optional] Regexp testing if an import path comes from a library 52 | // that wraps built-in JSX elements, e.g. `styled.div`. 53 | // This is used for detecting custom components implemented 54 | // using such libraries. 55 | domReferenceFactories: { 56 | "styled-components": /styled-components/, 57 | }, 58 | 59 | // [optional] Regexp specifying which file paths to exclude. 60 | // See the document below for the default value & details. 61 | isIgnoredFile: /\/node_modules\//, 62 | 63 | // [optional] Function narrowing down if the import matched by `isTargetModuleOrPath` 64 | // should be considered for analysis. See the document below for the default value & details. 65 | isTargetImport: imp => imp.type !== "cjs-import", // This example excludes `require` calls 66 | 67 | // [optional] Function checking if Tracker should include a particular usage found in code 68 | // in the analysis. See the document below for details. 69 | // Defaults to `() => true` 70 | isValidUsage: () => true, 71 | 72 | // [optional] String path specifying where tsconfig.json is located relative to `subprojectPath` 73 | // TSConfig helps Tracker resolve which files to include and how to navigate 74 | // the dependencies. Make sure this points to the correct file if it exists. 75 | // Defaults to "tsconfig.json" 76 | // Conflicts with `jsconfigPath` 77 | tsconfigPath: "tsconfig.json", 78 | 79 | // [optional] String path specifying where jsconfig.json is located relative to `subprojectPath` 80 | // Some projects use a JSConfig to specify data about the project: https://code.visualstudio.com/docs/languages/jsconfig 81 | // Tracker can use it to adjust its behavior, similar to `tsconfigPath` above. 82 | // Defaults to "jsconfig.json" 83 | // Conflicts with `tsconfigPath` 84 | jsconfigPath: "jsconfig.json" 85 | }, 86 | /* ...repeat for other projects */ 87 | ]; 88 | ``` 89 | 90 | For reference, you can find the configuration used to generate [the Tracker sample report](https://observablehq.com/@smoogly/design-system-metrics) 91 | under [`/src/lib/cli/test/grafana_samples.ts`](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/cli/test/grafana_samples.ts#L23) 92 | 93 | The full definition is [`WorkerConfig`](https://github.com/rangle/radius-tracker/blob/fe510f3de53f519816fcdf83d93b987f3045e947/src/lib/cli/timelines/workerTypes.ts#L5-L8) 94 | extending [`StatsConfig.`](https://github.com/rangle/radius-tracker/blob/fe510f3de53f519816fcdf83d93b987f3045e947/src/lib/cli/sharedTypes.ts#L5-L17) 95 | 96 | 97 | ## Multi-target configuration 98 | 99 | Besides your design system, UI projects in your organization ecosystem might use other component sources. 100 | To specify multiple sources, provide a set of regexps in `isTargetModuleOrPath`: 101 | ```js 102 | const targets = { 103 | ds: /^@company\/design-system/, 104 | material: /^(@mui|@material-ui)/, 105 | ant: /^(antd|@ant-design)/, 106 | }; 107 | 108 | export default [{ 109 | isTargetModuleOrPath: targets, 110 | // ...rest of the configuration 111 | }] 112 | ``` 113 | 114 | Tracker collects usages separately for each target in the set. That way, each usage 115 | is attributable to the particular source during the analysis. 116 | 117 | 118 | ## Handling file targets 119 | 120 | Some projects store the component library within the repo. 121 | 122 | Tracker supports file paths as targets, e.g., `isTargetModuleOrPath: /src\/components/` 123 | In this case, Tracker will ignore any usage found within `src/components` under the assumption 124 | that it forms a part of the component library implementation. 125 | 126 | 127 | ## Ignored files 128 | 129 | `isIgnoredFile` regexp filters out files that Tracker should not be analyze for component usage. 130 | File paths are given relative to the `subprojectPath` within the project. 131 | 132 | By default, Tracker ignores 133 | * files in the `node_modules` directory 134 | * `__mocks__` folders 135 | * `.spec.` and `.test.` along with `/test/` and `/spec/` directories to filter out common test files 136 | * `.story.` and `.stories.` along with `/story/` and `/stories/` directories to filter out common storybook locations 137 | * `.d.ts` files with typescript definitions. 138 | 139 | Ignoring the files improves Tracker performance by avoiding unnecessary work. 140 | It also enhances the output quality by removing non-production usages of components in tests and stories. 141 | 142 | [See the implementation](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/cli/resolveStatsConfig.ts#L48) for the default regexp. 143 | 144 | If you specify `isIgnoredFile` regexp, we advise you to filter out `node_modules` — 145 | parsing the dependencies takes forever if present. 146 | 147 | 148 | ## Ignored imports 149 | 150 | Similar to ignored files, `isTargetImport` specifies which particular imports Tracker should ignore. 151 | 152 | `isTargetImport` is a function receiving [an import model](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/resolveDependencies/identifyImports.ts#L6) 153 | as input and determining if this import should be included. The import model references the particular AST node 154 | where the import happens and contains pre-processed information about that import. 155 | 156 | Filtering imports improves Tracker performance, though insignificantly compared to ignored files. 157 | Most importantly, filtering imports prevents non-components from polluting the output. 158 | 159 | Even though the import model contains a `moduleSpecifier`, consider updating the target regexp if you need to filter on module names or paths. 160 | 161 | [The default implementation](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/cli/resolveStatsConfig.ts#L22-L32) 162 | filters out lower-case named imports — typically not React Components — and all-caps named imports — usually constant values. 163 | 164 | This function gets sent to the worker processes, so its implementation needs to be serializable via `.toString()` and de-serializable via `eval()`. 165 | It can not use scoped variables, as those don't get serialized with `fn.toString()` 166 | 167 | 168 | ## Valid usages filter 169 | 170 | As a last resort, you can use `isValidUsage` function to filter out incorrect data from the output. 171 | This function receives [a usage model](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/findUsages/findUsages.ts#L47-L52) 172 | referencing the particular AST node that Tracker considers a component usage. 173 | 174 | [The default implementation](https://github.com/rangle/radius-tracker/blob/17da736e27f325ec3fa7c920b85fd645a0a81a0a/src/lib/cli/resolveStatsConfig.ts#L79) 175 | is `() => true` — accepting all found usages as valid. 176 | 177 | This function gets sent to the worker processes, so its implementation needs to be serializable via `.toString()` and de-serializable via `eval()`. 178 | It can not use scoped variables, as those don't get serialized with `fn.toString()` 179 | 180 | -------------------------------------------------------------------------------- /src/docs/pages/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Radius Tracker 3 | description: Track every use of every component in every codebase in your company. 4 | Automatically generate design system adoption reports calculated bottom-up 5 | from individual component usage stats. 6 | --- 7 | 8 | import Image from 'next/image' 9 | import styles from "./index.module.css"; 10 | 11 | # Radius Tracker 12 | 13 | > Track every use
14 | > of every component
15 | > in every codebase
16 | > in your company. 17 | 18 | Radius Tracker generates reports measuring design system adoption, calculated bottom-up from individual component usage stats automatically collected from your organization repositories. 19 | 20 | 21 | Sample Radius Tracker dashboard containing design system metrics covering multiple component sources, projects, and components 22 | 23 | -------------------------------------------------------------------------------- /src/docs/pages/index.module.css: -------------------------------------------------------------------------------- 1 | .dashboardImgWrapper { 2 | display: block; 3 | 4 | position: relative; 5 | aspect-ratio: 2 / 1.5; 6 | 7 | margin-top: 20px; 8 | } 9 | 10 | .dashboardImgWrapper > img { 11 | object-fit: contain; 12 | object-position: top; 13 | } 14 | -------------------------------------------------------------------------------- /src/docs/pages/quick_start.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Quick start 3 | description: Run Radius Tracker for the first time and see the first report. 4 | --- 5 | 6 | # Quick start 7 | 8 | Run this in a project directory you want to analyze: 9 | ```sh 10 | npx radius-tracker in-place --targetRe "^@corporation/your-designsystem" 11 | ``` 12 | 13 | This way, Tracker will produce a snapshot of component usages from a single target for the files in the current directory. 14 | 15 | `--targetRe` is a regexp matched against module specifiers in import statements and require calls: 16 | ```js 17 | import { Component } from "module-specifier"; 18 | require("module-specifier"); 19 | ``` 20 | 21 | Check out `npx radius-tracker in-place --help` for more configuration parameters. 22 | 23 | 24 | ## Next steps 25 | 26 | You can [analyze Tracker output](./analysis) as is. However, the in-place run described above 27 | only collects data for the current state of a single project. And running Tracker manually like this 28 | is not sustainable for regularly collecting the data. 29 | 30 | Follow the [configuration guide](./configuration_file) to set up a repeatable process of generating Tracker reports. 31 | -------------------------------------------------------------------------------- /src/docs/pages/supported_technologies.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Supported technologies 3 | description: Radius Tracker supports React with a few limitations around CSS-in-JS styling. 4 | --- 5 | 6 | # Supported technologies 7 | 8 | Tracker currently only supports React projects written in JavaScript or TypeScript. 9 | 10 | For the purpose of homebrew detection, Tracker only supports lowercase JSX elements like `
`, 11 | as opposed to CSS-in-JS wrappers typically used as `styled.div`. See the report intro 12 | for more information about [homebrew components.](https://observablehq.com/@smoogly/design-system-metrics#cell-301) 13 | 14 | 15 | ## Other frameworks 16 | 17 | The frameworks vary greatly in ways they define a component, usage of a component, and dependencies between components. 18 | It makes it hard to support other popular frameworks out of the box. 19 | 20 | [Let us know](https://github.com/rangle/radius-tracker/issues/new?title=Feedback%20for%20%E2%80%9CSupported%20technologies%E2%80%9D&labels=feedback) 21 | if you want to see support for a particular technology. 22 | -------------------------------------------------------------------------------- /src/docs/public/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rangle/radius-tracker/b4ad5096172e74175935f72e3b182a808be65cbe/src/docs/public/dashboard.png -------------------------------------------------------------------------------- /src/docs/theme.config.jsx: -------------------------------------------------------------------------------- 1 | import { useRouter } from "next/router"; 2 | 3 | const repoUrl = "https://github.com/rangle/radius-tracker"; 4 | 5 | const targetBlankProps = { 6 | target: "_blank", 7 | rel: "noopener noreferrer", 8 | }; 9 | 10 | export default { 11 | logo: Radius Tracker, 12 | project: { 13 | link: repoUrl, 14 | }, 15 | 16 | docsRepositoryBase: repoUrl, 17 | feedback: { 18 | content: "Give feedback about this page →", 19 | }, 20 | 21 | editLink: { 22 | text: "Edit on Github →", 23 | component: function EditLink({ className, filePath, children }) { 24 | // Pages aren't hosted at the top level, need to rewrite the path 25 | const editUrl = `${ repoUrl }/src/docs/${ filePath }`; 26 | return { children }; 27 | }, 28 | }, 29 | 30 | toc: { 31 | extraContent: (() => { 32 | const prefill = Object.entries({ 33 | how_we_can_help: "Other", 34 | tell_us_a_bit_more_about_your_inquiry: "I want help setting up Radius Tracker in my organization", 35 | }).reduce((params, [key, val]) => [...params, `${ key }=${ encodeURIComponent(val) }`], []).join("&"); 36 | 37 | return <> 38 | 52 | 56 | Get Rangle to help set up Tracker in your organization → 57 | 58 | ; 59 | })(), 60 | }, 61 | 62 | footer: { 63 | text: { new Date().getFullYear() } 65 | Rangle.io 66 | , 67 | }, 68 | 69 | // Overwrite default `head` to remove Nextra SEO metadata 70 | head: <> 71 | 72 | 73 | 74 | , 75 | 76 | useNextSeoProps() { 77 | const { asPath } = useRouter(); 78 | return { 79 | titleTemplate: asPath !== "/" ? "%s – Radius Tracker" : "%s", 80 | }; 81 | }, 82 | }; 83 | -------------------------------------------------------------------------------- /src/lib/checkEngine.ts: -------------------------------------------------------------------------------- 1 | import { satisfies } from "semver"; 2 | import { requiredNodeVersion } from "./packageInfo"; 3 | 4 | if (!satisfies(process.version, requiredNodeVersion)) { 5 | throw new Error(`Unsupported Node version. Expected ${ requiredNodeVersion }, got ${ process.version }`); 6 | } 7 | 8 | const hasCheckYarnFlag = process.argv.includes("--check-yarn"); 9 | const npmExectPath = process.env.npm_execpath; 10 | 11 | if (hasCheckYarnFlag && npmExectPath && !/\byarn\b/.test(npmExectPath)) { 12 | throw new Error("Please use yarn instead of npm"); 13 | } 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/lib/cli/collectStats.spec.ts: -------------------------------------------------------------------------------- 1 | import { collectStats, isFile, isSubprojectPathEmptyWarning, listFiles } from "./collectStats"; 2 | import { resolveStatsConfig } from "./resolveStatsConfig"; 3 | import { InMemoryFileSystemHost } from "ts-morph"; 4 | import { MultiTargetModuleOrPath, StatsConfig } from "./sharedTypes"; 5 | import { atLeastOne } from "../guards"; 6 | 7 | const noop = () => void 0; 8 | describe("Collect stats", () => { 9 | let config: StatsConfig; 10 | let filesystem: InMemoryFileSystemHost; 11 | 12 | beforeEach(() => { 13 | filesystem = new InMemoryFileSystemHost(); 14 | config = { isTargetModuleOrPath: /target/ }; 15 | }); 16 | 17 | it("should return empty stats for an empty folder", async () => { 18 | const { stats } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 19 | expect(stats).toEqual([]); 20 | }); 21 | 22 | it("should return homebrew stats", async () => { 23 | filesystem.writeFileSync("/component.jsx", ` 24 | const Component = () =>
Hello
; 25 | export const App = () => { 26 | return ; 27 | } 28 | `); 29 | 30 | const { stats } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 31 | expect(stats.filter(x => x.source === "homebrew")).toHaveLength(1); 32 | }); 33 | 34 | it("should ignore homebrew detections in files matching the target regexp", async () => { 35 | const filename = "component.jsx"; 36 | filesystem.writeFileSync(`/${ filename }`, ` 37 | const Component = () =>
Hello
; 38 | export const App = () => { 39 | return ; 40 | } 41 | `); 42 | 43 | config = { isTargetModuleOrPath: new RegExp(`.*${ filename }.*`) }; 44 | const { stats } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 45 | expect(stats).toEqual([]); 46 | }); 47 | 48 | it("should return target stats", async () => { 49 | filesystem.writeFileSync("app.jsx", ` 50 | import { Component } from "target"; 51 | export const App = () => { 52 | return ; 53 | } 54 | `); 55 | const { stats } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 56 | expect(stats.filter(x => x.source === "target")).toHaveLength(1); 57 | }); 58 | 59 | it("should return stats from multiple targets", async () => { 60 | const targets = ["one", "two", "three"]; 61 | 62 | filesystem.writeFileSync("app.jsx", ` 63 | ${ targets.map(t => ` 64 | import * as ${ t } from "${ t }"; 65 | `).join("\n") } 66 | export const App = () => <> 67 | ${ targets.map(t => `<${ t }.Component />`).join("\n") } 68 | ; 69 | `); 70 | 71 | config = { 72 | isTargetModuleOrPath: targets.reduce((_set, t) => { 73 | _set[t] = new RegExp(t); 74 | return _set; 75 | }, {} as MultiTargetModuleOrPath), 76 | }; 77 | 78 | const { stats } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 79 | targets.forEach(t => { 80 | expect(stats.filter(x => x.source === t)).toHaveLength(1); 81 | }); 82 | }); 83 | 84 | it("should find homebrew components using a tag defined with a factory method", async () => { 85 | filesystem.writeFileSync("app.jsx", ` 86 | import styled from 'styled-components'; 87 | const Div = styled.div\`background: red\`; 88 | const Component = () =>
Hello, World!
; 89 | export const App = () => ; 90 | `); 91 | 92 | const { stats } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 93 | expect(stats).toHaveLength(1); 94 | 95 | const stat = atLeastOne(stats)[0]; 96 | expect(stat).toHaveProperty("source", "homebrew"); 97 | expect(stat).toHaveProperty("homebrew_detection_reason", "styled-components"); 98 | expect(stat).toHaveProperty("component_name", "Component"); 99 | }); 100 | 101 | it("should report warnings", async () => { 102 | config = { ...config, subprojectPath: "/does-not-exist" }; 103 | const { warnings } = await collectStats(filesystem, resolveStatsConfig(config), noop, "/"); 104 | 105 | expect(warnings).toHaveLength(1); 106 | 107 | const warning = atLeastOne(warnings)[0]; 108 | expect(isSubprojectPathEmptyWarning(warning)).toBeTruthy(); 109 | }); 110 | }); 111 | 112 | describe("collectStats fs helpers", () => { 113 | let filesystem: InMemoryFileSystemHost; 114 | 115 | beforeEach(() => { 116 | filesystem = new InMemoryFileSystemHost(); 117 | }); 118 | 119 | describe("listFiles", () => { 120 | it("should return a list of paths to all files in a given directory", async () => { 121 | const pathPrefix = "/dir/subdir"; 122 | const expectedFiles = [ 123 | "/deep/nested/file/a", 124 | "/deep/nested/file/b", 125 | "/deep/nested/c", 126 | "/deep/nested/d", 127 | "/deep/e", 128 | "/deep/f", 129 | "/another_dir/g", 130 | "/another_dir/h", 131 | "/root_i", 132 | "/root_j", 133 | ].map(f => pathPrefix + f); 134 | expectedFiles.forEach(f => filesystem.writeFileSync(f, "")); 135 | 136 | const files = listFiles(filesystem, pathPrefix, () => true); 137 | expect(files.sort()).toEqual(expectedFiles.sort()); 138 | }); 139 | }); 140 | 141 | describe("isFile", () => { 142 | it("should return true for a file that exists", async () => { 143 | const path = "/path/to/file"; 144 | filesystem.writeFileSync(path, ""); 145 | expect(isFile(filesystem, path)).toEqual(true); 146 | }); 147 | 148 | it("should return false if the file does not exists", async () => { 149 | const path = "/path/to/file"; 150 | expect(isFile(filesystem, path)).toEqual(false); 151 | }); 152 | 153 | it("should return false if the path points to a directory", async () => { 154 | const path = "/path/to/directory"; 155 | filesystem.writeFileSync(path + "/file", ""); 156 | expect(isFile(filesystem, path)).toEqual(false); 157 | }); 158 | }); 159 | }); 160 | -------------------------------------------------------------------------------- /src/lib/cli/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import "../checkEngine"; 4 | 5 | import yargs from "yargs"; 6 | import { hideBin } from "yargs/helpers"; 7 | 8 | import timelineCommand from "./timelines"; 9 | import inplaceCommand from "./inplace"; 10 | 11 | import reportGenerateTemplateCommand from "./report/generate_template"; 12 | import reportCommand from "./report"; 13 | import { defineYargsModule } from "./util/defineYargsModule"; 14 | import { cacheVersion } from "./util/cacheVersion"; 15 | 16 | yargs(hideBin(process.argv)) 17 | .scriptName("radius-tracker") 18 | .command(timelineCommand) 19 | .command(inplaceCommand) 20 | .command(reportCommand) 21 | .command(reportGenerateTemplateCommand) 22 | .command(defineYargsModule("cache_version", "show cache information", {}, () => console.log(cacheVersion))) 23 | .strictCommands() 24 | .demandCommand() 25 | .recommendCommands() 26 | .parseAsync(); 27 | -------------------------------------------------------------------------------- /src/lib/cli/inplace/index.ts: -------------------------------------------------------------------------------- 1 | import { defineYargsModule } from "../util/defineYargsModule"; 2 | import { collectStats } from "../collectStats"; 3 | import { performance } from "perf_hooks"; 4 | import { resolveStatsConfig } from "../resolveStatsConfig"; 5 | import { StatsConfig } from "../sharedTypes"; 6 | import { processStats, ProjectMetadata, statsMessage } from "../processStats"; 7 | import { join, resolve } from "path"; 8 | import { Merge } from "ts-toolbelt/out/Union/Merge"; 9 | import { OptionalKeys } from "ts-toolbelt/out/Object/OptionalKeys"; 10 | import { RequiredKeys } from "ts-toolbelt/out/Object/RequiredKeys"; 11 | import { Project } from "ts-morph"; 12 | import { writeGzippedOutput } from "../util/gzip"; 13 | 14 | export default defineYargsModule( 15 | "in-place [path]", 16 | "Stats collected directly from the filesystem", 17 | args => args 18 | .positional("path", { 19 | type: "string", 20 | normalize: true, 21 | demandOption: false, 22 | }) 23 | .options("ignoredFileRe", { type: "string" }) 24 | .options("targetRe", { type: "string", demandOption: true }) 25 | .options("tsconfigPath", { type: "string", normalize: true, conflicts: "jsconfigPath" }) 26 | .options("jsconfigPath", { type: "string", normalize: true, conflicts: "tsconfigPath" }) 27 | .options("outfile", { type: "string", normalize: true }), 28 | 29 | async args => { 30 | let prev: number | null = null; 31 | const tag = () => { 32 | const ts = performance.now(); 33 | const tg = `[${ Math.floor(prev ? (ts - prev) / 1000 : 0) }s]`; 34 | prev = ts; 35 | return tg; 36 | }; 37 | 38 | type Explicit = 39 | { [P in OptionalKeys]: T[P] | undefined } 40 | & { [P in RequiredKeys]: T[P] }; 41 | const unresolvedConfig: Explicit> = { 42 | tsconfigPath: args.tsconfigPath ?? null, 43 | jsconfigPath: args.jsconfigPath ?? null, 44 | isTargetModuleOrPath: new RegExp(args.targetRe), 45 | isTargetImport: undefined, 46 | isValidUsage: undefined, 47 | subprojectPath: "/", 48 | isIgnoredFile: args.ignoredFileRe ? new RegExp(args.ignoredFileRe) : undefined, 49 | domReferenceFactories: undefined, 50 | }; 51 | 52 | const config = resolveStatsConfig(unresolvedConfig); 53 | 54 | const project: ProjectMetadata = { 55 | name: args.path ?? "Current directory", 56 | url: args.path ?? ".", 57 | subprojectPath: "/", 58 | }; 59 | const stats = await collectStats( 60 | new Project().getFileSystem(), // Provide the disk filesystem 61 | config, 62 | (message: string) => console.log(`${ tag() } ${ message }`), 63 | resolve(args.path ?? process.cwd()), 64 | ); 65 | 66 | const statsDB = await processStats([{ project, config, stats: [{ 67 | commit: { 68 | oid: "latest", 69 | weeksAgo: 0, 70 | ts: new Date(), 71 | expectedDate: new Date(), 72 | }, 73 | ...stats, 74 | }] }]); 75 | 76 | const outfile = resolve(args.outfile || join(process.cwd(), "usages.sqlite.gz")); 77 | await writeGzippedOutput(Buffer.from(statsDB.export()), outfile); 78 | console.log(statsMessage(outfile)); 79 | }, 80 | ); 81 | -------------------------------------------------------------------------------- /src/lib/cli/processStats.ts: -------------------------------------------------------------------------------- 1 | // noinspection SqlResolve 2 | 3 | import initSqlight, { BindParams, Database } from "sql.js"; 4 | import { CommitData, Stats } from "./timelines/workerTypes"; 5 | import { atLeastOne, isRegexp, objectKeys } from "../guards"; 6 | import { ResolvedStatsConfig, UsageStat } from "./sharedTypes"; 7 | import { version } from "../packageInfo"; 8 | import { relative } from "path"; 9 | 10 | export type ProjectMetadata = { 11 | name: string, 12 | url: string, 13 | subprojectPath: string, 14 | }; 15 | export const processStats = async ( // TODO: include warnings in the output 16 | allStats: { 17 | project: ProjectMetadata, 18 | config: ResolvedStatsConfig, stats: Stats, 19 | }[], 20 | ): Promise => { 21 | const SQL = await initSqlight(); 22 | const db = new SQL.Database(); 23 | 24 | db.run(` 25 | CREATE TABLE meta ( 26 | id INTEGER PRIMARY KEY, 27 | key TEXT UNIQUE, 28 | value TEXT 29 | ); 30 | `); 31 | const meta: { [key: string]: string } = { 32 | version, 33 | schemaVersion: "3", 34 | collectedAt: new Date().toISOString(), 35 | }; 36 | objectKeys(meta).forEach(key => { 37 | const val = meta[key]; 38 | if (!val) { throw new Error(`String meta value expected for key '${ key }'`); } 39 | db.run("INSERT INTO meta(key, value) VALUES ($1, $2)", [key, val]); 40 | }); 41 | 42 | // Project config specifiers targets, sometimes multiple targets per project with their own regexps. 43 | // Targets with matching keys are considered the same target, even if they have different regexps. 44 | // This is meaningful because same thing may sometimes be imported in different ways. 45 | // TODO: capture what exactly was the regexp for a given source per project. 46 | db.run(` 47 | CREATE TABLE sources ( 48 | id INTEGER PRIMARY KEY, 49 | source TEXT UNIQUE 50 | ); 51 | `); 52 | db.run(` 53 | CREATE TABLE projects ( 54 | id INTEGER PRIMARY KEY, 55 | name TEXT, 56 | url TEXT, 57 | subproject_path TEXT 58 | ); 59 | `); 60 | db.run(` 61 | CREATE TABLE files ( 62 | id INTEGER PRIMARY KEY, 63 | project INTEGER, 64 | file TEXT, 65 | FOREIGN KEY (project) REFERENCES projects(id) 66 | ); 67 | `); 68 | db.run(` 69 | CREATE UNIQUE INDEX file_uniq_per_project ON files(project, file); 70 | `); 71 | db.run(` 72 | CREATE TABLE components ( 73 | id INTEGER PRIMARY KEY, 74 | source INTEGER, 75 | homebrew_project INTEGER, 76 | component TEXT, 77 | FOREIGN KEY (source) REFERENCES sources(id), 78 | FOREIGN KEY (homebrew_project) REFERENCES projects(id) 79 | ); 80 | `); 81 | db.run(` 82 | CREATE UNIQUE INDEX components_uniq_per_homebrew_project ON components(homebrew_project, component); 83 | `); 84 | db.run(` 85 | CREATE TABLE commits ( 86 | id INTEGER PRIMARY KEY, 87 | project INTEGER, 88 | oid TEXT, 89 | committedAt TEXT, 90 | FOREIGN KEY (project) REFERENCES projects(id) 91 | ); 92 | `); 93 | db.run(` 94 | CREATE UNIQUE INDEX commit_uniq_per_project ON commits(project, oid); 95 | `); 96 | db.run(` 97 | CREATE TABLE usages ( 98 | id INTEGER PRIMARY KEY, 99 | source INTEGER, 100 | project INTEGER, 101 | oid INTEGER, 102 | weeksAgo INTEGER, 103 | importedFrom INTEGER, 104 | targetNodeFile INTEGER, 105 | usageFile INTEGER, 106 | component INTEGER, 107 | FOREIGN KEY (source) REFERENCES sources(id), 108 | FOREIGN KEY (project) REFERENCES projects(id), 109 | FOREIGN KEY (oid) REFERENCES commits(id), 110 | FOREIGN KEY (importedFrom) REFERENCES files(id), 111 | FOREIGN KEY (targetNodeFile) REFERENCES files(id), 112 | FOREIGN KEY (usageFile) REFERENCES files(id), 113 | FOREIGN KEY (component) REFERENCES components(id) 114 | ); 115 | `); 116 | 117 | 118 | const execReturning = (sql: string, params?: BindParams) => { 119 | if (!sql.toLowerCase().includes("returning")) { throw new Error(`Expected a \`returning\` statement in the given sql, got: ${ sql }`); } 120 | const res = db.exec(sql, params); 121 | const row = res[0]?.values[0]; 122 | if (row === undefined) { throw new Error("Expected to find a row"); } 123 | const value = row[0]; 124 | if (value === undefined) { throw new Error("Expected to find a returned value in the row"); } 125 | return value; 126 | }; 127 | 128 | const homebrewId = execReturning("INSERT INTO sources(source) VALUES ($1) RETURNING id", ["homebrew"]); 129 | if (typeof homebrewId !== "number") { throw new Error("Expected homebrew source id to be a number"); } 130 | 131 | const sourceIdMap = [...new Set( 132 | allStats 133 | .map(x => x.config.isTargetModuleOrPath) 134 | .flatMap(target => isRegexp(target) ? "target" : objectKeys(target)), 135 | )] 136 | .map(source => { 137 | const id = execReturning("INSERT INTO sources(source) VALUES ($1) RETURNING id", [source]); 138 | if (typeof id !== "number") { throw new Error("Expected source id to be a number"); } 139 | return { source, id }; 140 | }) 141 | .reduce((map, { source, id }) => { 142 | map.set(source, id); 143 | return map; 144 | }, new Map([["homebrew", homebrewId]])); 145 | 146 | const getSourceId = (source: string) => { 147 | const id = sourceIdMap.get(source); 148 | if (!id) { throw new Error(`Can not find source ${ source }`); } 149 | return id; 150 | }; 151 | 152 | const commitCache: { [key: string]: number } = {}; 153 | const upsertCommit = (project: number, commit: string, commitTime: Date) => { 154 | const cacheKey = `${ project }:::${ commit }`; 155 | const cached = commitCache[cacheKey]; 156 | if (cached) { return cached; } 157 | 158 | const commitId = execReturning("INSERT INTO commits(project, oid, committedAt) VALUES ($0, $1, $2) ON CONFLICT DO UPDATE SET oid = oid RETURNING id", [project, commit, commitTime.toISOString()]); 159 | if (typeof commitId !== "number") { throw new Error("Expected a numeric commit id"); } 160 | 161 | commitCache[cacheKey] = commitId; 162 | return commitId; 163 | }; 164 | 165 | const fileCache: { [key: string]: number } = {}; 166 | const upsertFile = (project: number, file: string) => { 167 | const cacheKey = `${ project }:::${ file }`; 168 | const cached = fileCache[cacheKey]; 169 | if (cached) { return cached; } 170 | 171 | const fileId = execReturning("INSERT INTO files(project, file) VALUES ($0, $1) ON CONFLICT DO UPDATE SET file = file RETURNING id", [project, file]); 172 | if (typeof fileId !== "number") { throw new Error("Expected a numeric file id"); } 173 | 174 | fileCache[cacheKey] = fileId; 175 | return fileId; 176 | }; 177 | 178 | const componentCache: { [key: string]: number } = {}; 179 | const upsertComponent = (source: number, homebrewProject: number | null, component: string) => { 180 | const cacheKey = `${ source }:::${ homebrewProject }:::${ component }`; 181 | const cached = componentCache[cacheKey]; 182 | if (cached) { return cached; } 183 | 184 | const componentId = execReturning(` 185 | INSERT INTO components(source, homebrew_project, component) 186 | VALUES ($0, $1, $2) 187 | ON CONFLICT DO UPDATE SET component = component RETURNING id 188 | `, [source, homebrewProject, component]); 189 | if (typeof componentId !== "number") { throw new Error("Expected a numeric component id"); } 190 | 191 | componentCache[cacheKey] = componentId; 192 | return componentId; 193 | }; 194 | 195 | const usageColumns = ( 196 | project: number, 197 | commit: Pick, 198 | usage: UsageStat, 199 | ) => { 200 | const sourceId = getSourceId(usage.source); 201 | return { 202 | project, 203 | oid: upsertCommit(project, commit.oid, commit.ts), 204 | weeksAgo: commit.weeksAgo, 205 | 206 | source: sourceId, 207 | importedFrom: upsertFile(project, usage.imported_from), 208 | targetNodeFile: upsertFile(project, usage.target_node_file), 209 | usageFile: upsertFile(project, usage.usage_file), 210 | component: upsertComponent(sourceId, usage.source === "homebrew" ? project : null, usage.component_name), 211 | }; 212 | }; 213 | 214 | const write = (project: number, commit: Pick, usagesArr: UsageStat[]) => { 215 | if (usagesArr.length === 0) { return; } 216 | 217 | const usages = atLeastOne(usagesArr); 218 | const columns = objectKeys(usageColumns(project, commit, usages[0])); 219 | 220 | const chunks = usages.reduce((all, item, i) => { 221 | const chunkId = Math.floor(i / 1000); 222 | const chunk = all[chunkId] ?? []; 223 | chunk.push(item); 224 | all[chunkId] = chunk; 225 | return all; 226 | }, [] as UsageStat[][]); 227 | 228 | for (const chunk of chunks) { 229 | let placeholder = 0; 230 | const allValues = chunk.map(usage => { 231 | const usageData = usageColumns(project, commit, usage); 232 | const usageValues = columns.map(k => usageData[k]); 233 | return { 234 | placeholders: `(${ usageValues.map(() => `$${ placeholder++ }`).join(", ") })`, 235 | values: usageValues, 236 | }; 237 | }); 238 | 239 | db.run(` 240 | INSERT INTO usages(${ columns.join(", ") }) 241 | VALUES ${ allValues.map(v => v.placeholders).join(", ") } 242 | `, allValues.map(v => v.values).flat()); 243 | } 244 | }; 245 | 246 | for (const { project, stats: projectStats } of allStats) { 247 | console.log(`Processing stats for ${ project.name }`); 248 | const projectId = execReturning(` 249 | INSERT INTO projects(name, url, subproject_path) 250 | VALUES($0, $1, $2) RETURNING id 251 | `, [project.name, project.url, project.subprojectPath]); 252 | if (typeof projectId !== "number") { throw new Error("Numeric project id expected"); } 253 | 254 | for (const pointInTime of projectStats) { 255 | write(projectId, pointInTime.commit, pointInTime.stats); 256 | } 257 | } 258 | 259 | return db; 260 | }; 261 | 262 | export const statsMessage = (outfile: string) => { 263 | const out = relative(process.cwd(), outfile); 264 | return ` 265 | 266 | Stats were saved to: ${ out } 267 | It's a gzipped SQLite database you can use as an input for your analysis. 268 | 269 | Generate a local report by running: 270 | npx radius-tracker report --database ${ out } 271 | 272 | See https://rangle.github.io/radius-tracker/analysis for details. 273 | `.replace(/\n(?!\n)\s+/g, "\n"); 274 | }; 275 | -------------------------------------------------------------------------------- /src/lib/cli/report/additional_styles.css: -------------------------------------------------------------------------------- 1 | /* Styles selectively copied from observablehq stylesheet */ 2 | 3 | :root { 4 | --serif: "Source Serif Pro", "Iowan Old Style", "Apple Garamond", 5 | "Palatino Linotype", "Times New Roman", "Droid Serif", Times, serif, 6 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; 7 | 8 | --sans-serif: -apple-system, BlinkMacSystemFont, "avenir next", avenir, 9 | helvetica, "helvetica neue", ubuntu, roboto, noto, "segoe ui", arial, 10 | sans-serif; 11 | 12 | --monospace: Menlo, Consolas, monospace; 13 | } 14 | 15 | .observablehq--inspect { 16 | /* Hide the inspector values, primarily to avoid the inspector output of `viewof` cells */ 17 | /* TODO: should only be applied to `viewof` cell inspectors */ 18 | display: none; 19 | } 20 | 21 | html { 22 | font: 17px/1.5 var(--serif); 23 | } 24 | 25 | body { 26 | margin: 8rem auto; 27 | padding: 0 2rem; 28 | max-width: 76rem; 29 | } 30 | 31 | h1, 32 | h2, 33 | h3, 34 | h4, 35 | h5, 36 | h6 { 37 | color: #333; 38 | font-weight: 700; 39 | line-height: 1.15; 40 | margin-top: 4rem; 41 | margin-bottom: 0.25rem; 42 | } 43 | 44 | h2 ~ p, 45 | h3 ~ p, 46 | h4 ~ p, 47 | h2 ~ table, 48 | h3 ~ table, 49 | h4 ~ table { 50 | margin-top: 0; 51 | } 52 | 53 | a[href] { 54 | text-decoration: none; 55 | } 56 | 57 | a[href]:hover { 58 | text-decoration: underline; 59 | } 60 | 61 | figcaption { 62 | font: small var(--sans-serif); 63 | color: #838383; 64 | } 65 | 66 | pre, code, tt { 67 | font-family: var(--monospace); 68 | font-size: 14px; 69 | line-height: 1.5; 70 | } 71 | 72 | img { 73 | max-width: calc(100vw - 28px); 74 | } 75 | 76 | p, 77 | table, 78 | figure, 79 | figcaption, 80 | h1, 81 | h2, 82 | h3, 83 | h4, 84 | h5, 85 | h6, 86 | .katex-display { 87 | max-width: 640px; 88 | } 89 | 90 | blockquote, 91 | ol, 92 | ul { 93 | max-width: 600px; 94 | } 95 | 96 | blockquote { 97 | margin: 1rem 1.5rem; 98 | } 99 | 100 | ul, 101 | ol { 102 | padding-left: 28px; 103 | } 104 | 105 | pre { 106 | padding: 2px 0; 107 | } 108 | 109 | .observablehq--md-pre { 110 | overflow-x: auto; 111 | } 112 | 113 | input:not([type]), 114 | input[type="email"], 115 | input[type="number"], 116 | input[type="password"], 117 | input[type="range"], 118 | input[type="search"], 119 | input[type="tel"], 120 | input[type="text"], 121 | input[type="url"] { 122 | width: 240px; 123 | } 124 | 125 | input, 126 | canvas, 127 | button { 128 | vertical-align: middle; 129 | } 130 | 131 | button, 132 | input, 133 | textarea { 134 | accent-color: #3b5fc0; 135 | } 136 | 137 | 138 | table { 139 | width: 100%; 140 | border-collapse: collapse; 141 | font: 13px/1.2 var(--sans-serif); 142 | } 143 | 144 | table pre, 145 | table code, 146 | table tt { 147 | font-size: inherit; 148 | line-height: inherit; 149 | } 150 | 151 | th > pre:only-child, 152 | td > pre:only-child { 153 | margin: 0; 154 | padding: 0; 155 | } 156 | 157 | th { 158 | color: #111; 159 | text-align: left; 160 | vertical-align: bottom; 161 | } 162 | 163 | td { 164 | color: #444; 165 | vertical-align: top; 166 | } 167 | 168 | th, 169 | td { 170 | padding: 3px 6.5px 3px 0; 171 | } 172 | 173 | th:last-child, td:last-child { 174 | padding-right: 0; 175 | } 176 | 177 | tr:not(:last-child) { 178 | border-bottom: solid 1px #eee; 179 | } 180 | 181 | thead tr { 182 | border-bottom: solid 1px #ccc; 183 | } 184 | 185 | figure, 186 | table { 187 | margin: 1rem 0; 188 | } 189 | 190 | figure img { 191 | max-width: 100%; 192 | } 193 | 194 | -------------------------------------------------------------------------------- /src/lib/cli/report/async_exec.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | 3 | export const asyncExec = (cmd: string) => new Promise((res, rej) => { 4 | try { 5 | exec(cmd, (err, _, stderr) => { 6 | if (err) { 7 | console.log(stderr); 8 | rej(err); 9 | } else { 10 | res(); 11 | } 12 | }); 13 | } catch (e) { 14 | rej(e); 15 | } 16 | }); 17 | -------------------------------------------------------------------------------- /src/lib/cli/report/generate_report_template.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | set -e 4 | 5 | REPORT_DIR=build/report_template 6 | 7 | if [ -z "${1}" ]; then 8 | echo "Missing report url" 9 | exit; 10 | fi 11 | 12 | 13 | # Fetch 14 | curl "${1}" --output report.tgz 15 | 16 | # Create the dir 17 | rm -rf "${REPORT_DIR}" 18 | mkdir -p "${REPORT_DIR}" 19 | 20 | # Extract 21 | tar zxf report.tgz -C "${REPORT_DIR}" 22 | rm report.tgz 23 | 24 | # Run transformations 25 | BASEDIR=$(dirname "$0") 26 | [ -e "${BASEDIR}/prepare_report.js" ] && node "${BASEDIR}/prepare_report.js" "${REPORT_DIR}" # In package 27 | [ -e "${BASEDIR}/prepare_report.ts" ] && node -r ts-node/register/transpile-only "${BASEDIR}/prepare_report.ts" "${REPORT_DIR}" # In dev 28 | 29 | # Append additional styles 30 | cat "${BASEDIR}/additional_styles.css" >> "${REPORT_DIR}/inspector.css" 31 | 32 | # Cleanup unnecessary files 33 | rm -f "${REPORT_DIR}"/files/* "${REPORT_DIR}"/package.json "${REPORT_DIR}/README.md" 34 | touch "${REPORT_DIR}/files/.empty" 35 | -------------------------------------------------------------------------------- /src/lib/cli/report/generate_template.ts: -------------------------------------------------------------------------------- 1 | import { defineYargsModule } from "../util/defineYargsModule"; 2 | import { asyncExec } from "./async_exec"; 3 | import { join } from "path"; 4 | import { platform } from "os"; 5 | 6 | // This default determines which report is added to the package at build time. 7 | // After updating the url, run `yarn cli report-generate-template` 8 | // followed by `yarn cli report` using a sample usages database 9 | // 10 | // Make sure to check that 11 | // a) the report itself is working correctly, 12 | // b) there are no errors in the browser console, and no dom nodes marked with `observablehq-error` class 13 | // c) no network requests go outside of localhost. 14 | const defaultReportUrl = "https://api.observablehq.com/@smoogly/design-system-metrics@454.tgz?v=3"; 15 | 16 | export default defineYargsModule( 17 | "report-generate-template [url]", 18 | "Prepare a template for static reports from an ObservableHQ page", 19 | args => args 20 | .positional("url", { 21 | type: "string", 22 | default: defaultReportUrl, 23 | describe: "URL of the report to use for the template", 24 | demandOption: false, 25 | }), 26 | async args => { 27 | const supportedPlatforms = ["linux", "darwin"]; 28 | if (!supportedPlatforms.includes(platform())) { 29 | throw new Error(`Current implementation of the report template generator only supports ${ supportedPlatforms.join(", ") }, instead got ${ platform() }`); 30 | } 31 | 32 | if (!isUrl(args.url)) { 33 | throw new Error(`Expected a valid URL, instead got: ${ args.url }`); 34 | } 35 | 36 | const customTemplateMessage = ` 37 | Custom report templates API is unstable. 38 | Use at your own risk and please report issues. 39 | `.replace(/\n(?!\n)\s+/g, "\n"); 40 | 41 | try { 42 | await asyncExec(`${ join(__dirname, "generate_report_template.sh") } ${ args.url }`); 43 | console.log("\n\nReport template written to build/report_template\n"); 44 | if (args.url !== defaultReportUrl) { 45 | console.log(customTemplateMessage); 46 | } 47 | console.log("\n"); 48 | } catch (e) { 49 | if (args.url !== defaultReportUrl) { 50 | // Print the custom template warning 51 | process.on("exit", () => console.log(customTemplateMessage)); 52 | } 53 | 54 | throw e; 55 | } 56 | }, 57 | ); 58 | 59 | const isUrl = (url: string) => { 60 | try { 61 | return Boolean(new URL(url)); 62 | } catch (e) { 63 | return false; 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /src/lib/cli/report/index.ts: -------------------------------------------------------------------------------- 1 | import { defineYargsModule } from "../util/defineYargsModule"; 2 | import { access, copyFile, mkdir, readdir, rm } from "fs/promises"; 3 | import { join, relative } from "path"; 4 | import { constants } from "fs"; 5 | 6 | export default defineYargsModule( 7 | "report", 8 | "Creates a local report", 9 | args => args 10 | .options("template", { 11 | type: "string", 12 | normalize: true, 13 | describe: "Location of the report template. Uses built-in report by default.", 14 | demandOption: false, 15 | }) 16 | .options("database", { 17 | type: "string", 18 | normalize: true, 19 | default: "./usages.sqlite.gz", 20 | describe: "Location of the database", 21 | demandOption: false, 22 | }) 23 | .options("outdir", { 24 | type: "string", 25 | normalize: true, 26 | default: "./radius-tracker-report/", 27 | describe: "Output location of the report", 28 | demandOption: false, 29 | }), 30 | async args => { 31 | // Check the target location 32 | const relativeOutdir = relative(process.cwd(), args.outdir); 33 | if ((!relativeOutdir || relativeOutdir.includes("..")) && await exists(args.outdir, constants.W_OK)) { 34 | throw new Error(` 35 | Looks like outdir is outside of current directory, and is not empty. 36 | It's really scary to wipe the target directory outside of cwd, 37 | assuming you might be doing this by mistake. 38 | 39 | Outdir resolved to ${ relativeOutdir || "current directory" } 40 | 41 | Please report an issue if you are doing this deliberately. 42 | `.replace(/\n(?!\n)\s+/g, "\n")); 43 | } 44 | 45 | // Clean up the target location 46 | await rm(args.outdir, { recursive: true, force: true, maxRetries: 10 }); 47 | 48 | // Copy the report template 49 | const templatePath = args.template || await resolveBundledTemplateDirectory(); 50 | await copyDir(templatePath, args.outdir); 51 | 52 | // Copy the database 53 | await copyFile(args.database, join(args.outdir, "files", "usages.sqlite.gz")); 54 | 55 | console.log(` 56 | Radius Tracker report written to ${ args.outdir } 57 | 58 | To view, host the report as static files, or serve locally using: 59 | npx serve ${ args.outdir } 60 | `.replace(/\n(?!\n)\s+/g, "\n")); 61 | }, 62 | ); 63 | 64 | async function copyDir(from: string, to: string): Promise { 65 | // Read directory first, so that we fail early if the directory doesn't exist 66 | const directory = await readdir(from, { withFileTypes: true, encoding: "utf-8" }); 67 | 68 | await mkdir(to, { recursive: true }); 69 | for (const file of directory) { 70 | if (file.isDirectory()) { 71 | await copyDir(join(from, file.name), join(to, file.name)); 72 | continue; 73 | } 74 | 75 | if (file.isFile()) { 76 | await copyFile(join(from, file.name), join(to, file.name)); 77 | continue; 78 | } 79 | 80 | throw new Error(`Unhandled directory entry, not a file and not a directory: ${ file.name } `); 81 | } 82 | } 83 | 84 | async function resolveBundledTemplateDirectory() { 85 | // In package, report template is at the root of the package directory 86 | const packagePath = join(__dirname, "..", "..", "..", "report_template"); 87 | if (await exists(packagePath)) { 88 | return packagePath; 89 | } 90 | 91 | // In dev, report template is at the root of the build directory 92 | const devPath = join(__dirname, "..", "..", "..", "..", "build", "report_template"); 93 | if (await exists(devPath)) { 94 | return devPath; 95 | } 96 | 97 | throw new Error("Could not resolve a report template path. In dev, make sure to generate the template first."); 98 | } 99 | 100 | async function exists(path: string, mode?: number) { 101 | try { 102 | await access(path, mode); 103 | return true; 104 | } catch (e) { 105 | return false; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/lib/cli/report/prepare_report.ts: -------------------------------------------------------------------------------- 1 | import { dirname, isAbsolute, join, normalize } from "path"; 2 | import { Node, Project, Statement } from "ts-morph"; 3 | import { isEither } from "../../guards"; 4 | import { URL } from "url"; 5 | import { readdir, readFile, writeFile } from "fs/promises"; 6 | import { asyncExec } from "./async_exec"; 7 | 8 | const reportDirArg = process.argv[2]; 9 | if (!reportDirArg) { throw new Error("Missing report directory argument"); } 10 | const absoluteReportDir = normalize(isAbsolute(reportDirArg) ? reportDirArg : join(process.cwd(), reportDirArg)); 11 | 12 | const getStatement = (node: Node): Statement => { 13 | if (Node.isStatement(node)) { return node; } 14 | const parent = node.getParent(); 15 | if (!parent) { throw new Error("Implementation error: node without parent is not a statement"); } 16 | return getStatement(parent); 17 | }; 18 | 19 | const isStringLikeLiteral = isEither(Node.isStringLiteral, Node.isNoSubstitutionTemplateLiteral); 20 | const isFunction = isEither(Node.isFunctionDeclaration, Node.isFunctionExpression, Node.isArrowFunction); 21 | 22 | (async () => { 23 | // Set up the project 24 | const project = new Project({ 25 | useInMemoryFileSystem: false, 26 | compilerOptions: { 27 | baseUrl: absoluteReportDir, 28 | rootDir: absoluteReportDir, 29 | allowJs: true, 30 | }, 31 | }); 32 | project.addSourceFilesAtPaths(`${ absoluteReportDir }/**/*.js`); 33 | const ignoredFiles = ["runtime.js", "index.js"]; 34 | project.getSourceFiles() 35 | .filter(f => ignoredFiles.includes(f.getBaseName())) 36 | .forEach(f => project.removeSourceFile(f)); 37 | 38 | 39 | // Check no static esm imports are used 40 | // TODO: for the future might be useful to recursively fetch & rewrite esm imports 41 | const staticImport = project.getSourceFiles() 42 | .flatMap(f => f.getDescendantStatements()) 43 | .filter(Node.isImportDeclaration) 44 | .find(node => node.getModuleSpecifierValue().startsWith("http")); 45 | if (staticImport) { throw new Error(`Static imports are not supported. Found ${ getStatement(staticImport).print() }`); } 46 | 47 | 48 | // Check no dynamic imports are used 49 | // TODO: for the future might be useful to find a way to support dynamic imports 50 | const importExpression = project.getSourceFiles() 51 | .flatMap(f => f.forEachDescendantAsArray()) 52 | .find(Node.isImportExpression); 53 | if (importExpression) { throw new Error(`Dynamic imports are not supported. Found ${ getStatement(importExpression).print() }`); } 54 | 55 | 56 | // Find `knownExternalResources` list 57 | const knownExternalResourcesDefinitionCall = findCellDefinitionCall("knownExternalResources"); 58 | 59 | const knownExternalResourcesDefinitionFn = knownExternalResourcesDefinitionCall.getArguments()[1]; 60 | if (!Node.isIdentifier(knownExternalResourcesDefinitionFn)) { throw new Error("Could not find `knownExternalResources` identifier"); } 61 | 62 | const knownExternalResourcesImplementations = knownExternalResourcesDefinitionFn.getImplementations(); 63 | if (knownExternalResourcesImplementations.length !== 1) { throw new Error(`Expected a single \`knownExternalResources\` implementation, got ${ knownExternalResourcesImplementations.length }`); } 64 | 65 | const knownExternalResourcesImplementationNode = knownExternalResourcesImplementations[0]?.getNode()?.getParent(); 66 | if (!knownExternalResourcesImplementationNode) { throw new Error("Could not find `knownExternalResources` implementation node"); } 67 | if (!isFunction(knownExternalResourcesImplementationNode)) { throw new Error("`knownExternalResources` implementation node is not a function"); } 68 | 69 | const knownExternalResourceElements = knownExternalResourcesImplementationNode.getBody() 70 | ?.getDescendantStatements().find(Node.isReturnStatement) 71 | ?.forEachDescendantAsArray() 72 | .find(Node.isArrayLiteralExpression) 73 | ?.getElements(); 74 | if (!knownExternalResourceElements) { throw new Error("Could not find known external resource links in cell implementation"); } 75 | if (!knownExternalResourceElements.every(isStringLikeLiteral)) { throw new Error("Expected a list of string literals in known external resource links cell implementation"); } 76 | const knownExternalResources = knownExternalResourceElements.map(el => el.getLiteralText()); 77 | 78 | 79 | // Set up a mechanism to save remote dependencies 80 | const savedFiles = new Map(); 81 | async function save(urlString: string) { 82 | const url = new URL(urlString); 83 | const filename = url.pathname; 84 | const filepath = normalize(join(absoluteReportDir, "dependencies", filename)); 85 | 86 | await asyncExec(`mkdir -p ${ dirname(filepath) }`); 87 | await asyncExec(`curl "${ urlString }" --output "${ filepath }"`); 88 | savedFiles.set(urlString, filename); 89 | } 90 | 91 | 92 | // Fetch external resources 93 | await Promise.all(knownExternalResources.map(save)); 94 | 95 | 96 | // Rewrite the database filename 97 | const attachments = await readdir(join(absoluteReportDir, "files")); 98 | const databaseFile = attachments[0]; 99 | if (attachments.length !== 1 || !databaseFile) { throw new Error(`Expected a single attachment with the sqlite database, got ${ attachments.length } instead`); } 100 | 101 | const databaseReferenceNode = project.getSourceFiles() 102 | .flatMap(f => f.forEachDescendantAsArray()) 103 | .filter(isStringLikeLiteral) 104 | .find(node => node.getLiteralValue().includes(databaseFile)); 105 | if (!databaseReferenceNode) { throw new Error(`Could not find a string literal node referencing database attachment: ${ databaseFile }`); } 106 | databaseReferenceNode.setLiteralValue(databaseReferenceNode.getLiteralValue().replace(databaseFile, "usages.sqlite.gz")); 107 | 108 | 109 | // Remove dev-mode require overwrite 110 | getStatement(findCellDefinitionCall("require")).remove(); 111 | 112 | 113 | // Rewrite the require calls to point to local files 114 | const indexHtmlPath = join(absoluteReportDir, "index.html"); 115 | const indexHtml = await readFile(indexHtmlPath, "utf-8"); 116 | const numScriptTags = indexHtml.match(//)?.length ?? 0; 117 | if (numScriptTags !== 1) { throw new Error(`Expected a single script tag in index.html, got ${ numScriptTags } instead`); } 118 | 119 | const scriptContent = indexHtml.match(/((?:.|\n)*)<\/script>/)?.[1]; 120 | if (!scriptContent) { throw new Error("No script content found"); } 121 | const indexFile = project.createSourceFile("tmp_index_script", scriptContent); 122 | 123 | const runtimeImport = indexFile.forEachDescendantAsArray() 124 | .filter(Node.isImportDeclaration) 125 | .find(imp => imp.getModuleSpecifierValue() === "./runtime.js"); 126 | if (!runtimeImport) { throw new Error("Could not find import from ./runtime.js"); } 127 | if (!runtimeImport.getNamedImports().find(named => named.getName() === "Library")) { 128 | // Add library import if missing 129 | runtimeImport.addNamedImport("Library"); 130 | } 131 | 132 | const runtimeInstantiation = indexFile.forEachDescendantAsArray() 133 | .filter(Node.isNewExpression) 134 | .find(newExpr => newExpr.getExpression().getText(false) === "Runtime"); 135 | if (!runtimeInstantiation) { throw new Error("Could not find `new Runtime()` call"); } 136 | if (runtimeInstantiation.getArguments().length !== 0) { throw new Error("Expected runtime instantiation to have no arguments"); } 137 | 138 | // Prepend pre-fetched libs 139 | indexFile.insertStatements(getStatement(runtimeInstantiation).getChildIndex(), ` 140 | const localLibraries = { 141 | ${ [...savedFiles.entries()].map(([url, file]) => `"${ url }": "./dependencies${ file }"`).join(",\n") } 142 | }; 143 | const libraryUrls = Object.keys(localLibraries); 144 | 145 | const strictRequire = Library.requireFrom(async (name) => { 146 | const match = libraryUrls.find(u => u.includes(name)); 147 | if (!match) { 148 | throw new Error(\`Unknown require resource. Please add it to knownExternalResources list, so that it can be statically resolved for report archival: \${ name }\`); 149 | } 150 | 151 | return localLibraries[match]; 152 | }); 153 | 154 | strictRequire.resolve = (path) => { 155 | const match = libraryUrls.find(u => u.includes(path)); 156 | if (!match) { 157 | throw new Error(\`Unknown resolve resource. Please add a matching URL to knownExternalResources list, so that it can be statically resolved for report archival: \${ path }\`); 158 | } 159 | 160 | // Observable stdlib uses \`require.resolve\` to find where \`sql-wasm.wasm\` is located 161 | // relative to \`sql-wasm.js\` in \`sql.js\` module. 162 | // https://github.com/observablehq/stdlib/blob/fd48793e9e1bea5379e98d7f246a3442226562f2/src/sqlite.js#L5 163 | // 164 | // This contraption 1) assumes wasm file is loaded using knownExternalResources list, 165 | // and 2) local dependencies are organized in the same way the files in the original CDN are. 166 | const matchedPathname = new URL(sliceUntilAndIncluding(match, path)).pathname; 167 | return sliceUntilAndIncluding(localLibraries[match], matchedPathname); 168 | 169 | function sliceUntilAndIncluding(str, chunk) { 170 | const index = str.indexOf(chunk); 171 | if (index === -1) { throw new Error(\`Can not find \${ chunk } in \${ str }\`); } 172 | return str.slice(0, index + chunk.length); 173 | } 174 | }; 175 | 176 | // Overwrite the default require 177 | Library.require = strictRequire; 178 | `); 179 | 180 | // Add instantiation arg to runtime 181 | runtimeInstantiation.addArgument("new Library()"); 182 | 183 | // Write out the updated index file 184 | indexFile.formatText(); 185 | await writeFile(indexHtmlPath, indexHtml.replace(scriptContent, indexFile.getFullText()), "utf-8"); 186 | indexFile.delete(); // Remove the temporary file 187 | 188 | 189 | // Save the rewrites 190 | await project.save(); 191 | 192 | 193 | // Find cell helper 194 | function findCellDefinitionCall(cellName: string) { 195 | const definitionCall = project.getSourceFiles() 196 | .flatMap(f => f.forEachDescendantAsArray()) 197 | .filter(Node.isPropertyAccessExpression) 198 | .filter(propertyAccess => propertyAccess.getName() === "define") 199 | .map(propertyAccess => propertyAccess.getParent()) 200 | .filter(Node.isCallExpression) 201 | .find(callExpression => { 202 | const firstArg = callExpression.getArguments()[0]; 203 | return isStringLikeLiteral(firstArg) && firstArg.getLiteralText() === cellName; 204 | }); 205 | 206 | if (!definitionCall) { 207 | throw new Error(`Did not find '${ cellName }' cell`); 208 | } 209 | 210 | return definitionCall; 211 | } 212 | })().catch(err => { 213 | console.log(err); 214 | process.exit(1); 215 | }); 216 | -------------------------------------------------------------------------------- /src/lib/cli/resolveStatsConfig.ts: -------------------------------------------------------------------------------- 1 | import { 2 | hasProp, 3 | isFunction, 4 | isNull, 5 | isObjectOf, 6 | isRegexp, 7 | isString, 8 | objectKeys, 9 | objectValues, 10 | StringKeys, 11 | } from "../guards"; 12 | import { ResolvedStatsConfig, StatsConfig } from "./sharedTypes"; 13 | 14 | const isIgnoredFileKey: StringKeys = "isIgnoredFile"; 15 | const hasIsIgnoredFile = hasProp(isIgnoredFileKey); 16 | 17 | const isTargetModuleOrPathKey: StringKeys = "isTargetModuleOrPath"; 18 | const hasIsTargetModuleOrPath = hasProp(isTargetModuleOrPathKey); 19 | 20 | const isTargetImportKey: StringKeys = "isTargetImport"; 21 | const hasIsTargetImport = hasProp(isTargetImportKey); 22 | export const defaultIsTargetImport: ResolvedStatsConfig["isTargetImport"] = imp => { 23 | if (imp.type !== "esm-named") { return true; } 24 | 25 | const namedIdentifierText = imp.identifier.getText(); 26 | 27 | const firstChar = namedIdentifierText[0]; 28 | if (firstChar && firstChar.toLowerCase() === firstChar) { return false; } // Exclude named imports starting with a lowercase — not a component 29 | if (namedIdentifierText.toUpperCase() === namedIdentifierText) { return false; } // Exclude all-caps named imports, likely a constant 30 | 31 | return true; 32 | }; 33 | 34 | const isValidUsageKey: StringKeys = "isValidUsage"; 35 | const hasIsValidUsage = hasProp(isValidUsageKey); 36 | 37 | const subprojectPathKey: StringKeys = "subprojectPath"; 38 | const hasSubprojectPath = hasProp(subprojectPathKey); 39 | export const defaultSubprojectPath = "/"; 40 | 41 | const tsconfigPathKey: StringKeys = "tsconfigPath"; 42 | const hasTsconfigPath = hasProp(tsconfigPathKey); 43 | 44 | const jsconfigPathKey: StringKeys = "jsconfigPath"; 45 | const hasJsconfigPath = hasProp(jsconfigPathKey); 46 | 47 | const domReferenceFactoriesKey: StringKeys = "domReferenceFactories"; 48 | const hasDomReferenceFactories = hasProp(domReferenceFactoriesKey); 49 | 50 | const isStringRegexRecord = isObjectOf(isString, isRegexp); 51 | 52 | export const defaultDomReferenceFactories = { 53 | "styled-components": /styled-components/, 54 | "stitches": /^@stitches/, 55 | "emotion": /^@emotion\/styled/, 56 | }; 57 | 58 | export const defaultIgnoreFileRe = /((\.(tests?|specs?|stories|story)\.)|(\/(tests?|specs?|stories|story)\/)|(\/node_modules\/)|(\/__mocks__\/)|(\.d\.ts$))/; 59 | export const resolveStatsConfig = (config: StatsConfig | unknown): ResolvedStatsConfig => { 60 | const subprojectPath = hasSubprojectPath(config) && config.subprojectPath ? config.subprojectPath : defaultSubprojectPath; 61 | if (!isString(subprojectPath)) { throw new Error(`Expected a string subproject path, got: ${ subprojectPath }`); } 62 | 63 | const tsconfigPath = hasTsconfigPath(config) ? config.tsconfigPath : null; 64 | if (!isString(tsconfigPath) && !isNull(tsconfigPath)) { throw new Error(`Expected a string | null tsconfigPath, got: ${ tsconfigPath }`); } 65 | 66 | const jsconfigPath = hasJsconfigPath(config) ? config.jsconfigPath : null; 67 | if (!isString(jsconfigPath) && !isNull(jsconfigPath)) { throw new Error(`Expected a string | null jsconfigPath, got: ${ jsconfigPath }`); } 68 | 69 | const isIgnoredFile = hasIsIgnoredFile(config) && config.isIgnoredFile ? config.isIgnoredFile : defaultIgnoreFileRe; 70 | if (!isRegexp(isIgnoredFile)) { throw new Error(`Expected a regexp isIgnoredFile, got: ${ isIgnoredFile }`); } 71 | 72 | if (!hasIsTargetModuleOrPath(config)) { throw new Error("Expected the config to specify isTargetModuleOrPath regexp or set"); } 73 | const isTargetModuleOrPath = config.isTargetModuleOrPath; 74 | if (!isRegexp(isTargetModuleOrPath) && !isStringRegexRecord(isTargetModuleOrPath)) { 75 | throw new Error(`Expected a regexp or a set of regexp isTargetModuleOrPath, got: ${ isTargetModuleOrPath }`); 76 | } 77 | if (!isRegexp(isTargetModuleOrPath)) { 78 | if (objectValues(isTargetModuleOrPath).length === 0) { 79 | throw new Error("Expected a set of regexp in isTargetModuleOrPath to have at least one entry"); 80 | } 81 | if (objectKeys(isTargetModuleOrPath).some(k => k === "homebrew")) { 82 | throw new Error("isTargetModuleOrPath set contains a 'homebrew' key. Homebrew is a reserved target."); 83 | } 84 | } 85 | 86 | const isTargetImport = hasIsTargetImport(config) && config.isTargetImport ? config.isTargetImport : defaultIsTargetImport; 87 | if (!isFunction(isTargetImport)) { throw new Error(`Expected isTargetImport to be a filter function if given, got: ${ isTargetImport }`); } 88 | 89 | const isValidUsage = hasIsValidUsage(config) && config.isValidUsage ? config.isValidUsage : () => true; 90 | if (!isFunction(isValidUsage)) { throw new Error(`Expected isTargetImport to be a filter function if given, got: ${ isTargetImport }`); } 91 | 92 | const domReferenceFactories = hasDomReferenceFactories(config) && config.domReferenceFactories ? config.domReferenceFactories : defaultDomReferenceFactories; 93 | if (!isStringRegexRecord(domReferenceFactories)) { 94 | throw new Error(`Expected a set of regexps in domReferenceFactories, got: ${ domReferenceFactories }`); 95 | } 96 | 97 | return { 98 | subprojectPath, 99 | tsconfigPath, 100 | jsconfigPath, 101 | isIgnoredFile, 102 | isTargetModuleOrPath, 103 | isTargetImport: isTargetImport as ResolvedStatsConfig["isTargetImport"], 104 | isValidUsage: isValidUsage as ResolvedStatsConfig["isValidUsage"], 105 | domReferenceFactories, 106 | }; 107 | }; 108 | -------------------------------------------------------------------------------- /src/lib/cli/sharedTypes.ts: -------------------------------------------------------------------------------- 1 | import { Merge } from "ts-toolbelt/out/Union/Merge"; 2 | import { Import } from "../resolveDependencies/identifyImports"; 3 | import { Usage } from "../findUsages/findUsages"; 4 | 5 | type Target = string; 6 | export type MultiTargetModuleOrPath = { [targetName: Target]: RegExp }; 7 | 8 | type StatsConfigBase = { 9 | isIgnoredFile?: RegExp, 10 | isTargetModuleOrPath: RegExp | MultiTargetModuleOrPath, 11 | isTargetImport?: (imp: Import) => boolean, 12 | isValidUsage?: (use: Usage & { source: "homebrew" | Target }) => boolean, 13 | subprojectPath?: string, 14 | domReferenceFactories?: Record, 15 | }; 16 | 17 | type ExclusiveConfigPaths = { tsconfigPath?: null, jsconfigPath?: null } 18 | | { tsconfigPath: string } 19 | | { jsconfigPath: string }; 20 | 21 | export type StatsConfig = StatsConfigBase & ExclusiveConfigPaths; 22 | export type ResolvedStatsConfig = Required>; 23 | 24 | 25 | export type UsageStat = { 26 | source: "homebrew" | Target, 27 | homebrew_detection_reason?: string, // Only specified when source is 'homebrew' 28 | 29 | component_name: string, 30 | 31 | imported_from: string, 32 | target_node_file: string, 33 | usage_file: string, 34 | 35 | // author: string, // TODO: implement 36 | }; 37 | -------------------------------------------------------------------------------- /src/lib/cli/test/react_todoapp.js: -------------------------------------------------------------------------------- 1 | // React TodoApp from https://github.com/tastejs/todomvc/blob/4e301c7014093505dcf6678c8f97a5e8dee2d250/examples/react-hooks 2 | module.exports = [{ 3 | repoUrl: "https://github.com/tastejs/todomvc", 4 | subprojectPath: "examples/react", 5 | isTargetModuleOrPath: /react-router-dom/, // Pretend react-router is a lib we want to track 6 | maxWeeks: 1, 7 | }]; 8 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/concurrentQueue.spec.ts: -------------------------------------------------------------------------------- 1 | import { concurrentQueue } from "./concurrentQueue"; 2 | 3 | describe("Concurrent Queue", () => { 4 | const deferred = (): { promise: Promise, res: () => void } => { 5 | let res: null | (() => void) = null; 6 | const promise = new Promise(r => { res = r; }); 7 | 8 | if (!res) { throw new Error("Implementation error: could not grab resolve function"); } 9 | return { promise, res }; 10 | }; 11 | 12 | it("should return results in order of task definition, even if tasks complete out of sequence", async () => { 13 | const range = [...Array(10).keys()]; 14 | const tasks = range.map(i => () => new Promise(r => setTimeout( 15 | () => r(i), 16 | Math.round(Math.random()) + 1, // Resolve in 1 or 2ms 17 | ))); 18 | const res = await concurrentQueue(Infinity, tasks, x => x()); 19 | expect(res).toEqual(range); 20 | }); 21 | 22 | it("should limit the number of tasks running concurrently", async () => { 23 | const range = [...Array(5).keys()]; 24 | const deferreds = range.map(deferred); 25 | const tasks = range.map(i => jest.fn().mockImplementation(() => { 26 | const d = deferreds[i]; 27 | if (!d) { throw new Error("Ranges mismatch"); } 28 | return d.promise; 29 | })); 30 | 31 | const res = concurrentQueue(2, tasks, x => x()); 32 | 33 | expect(tasks[0]).toHaveBeenCalled(); 34 | expect(tasks[1]).toHaveBeenCalled(); 35 | expect(tasks[2]).not.toHaveBeenCalled(); 36 | expect(tasks[3]).not.toHaveBeenCalled(); 37 | expect(tasks[4]).not.toHaveBeenCalled(); 38 | 39 | deferreds[1]?.res(); // Resolve 2nd task 40 | await deferreds[1]?.promise; 41 | expect(tasks[0]).toHaveBeenCalled(); 42 | expect(tasks[1]).toHaveBeenCalled(); 43 | expect(tasks[2]).toHaveBeenCalled(); 44 | expect(tasks[3]).not.toHaveBeenCalled(); 45 | expect(tasks[4]).not.toHaveBeenCalled(); 46 | 47 | deferreds[2]?.res(); // Resolve 3rd task 48 | await deferreds[2]?.promise; 49 | expect(tasks[0]).toHaveBeenCalled(); 50 | expect(tasks[1]).toHaveBeenCalled(); 51 | expect(tasks[2]).toHaveBeenCalled(); 52 | expect(tasks[3]).toHaveBeenCalled(); 53 | expect(tasks[4]).not.toHaveBeenCalled(); 54 | 55 | deferreds[0]?.res(); // Resolve 1st task 56 | await deferreds[0]?.promise; 57 | expect(tasks[0]).toHaveBeenCalled(); 58 | expect(tasks[1]).toHaveBeenCalled(); 59 | expect(tasks[2]).toHaveBeenCalled(); 60 | expect(tasks[3]).toHaveBeenCalled(); 61 | expect(tasks[4]).toHaveBeenCalled(); 62 | 63 | deferreds.slice(3).forEach(x => x.res()); // Resolve remaining deferreds 64 | await res; // Handle error if any 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/concurrentQueue.ts: -------------------------------------------------------------------------------- 1 | export const concurrentQueue = (limit: number, items: ReadonlyArray, process: (val: T) => Promise): Promise => { 2 | function* init() { 3 | for (const item of items) { 4 | yield process(item); 5 | } 6 | } 7 | 8 | const iterator = init(); 9 | 10 | let inflight = 0; 11 | let processed = 0; 12 | const results: R[] = []; 13 | const resume = async (): Promise => { 14 | const batch = []; 15 | while (inflight < limit) { 16 | const itemIdx = processed; 17 | processed += 1; 18 | inflight += 1; 19 | 20 | const step = iterator.next(); 21 | if (step.done) { break; } 22 | 23 | batch.push(step.value.then(x => { 24 | results[itemIdx] = x; // Save preserving order 25 | inflight -= 1; 26 | return resume(); 27 | })); 28 | } 29 | 30 | await Promise.all(batch); 31 | }; 32 | 33 | return resume().then(() => results); 34 | }; 35 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/dates.ts: -------------------------------------------------------------------------------- 1 | export function midnightToday() { 2 | return new Date(new Date().toISOString().split("T")[0] + "T00:00:00.000Z"); 3 | } 4 | 5 | /** 6 | * Create a date several weeks ago aligned to a weekly grid — 7 | * so that dates land on the same day of the week. 8 | * @param weeksAgo 9 | */ 10 | export function dateWeeksAgo(weeksAgo: number) { 11 | // Today at 00:00 12 | const d = midnightToday(); 13 | 14 | // Set to preceding Saturday N weeks ago — aligns all commits on a weekly grid 15 | d.setDate(d.getDate() - 7 * weeksAgo - (d.getDay() + 1) % 7); 16 | 17 | return d; 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/getTimelineForOneRepo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CommitData, 3 | JsonOf, 4 | PostMessageOf, 5 | ResolvedWorkerConfig, 6 | Stats, 7 | WorkerPayload, 8 | WorkerResponse, 9 | } from "./workerTypes"; 10 | import { GitAPI } from "./git"; 11 | import { cacheFileName } from "../util/cache"; 12 | import { setupWorkerPool } from "./workerPool"; 13 | import { unexpected } from "../../guards"; 14 | import { UsageStat } from "../sharedTypes"; 15 | import { dateWeeksAgo, midnightToday } from "./dates"; 16 | import { isSubprojectPathEmptyWarning, Warning } from "../collectStats"; 17 | 18 | export const getTimelineForOneRepo = async ( 19 | cacheDir: string, 20 | config: ResolvedWorkerConfig, 21 | workerPool: ReturnType["pool"], 22 | git: GitAPI, 23 | ): Promise => { 24 | console.log(`Fetching ${ config.repoUrl }`); 25 | await git.cloneOrUpdate(config.repoUrl, config.since); 26 | 27 | const timeline = await getCommitsTimeline(config.since, git.listCommits); 28 | console.log(`Processing the timeline of ${ timeline.length } commits`); 29 | console.log(`Cache id ${ cacheFileName(config) }`); 30 | 31 | const uniqueCommits = timeline.map(({ oid }) => oid).reduce((uniq, commit) => { 32 | if (uniq.length === 0) { return [commit]; } 33 | if (uniq[0] === commit) { return uniq; } 34 | return [commit, ...uniq]; 35 | }, [] as string[]); 36 | 37 | const commitStats: { commit: string, stats: UsageStat[], warnings: Warning[] }[] = await Promise.all(uniqueCommits.reverse().map(async commit => { 38 | const payload: PostMessageOf = { 39 | commit, 40 | cacheDir, 41 | config: { 42 | ...config, 43 | isTargetImport: config.isTargetImport.toString(), 44 | isValidUsage: config.isValidUsage.toString(), 45 | }, 46 | }; 47 | 48 | const resp: PostMessageOf | PostMessageOf> = await workerPool.exec(payload); 49 | if (resp.status === "error") { throw resp.error; } 50 | if (resp.status === "result") { return { commit, stats: resp.result, warnings: resp.warnings }; } 51 | return unexpected(resp); 52 | })); 53 | 54 | const statsByCommit = new Map(); 55 | commitStats.forEach(({ commit, stats, warnings }) => statsByCommit.set(commit, { stats, warnings })); 56 | 57 | if (commitStats.every(stat => stat.warnings.some(isSubprojectPathEmptyWarning))) { 58 | // In every commit there is a warning about a missing subproject path — 59 | // that path is missing through entire checked history, and is certainly a configuration mistake. 60 | throw new Error(`Subproject path '${ config.subprojectPath }' missing in all analyzed commits`); 61 | } 62 | 63 | return timeline.map(commitData => { 64 | const stats = statsByCommit.get(commitData.oid); 65 | if (!stats) { throw new Error(`No stats found for commit ${ commitData.oid }`); } 66 | 67 | return { 68 | commit: commitData, 69 | ...stats, 70 | }; 71 | }); 72 | }; 73 | 74 | async function getCommitsTimeline(since: Date, listCommits: GitAPI["listCommits"]): Promise { 75 | const commitData = await listCommits(); 76 | 77 | const first = commitData.shift(); 78 | if (!first) { throw new Error("No commits"); } 79 | 80 | const targetCommits: CommitData[] = [{ ...first, weeksAgo: 0, expectedDate: midnightToday() }]; 81 | while (commitData.length) { 82 | const expectedDate = dateWeeksAgo(targetCommits.length); 83 | if (expectedDate < since) { break; } 84 | 85 | const prev = targetCommits[targetCommits.length - 1]; 86 | if (!prev) { throw new Error("Implementation error"); } 87 | 88 | if (prev.ts.getTime() < expectedDate.getTime()) { 89 | targetCommits.push({ ...prev, weeksAgo: targetCommits.length, expectedDate }); 90 | continue; 91 | } 92 | 93 | const c = commitData.shift(); 94 | if (!c) { throw new Error("Implementation error"); } 95 | 96 | if (c.ts.getTime() < expectedDate.getTime()) { 97 | targetCommits.push({ ...c, weeksAgo: targetCommits.length, expectedDate }); 98 | } 99 | } 100 | 101 | return targetCommits; 102 | } 103 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/git.spec.ts: -------------------------------------------------------------------------------- 1 | import { GitAPI, setupGitAPI } from "./git"; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | type MockedFn any> = jest.Mock, Parameters>; 5 | 6 | describe("Git API", () => { 7 | let exec: MockedFn[0]>; 8 | let fileExists: MockedFn[1]>; 9 | let projectPath: string; 10 | let git: GitAPI; 11 | 12 | beforeEach(() => { 13 | exec = jest.fn, Parameters>().mockResolvedValue({ stdout: "" }); 14 | fileExists = jest.fn, Parameters>().mockReturnValue(false); 15 | projectPath = "./path/to/project/directory"; 16 | git = setupGitAPI(exec, fileExists)(projectPath); 17 | }); 18 | 19 | describe("listCommits", () => { 20 | let hash: string; 21 | let date: string; 22 | beforeEach(() => { 23 | hash = "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF"; 24 | date = "2021-03-13"; 25 | exec.mockResolvedValue({ stdout: `${ hash } ${ date }` }); 26 | }); 27 | 28 | it("should call git log with a custom format", async () => { 29 | await git.listCommits(); 30 | const firstCallArgs = exec.mock.calls[0]; 31 | if (!firstCallArgs) { throw new Error("Expected a git log to be called at least once"); } 32 | expect(firstCallArgs[0]).toContain("log --pretty=format:\"%H %as\""); 33 | }); 34 | 35 | it("should return the parsed commit data", async () => { 36 | expect(await git.listCommits()).toEqual([{ 37 | ts: new Date(date), 38 | oid: hash, 39 | }]); 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/git.ts: -------------------------------------------------------------------------------- 1 | import { promisify } from "util"; 2 | import { exec as execWithCB, spawnSync } from "child_process"; 3 | import { join } from "path"; 4 | import { ResolvedWorkerConfig } from "./workerTypes"; 5 | import { cacheFileName, repoDirPath } from "../util/cache"; 6 | import { statSync } from "fs"; 7 | 8 | const maxBuffer = 1024 * 1024 * 1024; // ~1GB 9 | 10 | const formatDate = (val: Date): string => { 11 | const datePart = val.toISOString().split("T")[0]; 12 | if (!datePart) { throw new Error(`No date part found in ${ val.toISOString() }`); } 13 | return datePart; 14 | }; 15 | 16 | export const gitExists = () => spawnSync("git", ["--version"], { maxBuffer }).status === 0; 17 | export const getProjectPath = (cacheDir: string, config: ResolvedWorkerConfig) => join(repoDirPath(cacheDir), cacheFileName(config)); 18 | 19 | const gitCommand = (projectPath: string, command: string) => `git --git-dir=${ join(projectPath, ".git") } --work-tree=${ projectPath } ${ command }`; 20 | 21 | export type GitAPI = { 22 | listCommits: () => Promise<{ ts: Date, oid: string }[]>, 23 | cloneOrUpdate: (cloneUrl: string, since: Date) => Promise, 24 | cloneNoCheckout: (destination: string) => Promise, 25 | checkout: (ref: string) => Promise, 26 | }; 27 | 28 | export const setupGitAPI = ( 29 | exec: (command: string, opts?: { env?: NodeJS.ProcessEnv | undefined } | undefined) => Promise<{ stdout: string }>, 30 | fileExists: (path: string) => boolean, 31 | ): (projectPath: string) => GitAPI => { 32 | const gitExec = (command: string) => exec(command, { 33 | env: { 34 | ...process.env, 35 | 36 | // Disable terminal prompt, so that git fails when credentials are required. 37 | // Tracker is non-interactive, so getting stuck on a prompt is not a helpful behaviour. 38 | GIT_TERMINAL_PROMPT: "false", 39 | }, 40 | }); 41 | const getAPI = (projectPath: string): GitAPI => { 42 | return { 43 | listCommits, 44 | cloneOrUpdate, 45 | cloneNoCheckout, 46 | checkout, 47 | }; 48 | 49 | async function cloneOrUpdate(cloneUrl: string, since: Date) { 50 | const retryOnShallowInfoProcessingError = async (commandAndOpts: string, pathParams = "") => { 51 | try { 52 | await gitExec(gitCommand(projectPath, `${ commandAndOpts } --shallow-since=${ formatDate(since) } ${ pathParams }`)); 53 | } catch (_) { 54 | // If git fails with `--shallow-since` flag, assume it's due to shallow copying and retry without it. 55 | await gitExec(gitCommand(projectPath, `${ commandAndOpts } ${ pathParams }`)); 56 | } 57 | }; 58 | 59 | const dotGitPath = join(projectPath, ".git"); 60 | if (fileExists(dotGitPath)) { 61 | // Don't clone if already exists 62 | await gitExec(gitCommand(projectPath, "repack -d")); 63 | await retryOnShallowInfoProcessingError("fetch -q"); 64 | } else { 65 | // Clone just the main branch 66 | await retryOnShallowInfoProcessingError("clone -q --no-tags --single-branch --no-checkout", `${ cloneUrl } ${ dotGitPath }`); 67 | } 68 | } 69 | 70 | async function listCommits() { 71 | const { stdout } = await gitExec(gitCommand(projectPath, "log --pretty=format:\"%H %as\"")); 72 | return stdout 73 | .split("\n") 74 | .map(line => { 75 | const [commit, date] = line.split(" "); 76 | if (!commit || !date) { throw new Error(`Unexpected line format, expected commit hash & date, got: ${ line }`); } 77 | 78 | const ts = new Date(date); 79 | return { ts, oid: commit }; 80 | }) 81 | .sort((a, b) => b.ts.getTime() - a.ts.getTime()); 82 | } 83 | 84 | async function cloneNoCheckout(destination: string) { 85 | await gitExec(`git clone --no-checkout ${ projectPath } ${ destination }`); 86 | return getAPI(destination); 87 | } 88 | 89 | async function checkout(ref: string) { 90 | await gitExec(gitCommand(projectPath, `checkout --force ${ ref }`)); 91 | } 92 | }; 93 | 94 | return getAPI; 95 | }; 96 | 97 | 98 | const exec = promisify(execWithCB); 99 | export const getGit = setupGitAPI( 100 | command => exec(command, { maxBuffer, encoding: "utf8" }), 101 | path => Boolean(statSync(path, { throwIfNoEntry: false })), 102 | ); 103 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/index.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedWorkerConfig, WorkerConfig } from "./workerTypes"; 2 | import { setupWorkerPool } from "./workerPool"; 3 | import { getTimelineForOneRepo } from "./getTimelineForOneRepo"; 4 | import { processStats, ProjectMetadata, statsMessage } from "../processStats"; 5 | import { mkdirSync } from "fs"; 6 | import { isAbsolute, join, resolve } from "path"; 7 | import { defineYargsModule } from "../util/defineYargsModule"; 8 | import { getGit, getProjectPath, gitExists } from "./git"; 9 | import { hasProp, isDate, isNumber, isString, StringKeys } from "../../guards"; 10 | import { defaultSubprojectPath, resolveStatsConfig } from "../resolveStatsConfig"; 11 | import { concurrentQueue } from "./concurrentQueue"; 12 | import { dateWeeksAgo } from "./dates"; 13 | import { writeGzippedOutput } from "../util/gzip"; 14 | 15 | 16 | const repoUrlKey: StringKeys = "repoUrl"; 17 | const hasRepoUrl = hasProp(repoUrlKey); 18 | 19 | const displayNameKey: StringKeys = "displayName"; 20 | const hasDisplayName = hasProp(displayNameKey); 21 | 22 | const maxWeeksKey: StringKeys = "maxWeeks"; 23 | const hasMaxWeeks = hasProp(maxWeeksKey); 24 | 25 | const sinceKey: StringKeys = "since"; 26 | const hasSince = hasProp(sinceKey); 27 | 28 | const resolveConfig = (config: unknown): ResolvedWorkerConfig => { 29 | const statsConfig = resolveStatsConfig(config); 30 | 31 | if (!hasRepoUrl(config)) { throw new Error(`Config has no repo url: ${ JSON.stringify(config) }`); } 32 | const repoUrl = config.repoUrl; 33 | if (!repoUrl || !isString(repoUrl)) { throw new Error(`Expected a string repo URL, got: ${ repoUrl }`); } 34 | 35 | if (!hasRepoUrl(config)) { throw new Error(`Config has no repo url: ${ JSON.stringify(config) }`); } 36 | const defaultDisplayName = statsConfig.subprojectPath === defaultSubprojectPath ? config.repoUrl : `${ config.repoUrl } at ${ statsConfig.subprojectPath }`; 37 | const displayName = (hasDisplayName(config) ? config.displayName : defaultDisplayName) ?? defaultDisplayName; 38 | if (!displayName || !isString(displayName)) { throw new Error(`Expected a string project display name if given, got: ${ displayName }`); } 39 | 40 | 41 | let since: Date | null = null; 42 | if (hasMaxWeeks(config) && hasSince(config)) { throw new Error(`Config specifies both 'since' and 'maxWeeks' keys: ${ JSON.stringify(config) }`); } 43 | if (hasMaxWeeks(config)) { 44 | const maxWeeks = config.maxWeeks; 45 | if (!maxWeeks || !isNumber(maxWeeks)) { throw new Error(`Expected 'maxWeeks' to be a number, got: ${ maxWeeks }`); } 46 | if (maxWeeks < 1) { throw new Error(`Expected 'maxWeeks' to be a positive number, got: ${ maxWeeks }`); } 47 | since = dateWeeksAgo(maxWeeks); // Backwards compatible conversion from `maxWeeks` to explicit `since` 48 | } 49 | 50 | if (hasSince(config)) { 51 | const configSince = config.since; 52 | if (!configSince || !isDate(configSince)) { throw new Error(`Expected 'since' to be a Date, got: ${ configSince }`); } 53 | if (configSince > new Date()) { throw new Error(`Expected 'since' to be in the past, got: ${ configSince }`); } 54 | since = configSince; 55 | } 56 | 57 | if (!since) { throw new Error(`Config must specify either 'maxWeeks' or 'since' keys: ${ JSON.stringify(config) }`); } 58 | 59 | return { 60 | repoUrl, 61 | displayName, 62 | since, 63 | ...statsConfig, 64 | }; 65 | }; 66 | 67 | const hasDefault = hasProp("default"); 68 | export default defineYargsModule( 69 | "timelines ", 70 | "Collect timelines of stats from set of projects", 71 | args => args 72 | .positional("config", { 73 | type: "string", 74 | normalize: true, 75 | demandOption: true, 76 | }) 77 | .options("cacheDir", { 78 | type: "string", 79 | normalize: true, 80 | }) 81 | .options("outfile", { 82 | type: "string", 83 | normalize: true, 84 | }), 85 | async args => { 86 | if (!gitExists()) { throw new Error("Git seems to not be runnable. Make sure `git` is available on your system."); } 87 | 88 | // Make sure cache dir exists and is a directory 89 | const cacheDir = resolve(args.cacheDir ?? join(process.cwd(), "radius-tracker-cache")); 90 | mkdirSync(join(cacheDir, "cache"), { recursive: true }); 91 | 92 | // eslint-disable-next-line @typescript-eslint/no-var-requires 93 | const configFile = require(isAbsolute(args.config) ? args.config : join(process.cwd(), args.config)); // Load the config 94 | 95 | const configs = hasDefault(configFile) ? configFile.default : configFile; 96 | if (!Array.isArray(configs)) { throw new Error(`Expected an array of configs, got: ${ JSON.stringify(configs) }`); } 97 | 98 | await collectAllStats(cacheDir, args.outfile || join(process.cwd(), "usages.sqlite.gz"), configs.map(resolveConfig), getGit); 99 | }, 100 | ); 101 | 102 | async function collectAllStats(cacheDir: string, outfile: string, configs: ReadonlyArray, getGitApi: typeof getGit) { 103 | const { pool, size: poolSize } = setupWorkerPool(); 104 | 105 | try { 106 | const concurrencyLimit = 2 * poolSize; // Proportional to pool size, but oversized on purpose to avoid waiting on network 107 | const stats = await concurrentQueue(concurrencyLimit, configs, config => { 108 | const git = getGitApi(getProjectPath(cacheDir, config)); 109 | return getTimelineForOneRepo(cacheDir, config, pool, git); 110 | }); 111 | 112 | const statsDB = await processStats(stats.map((stat, idx) => { 113 | const config = configs[idx]; 114 | if (!config) { throw new Error(`Could not find a config at idx '${ idx }'`); } 115 | 116 | const project: ProjectMetadata = { 117 | name: config.displayName, 118 | url: config.repoUrl, 119 | subprojectPath: config.subprojectPath, 120 | }; 121 | return { project, config, stats: stat }; 122 | })); 123 | 124 | console.log("Writing the output to disk"); 125 | await writeGzippedOutput(Buffer.from(statsDB.export()), outfile); 126 | 127 | console.log(statsMessage(outfile)); 128 | } finally { 129 | await pool.destroy(); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/statsWorker.ts: -------------------------------------------------------------------------------- 1 | import { parentPort as parentPortImport, threadId } from "worker_threads"; 2 | import { rmSync, statSync, writeFileSync } from "fs"; 3 | 4 | import { collectStats } from "../collectStats"; 5 | import { 6 | PostMessageOf, 7 | WorkerFailureResponse, 8 | WorkerPayload, 9 | WorkerSuccessResponse, 10 | } from "./workerTypes"; 11 | import { performance } from "perf_hooks"; 12 | import { join } from "path"; 13 | import { cacheDirPath, cacheFileName, threadSpaceDirPath } from "../util/cache"; 14 | import { getGit, getProjectPath } from "./git"; 15 | import { Project } from "ts-morph"; 16 | 17 | const parentPort = parentPortImport; 18 | if (!parentPort) { throw new Error("Parent port not available, code not running as a worker"); } 19 | 20 | const hydrateFunction = (fn: string) => new Function(`"use strict"; return (${ fn })`)(); 21 | parentPort.on("message", configParam => { 22 | const { commit, config: rawConfig, cacheDir }: PostMessageOf = configParam; 23 | 24 | const config: WorkerPayload["config"] = { 25 | ...rawConfig, 26 | isTargetImport: hydrateFunction(rawConfig.isTargetImport), 27 | isValidUsage: hydrateFunction(rawConfig.isValidUsage), 28 | }; 29 | 30 | let prev: number | null = null; 31 | const tag = (keepTimestamp = false) => { 32 | const ts = performance.now(); 33 | const tg = `[Thread ${ threadId } - ${ config.displayName } - ${ Math.floor(prev ? (ts - prev) / 1000 : 0) }s]`; 34 | if (!keepTimestamp) { prev = ts; } 35 | return tg; 36 | }; 37 | 38 | let heartbeatInterval: NodeJS.Timer; 39 | (async () => { 40 | const sourceRepo = getGit(getProjectPath(cacheDir, config)); 41 | 42 | const commitCache = join(cacheDirPath(cacheDir), `commit_${ cacheFileName(config) }_${ commit }.json`); 43 | if (statSync(commitCache, { throwIfNoEntry: false })?.isFile()) { 44 | console.log(`${ tag() } Using cached stats for commit ${ commit }`); 45 | const cachedStats = require(commitCache); // eslint-disable-line @typescript-eslint/no-var-requires 46 | 47 | if (!cachedStats || !Array.isArray(cachedStats.stats) || !Array.isArray(cachedStats.warnings)) { 48 | throw new Error("Unexpected cache format: " + JSON.stringify(cachedStats)); 49 | } 50 | const success: WorkerSuccessResponse = { 51 | status: "result", 52 | result: cachedStats.stats, 53 | warnings: cachedStats.warnings, 54 | }; 55 | return parentPort.postMessage(success); 56 | } 57 | 58 | const threadSpacePath = threadSpaceDirPath(cacheDir); 59 | 60 | console.log(`${ tag() } Collecting stats for commit ${ commit }`); 61 | rmSync(threadSpacePath, { force: true, recursive: true, maxRetries: 10 }); 62 | 63 | console.log(`${ tag() } Cloning from ${ getProjectPath(cacheDir, config) }`); 64 | const threadspaceRepo = await sourceRepo.cloneNoCheckout(threadSpacePath); 65 | 66 | console.log(`${ tag() } Checking out commit ${ commit }`); 67 | await threadspaceRepo.checkout(commit); 68 | 69 | heartbeatInterval = setInterval(() => console.log(`${ tag(true) } still running`), 60000); 70 | const stats = await collectStats( 71 | new Project().getFileSystem(), // Provide the disk filesystem 72 | config, 73 | (message: string) => console.log(`${ tag() } ${ message }`), 74 | threadSpacePath, 75 | ); 76 | clearInterval(heartbeatInterval); 77 | 78 | writeFileSync(commitCache, JSON.stringify(stats), "utf8"); 79 | const success: WorkerSuccessResponse = { 80 | status: "result", 81 | result: stats.stats, 82 | warnings: stats.warnings, 83 | }; 84 | parentPort.postMessage(success); 85 | })().catch(err => { 86 | clearInterval(heartbeatInterval); 87 | const failure: WorkerFailureResponse = { status: "error", error: err }; 88 | parentPort.postMessage(failure); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/workerPool.ts: -------------------------------------------------------------------------------- 1 | import { cpus, totalmem } from "os"; 2 | import { StaticPool } from "node-worker-threads-pool"; 3 | import { join } from "path"; 4 | import { statSync } from "fs"; 5 | 6 | export const setupWorkerPool = () => { 7 | const twoGigInBytes = 2 * 1024 * 1024 * 1024; 8 | 9 | const allocatableMemory = totalmem() * 0.9; // Leave 10% memory for OS tasks. 10 | const desiredMemory = Math.min(allocatableMemory, twoGigInBytes); 11 | const numWorkers = Math.max( 12 | Math.min( 13 | cpus().length - 1, // All CPUs but one 14 | Math.floor(allocatableMemory / desiredMemory), // However many workers fit in memory with desired allocation 15 | ), 16 | 1, 17 | ); 18 | console.log(`Creating a worker pool with ${ numWorkers } workers`); 19 | 20 | // Select worker based on availability of .ts source 21 | // In dev select the .ts, otherwise we're in build so use .js 22 | const workerFileBase = join(__dirname, "statsWorker"); 23 | const workerFile = workerFileBase + (statSync(workerFileBase + ".ts", { throwIfNoEntry: false })?.isFile() ? ".ts" : ".js"); 24 | 25 | return { 26 | pool: new StaticPool({ 27 | size: numWorkers, 28 | task: workerFile, 29 | resourceLimits: { maxOldGenerationSizeMb: desiredMemory / 1024 }, 30 | }), 31 | size: numWorkers, 32 | }; 33 | }; 34 | 35 | -------------------------------------------------------------------------------- /src/lib/cli/timelines/workerTypes.ts: -------------------------------------------------------------------------------- 1 | import { Primitive } from "ts-toolbelt/out/Misc/Primitive"; 2 | import { StatsConfig, UsageStat } from "../sharedTypes"; 3 | import { Merge } from "ts-toolbelt/out/Union/Merge"; 4 | import { Warning } from "../collectStats"; 5 | 6 | const maxWeeksKey = "maxWeeks"; 7 | type DurationConfig = { [maxWeeksKey]: number } | { since: Date }; 8 | export type WorkerConfig = StatsConfig & DurationConfig & { 9 | repoUrl: string, 10 | displayName?: string, 11 | }; 12 | export type ResolvedWorkerConfig = Omit>, typeof maxWeeksKey>; 13 | 14 | export type WorkerSuccessResponse = { status: "result", result: UsageStat[], warnings: Warning[] }; 15 | export type WorkerFailureResponse = { status: "error", error: unknown }; 16 | export type WorkerResponse = WorkerSuccessResponse | WorkerFailureResponse; 17 | export type WorkerPayload = { config: ResolvedWorkerConfig, commit: string, cacheDir: string }; 18 | 19 | 20 | type Fn = (...args: any[]) => unknown; // eslint-disable-line @typescript-eslint/no-explicit-any 21 | 22 | type PostMessagePreservedTypes = RegExp | Date | Blob | ArrayBuffer | NodeJS.TypedArray | Map | Set; 23 | 24 | type SerializeForPostMessage = T extends Fn ? string 25 | : never; 26 | 27 | export type PostMessageOf = T extends Exclude ? T 28 | : T extends symbol ? never 29 | : T extends PostMessagePreservedTypes ? T 30 | : [SerializeForPostMessage] extends [never] ? ( 31 | T extends ArrayLike ? PostMessageOf[] 32 | : { [P in keyof T]: PostMessageOf } 33 | ) 34 | : SerializeForPostMessage; 35 | 36 | export type JsonOf = T extends Exclude ? T 37 | : T extends symbol | undefined ? never 38 | : T extends Fn ? never 39 | : T extends Date ? string 40 | : T extends ArrayLike ? JsonOf[] 41 | : { [P in keyof T]: JsonOf }; 42 | 43 | export type CommitData = { oid: string, ts: Date, weeksAgo: number, expectedDate: Date }; 44 | export type Stats = { commit: CommitData, stats: UsageStat[], warnings: Warning[] }[]; 45 | -------------------------------------------------------------------------------- /src/lib/cli/util/cache.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "crypto"; 2 | import { ResolvedStatsConfig } from "../sharedTypes"; 3 | import { ResolvedWorkerConfig } from "../timelines/workerTypes"; 4 | import { objectKeys, StringKeys } from "../../guards"; 5 | import { join } from "path"; 6 | import { threadId } from "worker_threads"; 7 | import { statSync } from "fs"; 8 | import { cacheVersion } from "./cacheVersion"; 9 | 10 | const md5 = (str: string): string => createHash("md5").update(str).digest("hex"); 11 | const sanitize = (val: string) => val.toLowerCase().replace(/[^a-z0-9]/ig, "_"); 12 | 13 | type CacheConfig = ResolvedStatsConfig & Pick; 14 | const cacheConfigKeys: { [P in StringKeys]-?: null } = { 15 | repoUrl: null, 16 | isIgnoredFile: null, 17 | isTargetModuleOrPath: null, 18 | isTargetImport: null, 19 | isValidUsage: null, 20 | subprojectPath: null, 21 | tsconfigPath: null, 22 | jsconfigPath: null, 23 | domReferenceFactories: null, 24 | }; 25 | 26 | export const cacheFileName = (config: CacheConfig) => { 27 | const configHash = md5(JSON.stringify( 28 | objectKeys(config).filter(k => k in cacheConfigKeys).reduce((_obj, k) => { 29 | (_obj[k] as unknown) = config[k]; 30 | return _obj; 31 | }, {} as CacheConfig), 32 | (_k, v) => 33 | v instanceof RegExp ? `REGEXP:${ v.toString() }` 34 | : typeof v === "function" ? `FUNC:${ v.toString() }` 35 | : v, 36 | )); 37 | 38 | return sanitize(`${ config.repoUrl }_v${ cacheVersion }_${ configHash }`); 39 | }; 40 | 41 | let checkedBase: string | null = null; 42 | const withExistenceCheck = (cb: (base: string) => string) => (base: string) => { 43 | if (checkedBase === base) { return cb(base); } 44 | 45 | statSync(base, { throwIfNoEntry: true }); // Make sure the base exists before using it to construct path 46 | checkedBase = base; 47 | 48 | return cb(base); 49 | }; 50 | 51 | export const cacheDirPath = withExistenceCheck((base: string) => join(base, "cache")); 52 | export const threadSpaceDirPath = withExistenceCheck((base: string) => join(base, "threadspace", `thread_${ threadId }`)); 53 | export const repoDirPath = withExistenceCheck((base: string) => join(base, "repos")); 54 | -------------------------------------------------------------------------------- /src/lib/cli/util/cacheVersion.ts: -------------------------------------------------------------------------------- 1 | export const cacheVersion = 5; 2 | -------------------------------------------------------------------------------- /src/lib/cli/util/defineYargsModule.ts: -------------------------------------------------------------------------------- 1 | import { CommandModule } from "yargs"; 2 | 3 | export const defineYargsModule = ( 4 | command: string, 5 | description: string, 6 | options: CommandModule<{}, U>["builder"], 7 | handler: CommandModule<{}, U>["handler"], 8 | ): CommandModule<{}, U> => { 9 | return { 10 | command, 11 | describe: description, 12 | builder: options, 13 | handler, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/cli/util/gzip.ts: -------------------------------------------------------------------------------- 1 | import { constants, createGzip } from "zlib"; 2 | import { Readable } from "stream"; 3 | import { createWriteStream } from "fs"; 4 | 5 | export const writeGzippedOutput = (data: Buffer, outfile: string) => { 6 | const deflate = createGzip({ level: constants.Z_BEST_COMPRESSION }); 7 | const compressedWriteStream = Readable.from(data) 8 | .pipe(deflate) 9 | .pipe(createWriteStream(outfile, { encoding: "utf8" })); 10 | 11 | return new Promise((res, rej) => { 12 | compressedWriteStream.on("finish", res); 13 | compressedWriteStream.on("error", rej); 14 | }); 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/cli/util/stats.ts: -------------------------------------------------------------------------------- 1 | import { objectKeys } from "../../guards"; 2 | import { UsageStat } from "../sharedTypes"; 3 | import { sep } from "path"; 4 | 5 | export function usageDistributionAcrossFileTree(usages: UsageStat[]): string { 6 | const inc = (agg: { [f: string]: number }, chunk: string) => agg[chunk] = (agg[chunk] ?? 0) + 1; 7 | 8 | const usageCounts = usages 9 | .map(usage => usage.usage_file) 10 | .reduce((_agg, f) => { 11 | const [firstChunk, ...chunks] = f.split(sep); 12 | 13 | if (firstChunk === undefined) { 14 | inc(_agg, "/"); 15 | return _agg; 16 | } 17 | 18 | if (firstChunk) { 19 | inc(_agg, firstChunk); 20 | } 21 | 22 | chunks.reduce((acc, c) => { 23 | const current = `${ acc }/${ c }`; 24 | inc(_agg, current); 25 | return current; 26 | }, firstChunk); 27 | 28 | return _agg; 29 | }, {} as { [f: string]: number }); 30 | 31 | return showTopEntries(usageCounts); 32 | } 33 | 34 | export function componentUsageDistribution(usages: UsageStat[]) { 35 | const usageCounts = usages.reduce((agg, u) => { 36 | const usage = agg[u.component_name] ?? 0; 37 | agg[u.component_name] = usage + 1; 38 | return agg; 39 | }, {} as { [name: string]: number }); 40 | 41 | return showTopEntries(usageCounts); 42 | } 43 | 44 | export function showTopEntries(keyedCounts: { [key: string]: number }): string { 45 | const allKeys = objectKeys(keyedCounts); 46 | const sortedKeys = allKeys 47 | .filter(k => (keyedCounts[k] ?? 0) > (allKeys.length > 10 ? 1 : 0)) 48 | .sort((a, b) => (keyedCounts[b] ?? 0) - (keyedCounts[a] ?? 0)); 49 | 50 | const relevantKeys = sortedKeys.length > 10 ? sortedKeys.slice(0, Math.floor(sortedKeys.length / 5)) : sortedKeys; 51 | 52 | const maxNumSize = relevantKeys.reduce((max, k) => { 53 | const num = keyedCounts[k] ?? 0; 54 | return max > num ? max : num; 55 | }, 0).toString().length; 56 | const pad = (n: number) => { 57 | const str = n.toString(); 58 | const paddingSize = maxNumSize - str.length; 59 | return paddingSize === 0 ? str : `${ " ".repeat(paddingSize) }${ str }`; 60 | }; 61 | return relevantKeys.map(k => `${ pad(keyedCounts[k] ?? 0) } ${ k }`).join("\n"); 62 | } 63 | -------------------------------------------------------------------------------- /src/lib/detectHomebrew/detectHomebrew.spec.ts: -------------------------------------------------------------------------------- 1 | import { detectHomebrew } from "./detectHomebrew"; 2 | import { Project, Node } from "ts-morph"; 3 | import { atLeastOne } from "../guards"; 4 | 5 | describe("detectHomebrew", () => { 6 | let project: Project; 7 | beforeEach(async () => { 8 | project = new Project({ useInMemoryFileSystem: true }); 9 | }); 10 | 11 | it("should detect a homebrew functional component", async () => { 12 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 13 | export function Homebrew() { return
}; 14 | `), {}); 15 | expect(homebrew).toHaveLength(1); 16 | expect(Node.isFunctionDeclaration(atLeastOne(homebrew)[0].declaration)).toBe(true); 17 | expect(Node.isIdentifier(atLeastOne(homebrew)[0].identifier)).toBe(true); 18 | expect(atLeastOne(homebrew)[0].identifier?.getText()).toBe("Homebrew"); 19 | }); 20 | 21 | it("should detect a const with homebrew functional component", async () => { 22 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 23 | const Hmbrw = function Homebrew() { return
}; 24 | `), {}); 25 | expect(homebrew).toHaveLength(1); 26 | expect(Node.isFunctionExpression(atLeastOne(homebrew)[0].declaration)).toBe(true); 27 | expect(Node.isIdentifier(atLeastOne(homebrew)[0].identifier)).toBe(true); 28 | expect(atLeastOne(homebrew)[0].identifier?.getText()).toBe("Hmbrw"); 29 | }); 30 | 31 | it("should detect homebrew arrow function", async () => { 32 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 33 | export const Homebrew = () =>
; 34 | `), {}); 35 | expect(homebrew).toHaveLength(1); 36 | expect(Node.isArrowFunction(atLeastOne(homebrew)[0].declaration)).toBe(true); 37 | expect(Node.isIdentifier(atLeastOne(homebrew)[0].identifier)).toBe(true); 38 | expect(atLeastOne(homebrew)[0].identifier?.getText()).toBe("Homebrew"); 39 | }); 40 | 41 | it("should detect default export of an anonymous function homebrew", async () => { 42 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 43 | export default function () { return
} 44 | `), {}); 45 | expect(homebrew).toHaveLength(1); 46 | expect(Node.isFunctionDeclaration(atLeastOne(homebrew)[0].declaration)).toBe(true); 47 | expect(atLeastOne(homebrew)[0].identifier).toBeUndefined(); 48 | }); 49 | 50 | it("should detect homebrew class definition", async () => { 51 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 52 | export class Homebrew { 53 | render() { 54 | return
; 55 | } 56 | } 57 | `), {}); 58 | expect(homebrew).toHaveLength(1); 59 | expect(Node.isClassDeclaration(atLeastOne(homebrew)[0].declaration)).toBe(true); 60 | expect(Node.isIdentifier(atLeastOne(homebrew)[0].identifier)).toBe(true); 61 | expect(atLeastOne(homebrew)[0].identifier?.getText()).toBe("Homebrew"); 62 | }); 63 | 64 | it("should detect homebrew class expression", async () => { 65 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 66 | export const Homebrew = class { 67 | render() { 68 | return
; 69 | } 70 | } 71 | `), {}); 72 | expect(homebrew).toHaveLength(1); 73 | expect(Node.isClassExpression(atLeastOne(homebrew)[0].declaration)).toBe(true); 74 | expect(Node.isIdentifier(atLeastOne(homebrew)[0].identifier)).toBe(true); 75 | expect(atLeastOne(homebrew)[0].identifier?.getText()).toBe("Homebrew"); 76 | }); 77 | 78 | it("should have no identifier for inline use of a component", async () => { 79 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 80 | higherOrder(() =>
); 81 | `), {}); 82 | expect(homebrew).toHaveLength(1); 83 | expect(Node.isArrowFunction(atLeastOne(homebrew)[0].declaration)).toBe(true); 84 | expect(atLeastOne(homebrew)[0].identifier).toBeUndefined(); 85 | }); 86 | 87 | it("should not detect arbitrary code as a component", async () => { 88 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 89 | const x = 1 + 1; 90 | console.log("This is not the component you're looking for", x); 91 | `), {}); 92 | expect(homebrew).toHaveLength(0); 93 | }); 94 | 95 | it("should not consider helper function defined inside a component as a separate component", async () => { 96 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 97 | export const Component = () => { 98 | const helper = useMemo(() =>
); 99 | return
{ helper }
; 100 | }; 101 | `), {}); 102 | expect(homebrew).toHaveLength(1); 103 | }); 104 | 105 | it("should not consider a component not using any built-in elements as a homebrew", async () => { 106 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 107 | import OtherComponent from "./otherComponent"; 108 | export const Component = () => { 109 | return <>; 110 | }; 111 | `), {}); 112 | expect(homebrew).toHaveLength(0); 113 | }); 114 | 115 | it("should detect an identifier after the homebrew is wrapped with forwardRef", async () => { 116 | const homebrew = detectHomebrew(project.createSourceFile("tst.js", ` 117 | const Hmbrw = React.forwardRef((props, forwardedRef) =>
); 118 | `), {}); 119 | expect(homebrew).toHaveLength(1); 120 | expect(Node.isArrowFunction(atLeastOne(homebrew)[0].declaration)).toBe(true); 121 | expect(Node.isIdentifier(atLeastOne(homebrew)[0].identifier)).toBe(true); 122 | expect(atLeastOne(homebrew)[0].identifier?.getText()).toBe("Hmbrw"); 123 | }); 124 | 125 | it("should detect homebrew component using a known component representing a dom node", async () => { 126 | const file = project.createSourceFile("tst.js", ` 127 | import styled from 'styled-components'; 128 | const Div = styled.div\`background: red\`; 129 | export function Homebrew() { return
}; 130 | `); 131 | 132 | const factoryDomReference = file.forEachDescendantAsArray() 133 | .filter(Node.isIdentifier) 134 | .find(id => id.getText() === "Div" && Node.isJsxSelfClosingElement(id.getParent())); 135 | if (!factoryDomReference) { throw new Error("Div component not found"); } 136 | 137 | const factoryName = "factory"; 138 | const homebrew = detectHomebrew(file, { [factoryName]: new Set([factoryDomReference]) }); 139 | expect(homebrew).toHaveLength(1); 140 | const detectedComponent = atLeastOne(homebrew)[0]; 141 | 142 | expect(Node.isFunctionDeclaration(detectedComponent.declaration)).toBe(true); 143 | expect(Node.isIdentifier(detectedComponent.identifier)).toBe(true); 144 | expect(detectedComponent.identifier?.getText()).toBe("Homebrew"); 145 | expect(detectedComponent.detectionReason).toBe(factoryName); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /src/lib/detectHomebrew/detectHomebrew.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowFunction, 3 | ClassDeclaration, 4 | ClassExpression, 5 | FunctionDeclaration, 6 | FunctionExpression, 7 | Identifier, 8 | Node, 9 | SourceFile, 10 | } from "ts-morph"; 11 | import { isEither, isNotNull, objectKeys, unexpected } from "../guards"; 12 | 13 | const builtinJsxReason = "builtin-lowercase-jsx"; 14 | export interface ComponentDeclaration { 15 | // Component declaration. Either a functional component, so an arrow or `function`, or a class component 16 | declaration: ArrowFunction | FunctionDeclaration | FunctionExpression | ClassDeclaration | ClassExpression, 17 | identifier?: Identifier, // Identifier does not exists for inline declaration, e.g. `higherOrder(() =>
)` 18 | 19 | // Reason why the component counts as homebrew: 20 | // either because it contains a built-in jsx tag, 21 | // or a name of a css-in-js lib generating dom elements. 22 | detectionReason: (typeof builtinJsxReason) | string, 23 | } 24 | 25 | const isPossibleComponentDeclaration = isEither( 26 | Node.isFunctionDeclaration, 27 | Node.isFunctionExpression, 28 | Node.isArrowFunction, 29 | Node.isClassDeclaration, 30 | Node.isClassExpression, 31 | ); 32 | 33 | const isJSXElement = isEither( 34 | Node.isJsxOpeningElement, 35 | Node.isJsxSelfClosingElement, 36 | ); 37 | const isBuiltInJsx = (node: Node): node is Node => { 38 | if (!isJSXElement(node)) { return false; } 39 | 40 | const tag = node.getTagNameNode(); 41 | if (!Node.isIdentifier(tag)) { return false; } 42 | 43 | const firstChar = tag.getText().charAt(0); 44 | return firstChar.toLowerCase() === firstChar; // Lowercase first character denotes dom tag 45 | }; 46 | 47 | export function detectHomebrew(file: SourceFile, knownPrimitiveUsages: Record>): ReadonlyArray { 48 | const findSetName = (node: Node) => { 49 | const matchingSet = objectKeys(knownPrimitiveUsages).find(k => knownPrimitiveUsages[k]?.has(node) ?? false); 50 | if (!matchingSet) { throw new Error(`Cant find a matching set for a node: ${ node.print() }`); } 51 | return matchingSet; 52 | }; 53 | 54 | const allKnownPrimitiveUsages = new Set(objectKeys(knownPrimitiveUsages).flatMap(k => [...knownPrimitiveUsages[k]?.values() ?? []])); 55 | const isKnownPrimitiveUsage = (node: Node): node is Node => allKnownPrimitiveUsages.has(node); 56 | const isPrimitiveUsage = isEither(isKnownPrimitiveUsage, isBuiltInJsx); 57 | const detectionReason = (node: Node) => isBuiltInJsx(node) ? builtinJsxReason : findSetName(node); 58 | 59 | const detectedComponents = file 60 | .forEachDescendantAsArray() 61 | .filter(isPossibleComponentDeclaration) 62 | .map(declaration => { 63 | // If there's no JSX inside the declaration — ignore. 64 | // It's either something entirely unrelated, or a compositional component. 65 | const domReference = declaration.forEachDescendantAsArray().find(isPrimitiveUsage); 66 | if (!domReference) { return null; } 67 | 68 | if (Node.isFunctionDeclaration(declaration)) { 69 | return { 70 | declaration, 71 | identifier: declaration.getNameNode(), 72 | detectionReason: detectionReason(domReference), 73 | }; 74 | } 75 | 76 | if (Node.isFunctionExpression(declaration) || Node.isArrowFunction(declaration)) { 77 | return { 78 | declaration, 79 | identifier: variableIdentifierIfExists(declaration), 80 | detectionReason: detectionReason(domReference), 81 | }; 82 | } 83 | 84 | if (Node.isClassDeclaration(declaration) || Node.isClassExpression(declaration)) { 85 | const render = declaration.getInstanceMember("render"); 86 | 87 | // No render in the class, or it's not a method — not a component 88 | if (!render || !Node.isMethodDeclaration(render)) { return null; } 89 | 90 | // No JSX in render — ignore 91 | const renderSpecificDomReference = render.forEachDescendantAsArray().find(isPrimitiveUsage); 92 | if (!renderSpecificDomReference) { return null; } 93 | 94 | return { 95 | declaration, 96 | identifier: declaration.getNameNode() || variableIdentifierIfExists(declaration), 97 | detectionReason: detectionReason(renderSpecificDomReference), 98 | }; 99 | } 100 | 101 | return unexpected(declaration); 102 | }) 103 | .filter(isNotNull); 104 | 105 | return detectedComponents.filter(({ declaration }) => detectedComponents.every(other => { 106 | if (other.declaration === declaration) { return true; } 107 | const match = declaration.getParentWhile(parent => parent !== other.declaration); 108 | return !match || Node.isSourceFile(match); 109 | })); 110 | } 111 | 112 | function variableIdentifierIfExists(node: Node): Identifier | undefined { 113 | const parent = node.getParent(); 114 | if (Node.isCallExpression(parent)) { return variableIdentifierIfExists(parent); } 115 | 116 | if (!Node.isVariableDeclaration(parent)) { return undefined; } 117 | const nameNode = parent.getNameNode(); 118 | 119 | if (Node.isArrayBindingPattern(nameNode)) { return undefined; } // TODO: warn 120 | if (Node.isObjectBindingPattern(nameNode)) { return undefined; } // TODO: warn. Currently triggers because of https://github.com/storybookjs/storybook/blob/215a21288fb09c4ca3ff56ab8fe7b4265ed04b1e/lib/ui/src/components/sidebar/SearchResults.tsx#L103 121 | return nameNode; 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/guards.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { inspect } from "util"; 3 | import { Any, Function, Misc } from "ts-toolbelt"; 4 | 5 | export const atLeastOne = (val: ArrayLike): [T, ...T[]] => { 6 | if (val.length < 1) { throw new Error("Expected at least one value"); } 7 | return val as any; 8 | }; 9 | 10 | export type StringKeys = T extends T ? Extract : never; 11 | export const objectKeys = (val: T): StringKeys[] => Object.keys(val) as any; 12 | export const objectValues = (val: T): (T[StringKeys])[] => Object.values(val); 13 | export const objectEntries = (val: T): [StringKeys, T[StringKeys]][] => Object.entries(val) as any; 14 | 15 | export type Guard = (val: In) => val is Out; 16 | 17 | type GuardInput> = G extends G ? Parameters[0] : never; 18 | type GuardArrInput[]> = GuardInput; 19 | 20 | type GuardOutput> = G extends G ? (G extends Guard ? Out : never) : never; 21 | type GuardArrOutput[]> = GuardOutput; 22 | 23 | export const isExactly = (expected: Function.Narrow) => (val: unknown): val is Val => val === expected; 24 | export const isNot = (guard: Guard) => (val: T | Out): val is T => !guard(val); 25 | 26 | export const isNull = isExactly(null); 27 | export const isNotNull = isNot(isNull); 28 | 29 | export const isUndefined = isExactly(undefined); 30 | export const isNotUndefined = isNot(isUndefined); 31 | 32 | const typeOf = (val: unknown) => typeof val; 33 | type TypeofResults = ReturnType; 34 | type TypeofMapping = { // Must be in sync with `TypeofResults` 35 | string: string, 36 | number: number, 37 | bigint: BigInt, 38 | boolean: boolean, 39 | symbol: symbol, 40 | undefined: undefined, 41 | object: {}, 42 | function: (...args: any[]) => unknown, 43 | }; 44 | 45 | export const isTypeof = (expectedType: T) => (val: unknown): val is TypeofMapping[T] => typeOf(val) === expectedType; 46 | export const isString = isTypeof("string"); 47 | export const isNumber = isTypeof("number"); 48 | export const isFunction = isTypeof("function"); 49 | export const isObject = isTypeof("object"); 50 | 51 | export const isObjectOf = (isKey: Guard, isValue: Guard) => 52 | (val: unknown): val is { [P in K]: V } => isObject(val) && objectKeys(val).every(isKey) && objectValues(val).every(isValue); 53 | 54 | type Ctor = (...args: any[]) => T; 55 | export const isInstanceof = (expected: Ctor) => (val: unknown): val is T => val instanceof expected; 56 | export const isRegexp = isInstanceof(RegExp); 57 | export const isDate = isInstanceof(Date as unknown as Ctor); 58 | 59 | export const isEither = []>(...guards: Guards) => (val: GuardArrInput): val is GuardArrOutput => guards.some(g => g(val)); 60 | 61 | export const hasProp =

(prop: P) => (val: T): val is T & { [p in P]: unknown } => { 62 | if (!val) { return false; } 63 | return Boolean(val) && typeof val === "object" && prop in val; 64 | }; 65 | 66 | const hasThen = hasProp("then"); 67 | export const isPromiseLike = (val: unknown): val is PromiseLike => hasThen(val) && typeof val.then === "function"; 68 | 69 | export const unexpected = (val: never): never => { throw new Error(`Unexpected value: ${ inspect(val) }`); }; 70 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { FindUsageWarning } from "./findUsages/findUsages"; 2 | import { ImportWarning } from "./resolveDependencies/identifyImports"; 3 | import { DependencyResolutionWarning } from "./resolveDependencies/resolveDependencies"; 4 | 5 | export * as tsMorph from "ts-morph"; // Re-export ts-morph, so that the consumers run exactly the same version 6 | export * as tsMorphCommon from "@ts-morph/common"; // Re-export @ts-morph/common, so that the consumers run exactly the same version 7 | 8 | export type { 9 | FilterExports, 10 | FilterImports, 11 | ResolveExportUses, 12 | ResolveDependencies, 13 | DependencyResolutionWarning, 14 | } from "./resolveDependencies/resolveDependencies"; 15 | 16 | export type { 17 | Export, 18 | ValueExport, 19 | Reexport, 20 | CJSOverwriteExport, 21 | CJSPropExport, 22 | ESMDefaultExport, 23 | ESMNamedExport, 24 | ESMReexportStar, 25 | ESMReexportStarAsNamed, 26 | ESMNamedReexport, 27 | } from "./resolveDependencies/identifyExports"; 28 | 29 | export type { 30 | Import, 31 | ESMImportEquals, 32 | ESMImportDefault, 33 | ESMImportNamespace, 34 | ESMImportNamed, 35 | ESMImportDynamic, 36 | CJSImport, 37 | ImportWarning, 38 | } from "./resolveDependencies/identifyImports"; 39 | 40 | export type { ResolveModule } from "./resolveModule/resolveModule"; 41 | 42 | export type { 43 | Trace, 44 | TraceExport, 45 | TraceImport, 46 | TraceHoc, 47 | TraceRef, 48 | Usage, 49 | FindUsages, 50 | FindUsageWarning, 51 | } from "./findUsages/findUsages"; 52 | 53 | export type Warning = FindUsageWarning | ImportWarning | DependencyResolutionWarning; 54 | 55 | export { 56 | getImportNode, 57 | getImportFile, 58 | isESMImportEquals, 59 | isESMImportDefault, 60 | isESMImportNamespace, 61 | isESMImportNamed, 62 | isESMImportDynamic, 63 | isCJSImport, 64 | } from "./resolveDependencies/identifyImports"; 65 | 66 | export { 67 | isCJSOverwriteExport, 68 | isCJSPropExport, 69 | isESMDefaultExport, 70 | isESMNamedExport, 71 | isESMReexportStar, 72 | isESMReexportStarAsNamed, 73 | isESMNamedReexport, 74 | isValueExport, 75 | isReexport, 76 | getExportFile, 77 | } from "./resolveDependencies/identifyExports"; 78 | 79 | export { detectHomebrew } from "./detectHomebrew/detectHomebrew"; 80 | export { resolveDependencies } from "./resolveDependencies/resolveDependencies"; 81 | export { setupModuleResolution } from "./resolveModule/resolveModule"; 82 | export { setupFindUsages, getTraceNode } from "./findUsages/findUsages"; 83 | 84 | export { SUPPORTED_FILE_TYPES } from "./supportedFileTypes"; 85 | 86 | export { defaultIgnoreFileRe, defaultIsTargetImport } from "./cli/resolveStatsConfig"; 87 | -------------------------------------------------------------------------------- /src/lib/packageInfo.ts: -------------------------------------------------------------------------------- 1 | const getPkg = () => { 2 | try { return require("../package.json"); } // In build 3 | catch (_ignore) { return require("../../package.json"); } // In dev 4 | }; 5 | 6 | const packageJson = getPkg(); 7 | export const version: string = packageJson.version; 8 | export const requiredNodeVersion: string = packageJson.engines.node; 9 | -------------------------------------------------------------------------------- /src/lib/resolveDependencies/identifyExports.spec.ts: -------------------------------------------------------------------------------- 1 | import { Node, Project } from "ts-morph"; 2 | import { 3 | Export, 4 | identifyExports, 5 | isESMDefaultExport, 6 | isESMNamedExport, 7 | isESMReexportStar, 8 | isESMReexportStarAsNamed, 9 | isESMNamedReexport, isCJSOverwriteExport, isCJSPropExport, 10 | } from "./identifyExports"; 11 | import { atLeastOne } from "../guards"; 12 | 13 | describe("Identify exports", () => { 14 | let project: Project; 15 | 16 | beforeEach(async () => { 17 | project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } }); 18 | }); 19 | 20 | it("should find esm default export of a literal", async () => { 21 | const exports = identifyExports(project.createSourceFile("tst.js", ` 22 | export default 1; 23 | `)); 24 | 25 | expect(exports).toHaveLength(1); 26 | 27 | const exp = atLeastOne(exports)[0]; 28 | if (!isESMDefaultExport(exp)) { throw new Error("Expected an ESM default export"); } 29 | expect(Node.isNumericLiteral(exp.exported)).toBe(true); 30 | }); 31 | 32 | it("should find esm default export of a variable identifier", async () => { 33 | const exports = identifyExports(project.createSourceFile("tst.js", ` 34 | const a = 1; 35 | export default a; 36 | `)); 37 | 38 | expect(exports).toHaveLength(1); 39 | 40 | const exp = atLeastOne(exports)[0]; 41 | if (!isESMDefaultExport(exp)) { throw new Error("Esm default export expected"); } 42 | expect(Node.isIdentifier(exp.exported)).toBe(true); 43 | expect(exp.exported.getText()).toBe("a"); 44 | expect(exp.exported.getParent()?.getText()).not.toContain("const"); 45 | }); 46 | 47 | it("should find esm named function export", async () => { 48 | const exports = identifyExports(project.createSourceFile("tst.js", ` 49 | export function named() { return void 0; } 50 | `)); 51 | 52 | expect(exports).toHaveLength(1); 53 | 54 | const exp = atLeastOne(exports)[0]; 55 | if (!isESMNamedExport(exp)) { throw new Error("Export is not ESM named export"); } 56 | 57 | expect(exp).toHaveProperty("alias", "named"); 58 | expect(Node.isFunctionDeclaration(exp.exported)).toBe(true); 59 | }); 60 | 61 | it("should find esm default function export without identifier", async () => { 62 | const exports = identifyExports(project.createSourceFile("tst.js", ` 63 | export default function() { return void 0; } 64 | `)); 65 | 66 | expect(exports).toHaveLength(1); 67 | 68 | const exp = atLeastOne(exports)[0]; 69 | if (!isESMDefaultExport(exp)) { throw new Error("Export is not ESM named export"); } 70 | expect(Node.isFunctionDeclaration(exp.exported)).toBe(true); 71 | }); 72 | 73 | it("should find esm default function export with identifier", async () => { 74 | const exports = identifyExports(project.createSourceFile("tst.js", ` 75 | export default function tst() { return void 0; } 76 | `)); 77 | 78 | expect(exports).toHaveLength(1); 79 | 80 | const exp = atLeastOne(exports)[0]; 81 | if (!isESMDefaultExport(exp)) { throw new Error("Export is not ESM named export"); } 82 | expect(Node.isFunctionDeclaration(exp.exported)).toBe(true); 83 | }); 84 | 85 | it("should find esm named const exports", async () => { 86 | const exports = identifyExports(project.createSourceFile("tst.js", ` 87 | export const a = 1, b = 2; 88 | `)); 89 | 90 | expect(exports).toHaveLength(2); 91 | 92 | const checkConstExport = (exp: Export | undefined, expectedName: string) => { 93 | if (!exp || !isESMNamedExport(exp)) { throw new Error("Expected an esm named export"); } 94 | 95 | expect(exp).toHaveProperty("alias", expectedName); 96 | expect(Node.isVariableDeclaration(exp.exported)).toBe(true); 97 | }; 98 | 99 | checkConstExport(exports[0], "a"); 100 | checkConstExport(exports[1], "b"); 101 | }); 102 | 103 | it("should find esm named exports", async () => { 104 | const exports = identifyExports(project.createSourceFile("tst.js", ` 105 | const a = 1, b = 2; 106 | export { a as one, b as two }; 107 | `)); 108 | 109 | expect(exports).toHaveLength(2); 110 | 111 | const checkNamedExport = (exp: Export | undefined, expectedAlias: string, expectedIdentifierName: string) => { 112 | if (!exp || !isESMNamedExport(exp)) { throw new Error("Expected an esm named export"); } 113 | 114 | expect(exp).toHaveProperty("alias", expectedAlias); 115 | 116 | if (!Node.isIdentifier(exp.exported)) { throw new Error("Expected to export an identifier"); } 117 | expect(exp.exported.getText()).toBe(expectedIdentifierName); 118 | }; 119 | checkNamedExport(exports[0], "one", "a"); 120 | checkNamedExport(exports[1], "two", "b"); 121 | }); 122 | 123 | it("should find esm reexport star", async () => { 124 | project.createSourceFile("source.js", ` 125 | const val1 = 1; 126 | const val2 = 2; 127 | export { val1, val2 }; 128 | `); 129 | const exports = identifyExports(project.createSourceFile("tst.js", ` 130 | export * from "/source.js"; 131 | `)); 132 | 133 | expect(exports).toHaveLength(1); 134 | 135 | const exp = atLeastOne(exports)[0]; 136 | if (!isESMReexportStar(exp)) { throw new Error("Expected an ESM reexport star"); } 137 | expect(exp.moduleSpecifier).toBe("/source.js"); 138 | }); 139 | 140 | it("should find aliased esm reexport star", async () => { 141 | project.createSourceFile("source.js", ` 142 | const val1 = 1; 143 | const val2 = 2; 144 | export { val1, val2 }; 145 | `); 146 | const exports = identifyExports(project.createSourceFile("tst.js", ` 147 | export * as alias from "/source.js"; 148 | `)); 149 | 150 | expect(exports).toHaveLength(1); 151 | 152 | const exp = atLeastOne(exports)[0]; 153 | if (!isESMReexportStarAsNamed(exp)) { throw new Error("Expected an aliased ESM reexport star"); } 154 | expect(exp.moduleSpecifier).toBe("/source.js"); 155 | expect(exp.alias).toBe("alias"); 156 | }); 157 | 158 | it("should find named esm reexport", async () => { 159 | const exports = identifyExports(project.createSourceFile("tst.js", ` 160 | export { val as alias } from "blah"; 161 | `)); 162 | 163 | expect(exports).toHaveLength(1); 164 | 165 | const exp = atLeastOne(exports)[0]; 166 | if (!isESMNamedReexport(exp)) { throw new Error("Expected a named ESM reexport"); } 167 | expect(exp.moduleSpecifier).toBe("blah"); 168 | expect(exp.referencedExport).toBe("val"); 169 | expect(exp.alias).toBe("alias"); 170 | }); 171 | 172 | it("should correctly process multiple ESM exports in a single file", async () => { 173 | const exports = identifyExports(project.createSourceFile("tst.js", ` 174 | export * as whop from "external"; 175 | export * as hey from "module"; 176 | export { a, b, c } from "dep1"; 177 | export { d, e, f } from "dep2"; 178 | export default 1; 179 | `)); 180 | 181 | expect(exports).toHaveLength(9); 182 | }); 183 | 184 | it("should not return typescript type exports", async () => { 185 | const exports = identifyExports(project.createSourceFile("tst.js", ` 186 | export type Tst = 1; 187 | export interface Blah {}; 188 | `)); 189 | 190 | expect(exports).toHaveLength(0); 191 | }); 192 | 193 | it("should not return duplicate typescript type exports", async () => { 194 | const exports = identifyExports(project.createSourceFile("tst.js", ` 195 | export interface Something { label: Label; } 196 | export interface Label { col: number; } 197 | export interface Label { row: number; } 198 | `)); 199 | 200 | expect(exports).toHaveLength(0); 201 | }); 202 | 203 | it("should correctly process export referenced multiple times throughout the file", async () => { 204 | const exports = identifyExports(project.createSourceFile("tst.js", ` 205 | export function tst() {} 206 | tst.x = 1; 207 | `)); 208 | 209 | expect(exports).toHaveLength(1); 210 | }); 211 | 212 | it("should find cjs export overwrite", async () => { 213 | const exports = identifyExports(project.createSourceFile("tst.js", ` 214 | module.exports = 1; 215 | `)); 216 | 217 | expect(exports).toHaveLength(1); 218 | 219 | const exp = atLeastOne(exports)[0]; 220 | if (!isCJSOverwriteExport(exp)) { throw new Error("Expected a CJS overwrite export"); } 221 | expect(Node.isNumericLiteral(exp.exported)).toBe(true); 222 | }); 223 | 224 | it("should find cjs prop export", async () => { 225 | const exports = identifyExports(project.createSourceFile("tst.js", ` 226 | module.exports.prop = 1; 227 | `)); 228 | 229 | expect(exports).toHaveLength(1); 230 | 231 | const exp = atLeastOne(exports)[0]; 232 | if (!isCJSPropExport(exp)) { throw new Error("Expected a CJS prop export"); } 233 | expect(Node.isNumericLiteral(exp.exported)).toBe(true); 234 | expect(exp.alias).toBe("prop"); 235 | }); 236 | 237 | it("should find property access used as cjs prop export", async () => { 238 | const exports = identifyExports(project.createSourceFile("tst.js", ` 239 | module.exports.prop = 1; 240 | `)); 241 | 242 | expect(exports).toHaveLength(1); 243 | 244 | const exp = atLeastOne(exports)[0]; 245 | if (!isCJSPropExport(exp)) { throw new Error("Expected a CJS prop export"); } 246 | expect(Node.isNumericLiteral(exp.exported)).toBe(true); 247 | expect(exp.alias).toBe("prop"); 248 | }); 249 | 250 | it("should find element access expression used as cjs prop export", async () => { 251 | const exports = identifyExports(project.createSourceFile("tst.js", ` 252 | module.exports["prop"] = 1; 253 | `)); 254 | 255 | expect(exports).toHaveLength(1); 256 | 257 | const exp = atLeastOne(exports)[0]; 258 | if (!isCJSPropExport(exp)) { throw new Error("Expected a CJS prop export"); } 259 | expect(Node.isNumericLiteral(exp.exported)).toBe(true); 260 | expect(exp.alias).toBe("prop"); 261 | }); 262 | 263 | it("should ignore dynamic cjs prop exports", async () => { 264 | const exports = identifyExports(project.createSourceFile("tst.js", ` 265 | ["a", "b", "c"].forEach(x => module.exports[x] = 1); 266 | `)); 267 | expect(exports).toHaveLength(0); 268 | }); 269 | 270 | it("should identify an implementation of an overloaded function in TS", async () => { 271 | const exports = identifyExports(project.createSourceFile("tst.ts", ` 272 | export function tst(val: number): number; 273 | export function tst(val: string): string; 274 | export function tst(val: T): T { return val; } 275 | `)); 276 | 277 | expect(exports).toHaveLength(1); 278 | 279 | const exp = atLeastOne(exports)[0]; 280 | if (!isESMNamedExport(exp)) { throw new Error("Expected an ESM named export"); } 281 | expect(exp.alias).toBe("tst"); 282 | 283 | if (!Node.isFunctionDeclaration(exp.exported)) { throw new Error("Expected a function declaration"); } 284 | expect(exp.exported.hasBody()).toBe(true); 285 | }); 286 | }); 287 | -------------------------------------------------------------------------------- /src/lib/resolveDependencies/identifyExports.ts: -------------------------------------------------------------------------------- 1 | import { ExportDeclaration, ExportSpecifier, NamespaceExport, Node, SourceFile, Symbol as TSSymbol } from "ts-morph"; 2 | import { 3 | atLeastOne, 4 | Guard, 5 | isEither, 6 | isNotNull, 7 | isNotUndefined, 8 | objectValues, 9 | unexpected, 10 | } from "../guards"; 11 | import { describeNode } from "../findUsages/findUsages"; 12 | 13 | export type Export = ValueExport | Reexport; 14 | 15 | export type ValueExport = ESMDefaultExport | ESMNamedExport | CJSOverwriteExport | CJSPropExport; 16 | export type Reexport = ESMReexportStar | ESMReexportStarAsNamed | ESMNamedReexport; 17 | 18 | // module.exports = 19 | export type CJSOverwriteExport = { type: "cjs-overwrite", exported: Node }; 20 | export const isCJSOverwriteExport = (exp: Export): exp is CJSOverwriteExport => exp.type === "cjs-overwrite"; 21 | 22 | // module.exports.name = 23 | export type CJSPropExport = { type: "cjs-prop", alias: string, exported: Node }; 24 | export const isCJSPropExport = (exp: Export): exp is CJSPropExport => exp.type === "cjs-prop"; 25 | 26 | // export default 27 | export type ESMDefaultExport = { type: "esm-default", exported: Node }; 28 | export const isESMDefaultExport = (exp: Export): exp is ESMDefaultExport => exp.type === "esm-default"; 29 | 30 | // export function () {...} 31 | // export const = ..., = ...; 32 | export type ESMNamedExport = { type: "esm-named", alias: string, exported: Node }; 33 | export const isESMNamedExport = (exp: Export): exp is ESMNamedExport => exp.type === "esm-named"; 34 | 35 | // export * from "blah" 36 | export type ESMReexportStar = { type: "esm-reexport-star", moduleSpecifier: string, declaration: ExportDeclaration }; 37 | export const isESMReexportStar = (exp: Export): exp is ESMReexportStar => exp.type === "esm-reexport-star"; 38 | 39 | // export * as alias from "blah" 40 | export type ESMReexportStarAsNamed = { type: "esm-reexport-star-as-named", moduleSpecifier: string, alias: string, declaration: NamespaceExport }; 41 | export const isESMReexportStarAsNamed = (exp: Export): exp is ESMReexportStarAsNamed => exp.type === "esm-reexport-star-as-named"; 42 | 43 | // export { val as alias } from "blah" 44 | export type ESMNamedReexport = { type: "esm-named-reexport", moduleSpecifier: string, alias: string, referencedExport: string, declaration: ExportSpecifier }; 45 | export const isESMNamedReexport = (exp: Export): exp is ESMNamedReexport => exp.type === "esm-named-reexport"; 46 | 47 | const valueExportGuards: { [P in ValueExport["type"]]: Guard> } = { 48 | "esm-default": isESMDefaultExport, 49 | "esm-named": isESMNamedExport, 50 | "cjs-overwrite": isCJSOverwriteExport, 51 | "cjs-prop": isCJSPropExport, 52 | }; 53 | export const isValueExport = isEither(...objectValues(valueExportGuards)); 54 | 55 | const reexportGuards: { [P in Reexport["type"]]: Guard> } = { 56 | "esm-reexport-star": isESMReexportStar, 57 | "esm-reexport-star-as-named": isESMReexportStarAsNamed, 58 | "esm-named-reexport": isESMNamedReexport, 59 | }; 60 | export const isReexport = isEither(...objectValues(reexportGuards)); 61 | 62 | const isExport = isEither( 63 | Node.isNamedExports, 64 | Node.isNamespaceExport, 65 | Node.isExportAssignment, 66 | Node.isExportSpecifier, 67 | Node.isExportDeclaration, 68 | ); 69 | const isExportOrExportable = isEither(Node.isExportable, isExport); 70 | function findExportedDeclaration(symbol: TSSymbol, file: SourceFile) { 71 | const declarations = symbol.getDeclarations() 72 | .filter(declaration => isExportOrExportable(declaration) || declaration.getAncestors().some(isExportOrExportable)) 73 | .filter(declaration => declaration.getSourceFile() === file) 74 | .filter(declaration => !Node.isFunctionDeclaration(declaration) || declaration.hasBody()) 75 | .filter(declaration => !Node.isInterfaceDeclaration(declaration) && !Node.isTypeAliasDeclaration(declaration)); // Ignore types and interfaces 76 | 77 | if (declarations.length === 0) { return null; } 78 | if (declarations.length > 1) { 79 | throw new Error(`Implementation error: Expected no more than one declaration for an export symbol ${ symbol.getName() } in ${ file.getFilePath() }, got ${ declarations.length } instead`); 80 | } 81 | 82 | const declaration = atLeastOne(declarations)[0]; 83 | const exported = Node.isExpressionable(declaration) || Node.isExpressioned(declaration) ? declaration.getExpression() : declaration; 84 | if (!exported) { 85 | throw new Error(`Could not find expression for declaration: ${ describeNode(declaration) }`); 86 | } 87 | 88 | if (Node.isExportSpecifier(exported)) { 89 | return exported.getNameNode(); 90 | } 91 | 92 | if (Node.isIdentifier(exported)) { 93 | exported.getDefinitions(); 94 | } 95 | 96 | return exported; 97 | } 98 | 99 | export function identifyExports(file: SourceFile): Export[] { 100 | const cjsExportParents = file.forEachDescendantAsArray() 101 | .filter(Node.isPropertyAccessExpression) 102 | .filter(node => node.getText({ trimLeadingIndentation: true, includeJsDocComments: false }) === "module.exports") 103 | .map(node => { 104 | const parent = node.getParent(); 105 | if (!parent) { return null; } 106 | return { node, parent }; 107 | }) 108 | .filter(isNotNull); 109 | 110 | const cjsOverwrites = cjsExportParents 111 | .map(({ node, parent }) => { 112 | if (!Node.isBinaryExpression(parent)) { return null; } 113 | return { node, binaryExp: parent }; 114 | }) 115 | .filter(isNotNull) 116 | .filter(({ node, binaryExp }) => binaryExp.getOperatorToken().getKindName() === "EqualsToken" && binaryExp.getLeft() === node) 117 | .map(({ binaryExp }): CJSOverwriteExport => ({ type: "cjs-overwrite", exported: binaryExp.getRight() })); 118 | 119 | const cjsPropAssignments = cjsExportParents 120 | .map(({ parent }) => { 121 | if (!Node.isPropertyAccessExpression(parent)) { return null; } 122 | 123 | const binaryExp = parent.getParent(); 124 | if (!binaryExp || !Node.isBinaryExpression(binaryExp)) { return null; } 125 | 126 | return { propAccess: parent, binaryExp }; 127 | }) 128 | .filter(isNotNull) 129 | .map(({ propAccess, binaryExp }): CJSPropExport => ({ type: "cjs-prop", alias: propAccess.getName(), exported: binaryExp.getRight() })); 130 | 131 | const cjsElementAccessAssignments = cjsExportParents 132 | .map(({ node, parent }) => { 133 | if (!Node.isElementAccessExpression(parent)) { return null; } 134 | 135 | const binaryExp = parent.getParent(); 136 | if (!binaryExp || !Node.isBinaryExpression(binaryExp)) { return null; } 137 | 138 | const argument = parent.getArgumentExpression(); 139 | if (!argument) { throw new Error(`No argument found on element access expression ${ describeNode(node) }`); } 140 | 141 | if (!Node.isStringLiteral(argument) && !Node.isNumericLiteral(argument) && !Node.isNoSubstitutionTemplateLiteral(argument)) { 142 | return null; 143 | } 144 | 145 | return { argument, binaryExp }; 146 | }) 147 | .filter(isNotNull) 148 | .map(({ argument, binaryExp }): CJSPropExport => ({ type: "cjs-prop", alias: argument.getLiteralText(), exported: binaryExp.getRight() })); 149 | 150 | const exportSymbols = file.getExportSymbols() 151 | .map((symbol): Export | null => { 152 | const exportDeclarations = symbol.getDeclarations() 153 | .map(dec => dec.getAncestors().find(Node.isExportDeclaration)) 154 | .filter(isNotUndefined); 155 | 156 | if (exportDeclarations.length && exportDeclarations.some(d => d.getModuleSpecifierValue())) { 157 | return null; // Reexport, handled below 158 | } 159 | 160 | const exported = findExportedDeclaration(symbol, file); 161 | if (!exported) { return null; } 162 | 163 | if (Node.isTypeAliasDeclaration(exported) || Node.isInterfaceDeclaration(exported)) { 164 | return null; // Ignore the type exports 165 | } 166 | 167 | if (symbol.getName() === "default") { 168 | return { 169 | type: "esm-default", 170 | exported, 171 | }; 172 | } 173 | 174 | return { 175 | type: "esm-named", 176 | alias: symbol.getName(), 177 | exported, 178 | }; 179 | }) 180 | .filter(isNotNull); 181 | 182 | const exportDeclarations = file.getExportDeclarations() 183 | .map((exportDeclaration): Export[] | null => { 184 | if (exportDeclaration.isTypeOnly()) { return null; } // Only interested in value exports 185 | 186 | const moduleSpecifier = exportDeclaration.getModuleSpecifierValue(); 187 | if (!moduleSpecifier) { return null; } // Not a reexport, should have been handled with the symbols 188 | 189 | const namespaceExport = exportDeclaration.getNamespaceExport(); 190 | if (namespaceExport) { 191 | return [{ 192 | type: "esm-reexport-star-as-named", 193 | alias: namespaceExport.getName(), 194 | declaration: namespaceExport, 195 | moduleSpecifier, 196 | }]; 197 | } 198 | 199 | const namedExports = exportDeclaration.getNamedExports(); 200 | if (namedExports.length === 0) { 201 | return [{ 202 | type: "esm-reexport-star", 203 | declaration: exportDeclaration, 204 | moduleSpecifier, 205 | }]; 206 | } 207 | 208 | return namedExports.map((namedExportSpecifier): ESMNamedReexport => ({ 209 | type: "esm-named-reexport", 210 | alias: namedExportSpecifier.getAliasNode()?.getText() ?? namedExportSpecifier.getName(), 211 | referencedExport: namedExportSpecifier.getName(), 212 | declaration: namedExportSpecifier, 213 | moduleSpecifier, 214 | })); 215 | }) 216 | .filter(isNotNull) 217 | .reduce((a, b) => [...a, ...b], []); 218 | 219 | return [...exportSymbols, ...exportDeclarations, ...cjsOverwrites, ...cjsPropAssignments, ...cjsElementAccessAssignments]; 220 | } 221 | 222 | export const getExportFile = (exp: Export): SourceFile => { 223 | switch (exp.type) { 224 | case "esm-named": 225 | case "esm-default": return exp.exported.getSourceFile(); 226 | 227 | case "esm-reexport-star": 228 | case "esm-named-reexport": 229 | case "esm-reexport-star-as-named": return exp.declaration.getSourceFile(); 230 | 231 | case "cjs-prop": 232 | case "cjs-overwrite": return exp.exported.getSourceFile(); 233 | 234 | default: 235 | return unexpected(exp); 236 | } 237 | }; 238 | -------------------------------------------------------------------------------- /src/lib/resolveDependencies/identifyImports.spec.ts: -------------------------------------------------------------------------------- 1 | import { Node, Project } from "ts-morph"; 2 | import { 3 | identifyImports, isCJSImport, 4 | isESMImportDefault, 5 | isESMImportDynamic, 6 | isESMImportEquals, 7 | isESMImportNamed, 8 | isESMImportNamespace, 9 | } from "./identifyImports"; 10 | import { atLeastOne } from "../guards"; 11 | 12 | describe("Identify imports", () => { 13 | let project: Project; 14 | 15 | beforeEach(async () => { 16 | project = new Project({ useInMemoryFileSystem: true, compilerOptions: { allowJs: true } }); 17 | }); 18 | 19 | it("should find default import", async () => { 20 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 21 | import val from "module" 22 | `)); 23 | 24 | expect(imports).toHaveLength(1); 25 | 26 | const imp = atLeastOne(imports)[0]; 27 | if (!isESMImportDefault(imp)) { throw new Error("Expected an ESM default import"); } 28 | expect(imp).toHaveProperty("moduleSpecifier", "module"); 29 | expect(imp.identifier.getText()).toBe("val"); 30 | }); 31 | 32 | it("should find namespace import", async () => { 33 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 34 | import * as val from "module" 35 | `)); 36 | 37 | expect(imports).toHaveLength(1); 38 | 39 | const imp = atLeastOne(imports)[0]; 40 | if (!isESMImportNamespace(imp)) { throw new Error("Expected an ESM namespace import"); } 41 | expect(imp).toHaveProperty("moduleSpecifier", "module"); 42 | expect(imp.identifier.getText()).toBe("val"); 43 | }); 44 | 45 | it("should find named import", async () => { 46 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 47 | import { val as alias } from "module" 48 | `)); 49 | 50 | expect(imports).toHaveLength(1); 51 | 52 | const imp = atLeastOne(imports)[0]; 53 | if (!isESMImportNamed(imp)) { throw new Error("Expected an ESM named import"); } 54 | expect(imp).toHaveProperty("moduleSpecifier", "module"); 55 | expect(imp).toHaveProperty("referencedExport", "val"); 56 | expect(imp.identifier.getText()).toBe("alias"); 57 | }); 58 | 59 | it("should find combined imports in a single import definition", async () => { 60 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 61 | import def, { val as alias, thirdOne } from "module" 62 | `)); 63 | 64 | expect(imports).toHaveLength(3); 65 | }); 66 | 67 | it("should find import equals assignment", async () => { 68 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 69 | import val = require("module"); 70 | `)); 71 | 72 | expect(imports).toHaveLength(1); 73 | 74 | const imp = atLeastOne(imports)[0]; 75 | if (!isESMImportEquals(imp)) { throw new Error("Expected an ESM import equals"); } 76 | expect(imp).toHaveProperty("moduleSpecifier", "module"); 77 | expect(imp.identifier.getText()).toBe("val"); 78 | }); 79 | 80 | it("should warn if import equals assignment does not have a string module identifier", async () => { 81 | const { warnings } = identifyImports(project.createSourceFile("tst.js", ` 82 | const mod = "module"; 83 | import val = require(mod); 84 | `)); 85 | 86 | expect(warnings).toHaveLength(1); 87 | 88 | const [warning] = atLeastOne(warnings); 89 | if (warning.type !== "import-unresolved-module-specifier") { 90 | throw new Error(`Expected an import-unresolved-module-specifier warning, got ${ warning.type } instead`); 91 | } 92 | expect(Node.isIdentifier(warning.moduleSpecifier)).toBe(true); 93 | expect(warning.moduleSpecifier.getText()).toBe("mod"); 94 | }); 95 | 96 | it("should find dynamic imports", async () => { 97 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 98 | async function tst() { 99 | await import("module"); 100 | } 101 | `)); 102 | 103 | expect(imports).toHaveLength(1); 104 | 105 | const imp = atLeastOne(imports)[0]; 106 | if (!isESMImportDynamic(imp)) { throw new Error("Expected an ESM dynamic import"); } 107 | expect(imp).toHaveProperty("moduleSpecifier", "module"); 108 | expect(imp.importCall.getText()).toBe("import(\"module\")"); 109 | }); 110 | 111 | it("should warn if dynamic import is used without string module identifier", async () => { 112 | const { warnings } = identifyImports(project.createSourceFile("tst.js", ` 113 | async function tst() { 114 | const mod = "module"; 115 | await import(mod); 116 | } 117 | `)); 118 | 119 | expect(warnings).toHaveLength(1); 120 | 121 | const [warning] = atLeastOne(warnings); 122 | if (warning.type !== "import-unresolved-module-specifier") { 123 | throw new Error(`Expected an import-unresolved-module-specifier warning, got ${ warning.type } instead`); 124 | } 125 | expect(Node.isIdentifier(warning.moduleSpecifier)).toBe(true); 126 | expect(warning.moduleSpecifier.getText()).toBe("mod"); 127 | }); 128 | 129 | it("should warn if import equals is used without referencing an external module", async () => { 130 | const { warnings } = identifyImports(project.createSourceFile("tst.js", ` 131 | import IRichLanguageConfiguration = monaco.languages.LanguageConfiguration 132 | `)); 133 | 134 | expect(warnings).toHaveLength(1); 135 | 136 | const [warning] = atLeastOne(warnings); 137 | if (warning.type !== "import-unresolved-import-equals-definition") { 138 | throw new Error(`Expected an import-unresolved-module-specifier warning, got ${ warning.type } instead`); 139 | } 140 | expect(warning.imported.getText()).toBe("monaco.languages.LanguageConfiguration"); 141 | }); 142 | 143 | it("should detect cjs require imports", async () => { 144 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 145 | const val = require("module"); 146 | `)); 147 | 148 | expect(imports).toHaveLength(1); 149 | 150 | const imp = atLeastOne(imports)[0]; 151 | if (!isCJSImport(imp)) { throw new Error("Expected a CJS import"); } 152 | expect(imp).toHaveProperty("moduleSpecifier", "module"); 153 | expect(imp.importCall.getText()).toBe("require(\"module\")"); 154 | }); 155 | 156 | it("should throw if cjs require import is used without string module identifier", async () => { 157 | const { warnings } = identifyImports(project.createSourceFile("tst.js", ` 158 | const mod = "module"; 159 | require(mod); 160 | `)); 161 | 162 | expect(warnings).toHaveLength(1); 163 | 164 | const [warning] = atLeastOne(warnings); 165 | if (warning.type !== "import-unresolved-module-specifier") { 166 | throw new Error(`Expected an import-unresolved-module-specifier warning, got ${ warning.type } instead`); 167 | } 168 | expect(Node.isIdentifier(warning.moduleSpecifier)).toBe(true); 169 | expect(warning.moduleSpecifier.getText()).toBe("mod"); 170 | }); 171 | 172 | it("should ignore type-only imports", async () => { 173 | const { imports } = identifyImports(project.createSourceFile("tst.js", ` 174 | import type { t } from "module" 175 | `)); 176 | 177 | expect(imports).toHaveLength(0); 178 | }); 179 | }); 180 | -------------------------------------------------------------------------------- /src/lib/resolveDependencies/identifyImports.ts: -------------------------------------------------------------------------------- 1 | import { CallExpression, Identifier, SourceFile, Node } from "ts-morph"; 2 | import { isNotNull, unexpected } from "../guards"; 3 | import { describeNode } from "../findUsages/findUsages"; 4 | 5 | 6 | export type Import = ESMImportEquals | ESMImportDefault | ESMImportNamed | ESMImportDynamic | ESMImportNamespace | CJSImport; 7 | 8 | // import = require(""); 9 | export type ESMImportEquals = { type: "esm-equals", identifier: Identifier, moduleSpecifier: string }; 10 | export const isESMImportEquals = (imp: Import): imp is ESMImportEquals => imp.type === "esm-equals"; 11 | 12 | // import from "" 13 | export type ESMImportDefault = { type: "esm-default", identifier: Identifier, moduleSpecifier: string }; 14 | export const isESMImportDefault = (imp: Import): imp is ESMImportDefault => imp.type === "esm-default"; 15 | 16 | // import * as from "" 17 | export type ESMImportNamespace = { type: "esm-namespace", identifier: Identifier, moduleSpecifier: string }; 18 | export const isESMImportNamespace = (imp: Import): imp is ESMImportNamespace => imp.type === "esm-namespace"; 19 | 20 | // import { as } from "" 21 | export type ESMImportNamed = { type: "esm-named", identifier: Identifier, referencedExport: string, moduleSpecifier: string }; 22 | export const isESMImportNamed = (imp: Import): imp is ESMImportNamed => imp.type === "esm-named"; 23 | 24 | // await import("") 25 | export type ESMImportDynamic = { type: "esm-dynamic", importCall: CallExpression, moduleSpecifier: string }; 26 | export const isESMImportDynamic = (imp: Import): imp is ESMImportDynamic => imp.type === "esm-dynamic"; 27 | 28 | // require(""); 29 | export type CJSImport = { type: "cjs-import", importCall: CallExpression, moduleSpecifier: string }; 30 | export const isCJSImport = (imp: Import): imp is CJSImport => imp.type === "cjs-import"; 31 | 32 | 33 | export type ImportWarning = UnresolvedModuleSpecifierWarning | UnresolvedImportEqualsDefinition; 34 | export type UnresolvedModuleSpecifierWarning = { type: "import-unresolved-module-specifier", message: string, moduleSpecifier: Node }; 35 | export type UnresolvedImportEqualsDefinition = { type: "import-unresolved-import-equals-definition", message: string, imported: Node }; 36 | 37 | export function identifyImports(file: SourceFile): { imports: Import[], warnings: ImportWarning[] } { 38 | const warnings: ImportWarning[] = []; 39 | 40 | const cjsRequires = file.forEachDescendantAsArray() 41 | .filter(Node.isCallExpression) 42 | .filter(call => { 43 | const exp = call.getExpression(); 44 | if (!Node.isIdentifier(exp)) { return false; } 45 | return exp.getText({ trimLeadingIndentation: true, includeJsDocComments: false }) === "require"; 46 | }) 47 | .filter(call => !call.getAncestors().some(Node.isImportEqualsDeclaration)) 48 | .map((callExpression): CJSImport | null => { 49 | const [moduleRef] = callExpression.getArguments(); 50 | if (!moduleRef) { throw new Error(`Implementation error: Expected require call expression to have at least one argument. Reading ${ describeNode(callExpression) } in ${ file.getFilePath() }`); } 51 | 52 | if (!Node.isStringLiteral(moduleRef) && !Node.isNoSubstitutionTemplateLiteral(moduleRef)) { 53 | warnings.push({ 54 | type: "import-unresolved-module-specifier", 55 | message: `Dynamic cjs require call module specifier not supported. Reading ${ describeNode(callExpression) } in ${ file.getFilePath() }`, 56 | moduleSpecifier: moduleRef, 57 | }); 58 | return null; 59 | } 60 | 61 | return { 62 | type: "cjs-import", 63 | importCall: callExpression, 64 | moduleSpecifier: moduleRef.getLiteralValue(), 65 | }; 66 | }) 67 | .filter(isNotNull); 68 | 69 | const importDeclarations = file.getImportDeclarations() 70 | .map((imp): (Import | null)[] => { 71 | if (imp.isTypeOnly()) { return [null]; } 72 | 73 | const moduleSpecifier = imp.getModuleSpecifierValue(); 74 | 75 | const dflt = imp.getDefaultImport(); 76 | const dfltImport: ESMImportDefault | null = dflt 77 | ? { type: "esm-default", identifier: dflt, moduleSpecifier } 78 | : null; 79 | 80 | const namespace = imp.getNamespaceImport(); 81 | const namespaceImport: ESMImportNamespace | null = namespace 82 | ? { type: "esm-namespace", identifier: namespace, moduleSpecifier } 83 | : null; 84 | 85 | const namedImports = imp.getNamedImports().map((named): ESMImportNamed => ({ 86 | type: "esm-named", 87 | identifier: named.getAliasNode() ?? named.getNameNode(), 88 | referencedExport: named.getName(), 89 | moduleSpecifier, 90 | })); 91 | 92 | return [dfltImport, namespaceImport, ...namedImports]; 93 | }) 94 | .reduce((a, b) => [...a, ...b], []) 95 | .filter(isNotNull); 96 | 97 | const importEquals = file.forEachDescendantAsArray() 98 | .filter(Node.isImportEqualsDeclaration) 99 | .map((imp): ESMImportEquals | null => { 100 | const moduleReference = imp.getModuleReference(); 101 | if (!Node.isExternalModuleReference(moduleReference)) { 102 | warnings.push({ 103 | type: "import-unresolved-import-equals-definition", 104 | message: `Imported value is not an external module. Reading ${ describeNode(imp) } in ${ file.getFilePath() }`, 105 | imported: moduleReference, 106 | }); 107 | return null; 108 | } 109 | 110 | const moduleName = moduleReference.getExpression(); 111 | if (!moduleName) { throw new Error(`Implementation error: Expected import equals to have a module name. Reading ${ describeNode(imp) } in ${ file.getFilePath() }`); } 112 | 113 | if (!Node.isStringLiteral(moduleName) && !Node.isNoSubstitutionTemplateLiteral(moduleName)) { 114 | warnings.push({ 115 | type: "import-unresolved-module-specifier", 116 | message: `Dynamic esm import equals module specifier not supported. Reading ${ describeNode(imp) } in ${ file.getFilePath() }`, 117 | moduleSpecifier: moduleName, 118 | }); 119 | return null; 120 | } 121 | 122 | return { 123 | type: "esm-equals", 124 | identifier: imp.getNameNode(), 125 | moduleSpecifier: moduleName.getLiteralValue(), 126 | }; 127 | }) 128 | .filter(isNotNull); 129 | 130 | const dynamicImports = file.forEachDescendantAsArray() 131 | .filter(Node.isImportExpression) 132 | .map((importExpr): ESMImportDynamic | null => { 133 | const callExpression = importExpr.getParent(); 134 | if (!Node.isCallExpression(callExpression)) { throw new Error(`Implementation error: Expected import expression parent to be a call expression. Reading ${ describeNode(callExpression) } in ${ file.getFilePath() }`); } 135 | 136 | const [moduleRef] = callExpression.getArguments(); 137 | if (!moduleRef) { throw new Error(`Implementation error: Expected import call expression to have at least one argument. Reading ${ describeNode(callExpression) } in ${ file.getFilePath() }`); } 138 | 139 | if (!Node.isStringLiteral(moduleRef) && !Node.isNoSubstitutionTemplateLiteral(moduleRef)) { 140 | warnings.push({ 141 | type: "import-unresolved-module-specifier", 142 | message: `Dynamic esm import module specifier not supported. Reading ${ describeNode(importExpr) } in ${ file.getFilePath() }`, 143 | moduleSpecifier: moduleRef, 144 | }); 145 | return null; 146 | } 147 | return { 148 | type: "esm-dynamic", 149 | importCall: callExpression, 150 | moduleSpecifier: moduleRef.getLiteralValue(), 151 | }; 152 | }) 153 | .filter(isNotNull); 154 | 155 | return { 156 | imports: [...importEquals, ...importDeclarations, ...dynamicImports, ...cjsRequires], 157 | warnings, 158 | }; 159 | } 160 | 161 | export const getImportNode = (imp: Import): Node => { 162 | switch (imp.type) { 163 | case "cjs-import": 164 | case "esm-dynamic": 165 | return imp.importCall; 166 | 167 | case "esm-named": 168 | case "esm-equals": 169 | case "esm-default": 170 | case "esm-namespace": 171 | return imp.identifier; 172 | 173 | default: 174 | return unexpected(imp); 175 | } 176 | }; 177 | 178 | export const getImportFile = (imp: Import): SourceFile => { 179 | return getImportNode(imp).getSourceFile(); 180 | }; 181 | -------------------------------------------------------------------------------- /src/lib/resolveDependencies/resolveDependencies.ts: -------------------------------------------------------------------------------- 1 | import { Project, SourceFile } from "ts-morph"; 2 | import { isModuleResolutionWarning, ModuleResolutionWarning, ResolveModule } from "../resolveModule/resolveModule"; 3 | import { 4 | Export, 5 | getExportFile, 6 | identifyExports, 7 | isValueExport, ValueExport, 8 | } from "./identifyExports"; 9 | import { 10 | ESMImportDefault, 11 | getImportFile, 12 | identifyImports, 13 | Import, 14 | ImportWarning, 15 | isESMImportDefault, 16 | } from "./identifyImports"; 17 | import { unexpected } from "../guards"; 18 | import { describeNode } from "../findUsages/findUsages"; 19 | 20 | export type FilterImports = (predicate: (imp: Import) => boolean) => Import[]; 21 | export type FilterExports = (predicate: (exp: ValueExport) => boolean) => ValueExport[]; 22 | export type ResolveExportUses = (exp: ValueExport) => { imp: Import, aliasPath: string[] }[]; 23 | 24 | export type DependencyResolutionWarning = AmbiguousImportWarning | ImportWarning | ModuleResolutionWarning; 25 | export type AmbiguousImportWarning = { type: "dependency-resolution-ambiguous-import", message: string, imp: ESMImportDefault, exp: Export }; 26 | 27 | export type ResolveDependencies = { 28 | filterImports: FilterImports, 29 | filterExports: FilterExports, 30 | resolveExportUses: ResolveExportUses, 31 | warnings: DependencyResolutionWarning[], 32 | }; 33 | 34 | type Star = "*" & { __type: "star" }; 35 | const star: Star = "*" as never; 36 | const isStar = (val: string): val is Star => val === "*"; 37 | 38 | // TODO: resolving dependencies needs optimization. Doing `.forEachDescendantAsArray()` over and over is expensive. 39 | export const resolveDependencies = (project: Project, resolveModule: ResolveModule): ResolveDependencies => { 40 | const files = project.getSourceFiles(); 41 | 42 | const allImports: Import[] = []; 43 | const allExports: Export[] = []; 44 | const exportsPerFile = new Map(); 45 | 46 | const warnings: DependencyResolutionWarning[] = []; 47 | 48 | files.forEach(file => { 49 | const { imports, warnings: importWarnings } = identifyImports(file); 50 | warnings.push(...importWarnings); 51 | allImports.push(...imports); 52 | 53 | const exports = identifyExports(file); 54 | allExports.push(...exports); 55 | exportsPerFile.set(file.getFilePath(), exports); 56 | }); 57 | 58 | const resolveExport = (exportName: string | Star, moduleSpecifier: string, originalImport: Import, file: SourceFile) => { 59 | const target = resolveModule(moduleSpecifier, file.getFilePath()); 60 | if (!target) { return []; } // Targeting an external file, ignore 61 | 62 | if (isModuleResolutionWarning(target)) { 63 | warnings.push(target); 64 | return []; 65 | } 66 | 67 | const starRequested = isStar(exportName); 68 | const fileExports = exportsPerFile.get(target.getFilePath()) ?? []; 69 | return fileExports 70 | .map((exp): ({ exp: Export, key: string | Star, aliasPath: string[] })[] => { 71 | switch (exp.type) { 72 | case "esm-default": 73 | return starRequested || "default" === exportName 74 | ? [{ exp, key: "default", aliasPath: starRequested ? ["default"] : [] }] 75 | : []; 76 | 77 | case "esm-named": 78 | return starRequested || exp.alias === exportName 79 | ? [{ exp, key: exp.alias, aliasPath: starRequested ? [exp.alias] : [] }] 80 | : []; 81 | 82 | case "esm-named-reexport": 83 | return starRequested || exp.alias === exportName 84 | ? resolveExport(exp.referencedExport, exp.moduleSpecifier, originalImport, target) 85 | .map(e => ({ ...e, aliasPath: starRequested ? [exp.alias, ...e.aliasPath] : e.aliasPath })) 86 | : []; 87 | 88 | case "esm-reexport-star-as-named": 89 | return starRequested || exp.alias === exportName 90 | ? resolveExport(star, exp.moduleSpecifier, originalImport, target) 91 | .map(e => ({ ...e, aliasPath: starRequested ? [exp.alias, ...e.aliasPath] : e.aliasPath })) 92 | : []; 93 | 94 | case "esm-reexport-star": 95 | return resolveExport(exportName, exp.moduleSpecifier, originalImport, target); 96 | 97 | case "cjs-overwrite": 98 | if (isESMImportDefault(originalImport)) { 99 | warnings.push({ 100 | type: "dependency-resolution-ambiguous-import", 101 | message: `Ambiguous default ESM import of a CJS export.\nImport: ${ describeNode(originalImport.identifier) }\nExport file: ${ exp.exported.getSourceFile().getFilePath() }`, 102 | imp: originalImport, 103 | exp, 104 | }); 105 | 106 | return []; 107 | } 108 | 109 | return [{ exp, key: star, aliasPath: [] }]; 110 | 111 | case "cjs-prop": 112 | if (isESMImportDefault(originalImport)) { 113 | warnings.push({ 114 | type: "dependency-resolution-ambiguous-import", 115 | message: `Ambiguous default ESM import of a CJS export.\nImport: ${ describeNode(originalImport.identifier) }\nExport file: ${ exp.exported.getSourceFile().getFilePath() }`, 116 | imp: originalImport, 117 | exp, 118 | }); 119 | 120 | return []; 121 | } 122 | 123 | return starRequested || exp.alias === exportName 124 | ? [{ exp, key: exp.alias, aliasPath: starRequested ? [exp.alias] : [] }] 125 | : []; 126 | 127 | default: 128 | return unexpected(exp); 129 | } 130 | }) 131 | .reduce((a, b) => [...a, ...b], []); 132 | }; 133 | 134 | // Per file — per export key — list of associated imports and their alias paths 135 | const index = new Map>(); 136 | const set = (target: SourceFile, exportKey: string | Star, imp: Import, aliasPath: string[]) => { 137 | const fData = index.get(target.getFilePath()) ?? new Map(); 138 | index.set(target.getFilePath(), fData); 139 | 140 | const imports = fData.get(exportKey) ?? []; 141 | fData.set(exportKey, imports); 142 | 143 | imports.push({ imp, aliasPath }); 144 | }; 145 | 146 | allImports.forEach(imp => { 147 | switch (imp.type) { 148 | case "esm-default": 149 | return resolveExport("default", imp.moduleSpecifier, imp, getImportFile(imp)).forEach(e => set(getExportFile(e.exp), e.key, imp, e.aliasPath)); 150 | 151 | case "esm-named": 152 | return resolveExport(imp.referencedExport, imp.moduleSpecifier, imp, getImportFile(imp)).forEach(e => set(getExportFile(e.exp), e.key, imp, e.aliasPath)); 153 | 154 | case "cjs-import": 155 | case "esm-equals": 156 | case "esm-dynamic": 157 | case "esm-namespace": 158 | return resolveExport(star, imp.moduleSpecifier, imp, getImportFile(imp)).forEach(e => set(getExportFile(e.exp), e.key, imp, e.aliasPath)); 159 | 160 | default: 161 | return unexpected(imp); 162 | } 163 | }); 164 | 165 | return { 166 | filterImports: predicate => allImports.filter(predicate), 167 | filterExports: predicate => allExports.filter(isValueExport).filter(predicate), 168 | resolveExportUses: exp => { 169 | const fileExports = index.get(getExportFile(exp).getFilePath()); 170 | switch (exp.type) { 171 | case "cjs-prop": 172 | case "esm-named": 173 | return fileExports?.get(exp.alias) ?? []; 174 | 175 | case "esm-default": 176 | return fileExports?.get("default") ?? []; 177 | 178 | case "cjs-overwrite": 179 | return fileExports?.get(star) ?? []; 180 | 181 | default: 182 | return unexpected(exp); 183 | } 184 | }, 185 | 186 | get warnings() { return [...warnings]; }, 187 | }; 188 | }; 189 | -------------------------------------------------------------------------------- /src/lib/resolveModule/resolveModule.spec.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { Project } from "ts-morph"; 3 | import { ResolveModule, setupModuleResolution } from "./resolveModule"; 4 | 5 | describe("Resolve module", () => { 6 | let baseUrl: string; 7 | let project: Project; 8 | let resolveModule: ResolveModule; 9 | 10 | beforeEach(async () => { 11 | baseUrl = "./whatso/ever"; 12 | project = new Project({ useInMemoryFileSystem: true, compilerOptions: { baseUrl } }); 13 | resolveModule = setupModuleResolution(project, "/"); 14 | }); 15 | 16 | it("should return null for external library", async () => { 17 | project.createSourceFile("node_modules/third-party-lib/index.js", ` 18 | export default "Hello"; 19 | `); 20 | project.createSourceFile("tst.js", ` 21 | import * as ThirdParty from 'third-party-lib'; 22 | `); 23 | 24 | expect(resolveModule("third-party-lib", "tst.js")).toBe(null); 25 | }); 26 | 27 | it("should return null for a specific external library file", async () => { 28 | project.createSourceFile("node_modules/third-party-lib/lib/file.js", ` 29 | export default "Hello"; 30 | `); 31 | project.createSourceFile("tst.js", ` 32 | import * as ThirdParty from 'third-party-lib/lib/file.js'; 33 | `); 34 | 35 | expect(resolveModule("third-party-lib/lib/file.js", "tst.js")).toBe(null); 36 | }); 37 | 38 | it("should return null for missing specific external library file", async () => { 39 | project.createSourceFile("tst.js", ` 40 | import * as ThirdParty from 'third-party-lib/lib/file.js'; 41 | `); 42 | 43 | expect(resolveModule("third-party-lib/lib/file.js", "tst.js")).toBe(null); 44 | }); 45 | 46 | it("should return null for non-code files", async () => { 47 | project.createSourceFile("styles.module.scss", ` 48 | .someClass { background: red; } 49 | `); 50 | project.createSourceFile("tst.js", ` 51 | import * as styles from './styles.module.scss'; 52 | `); 53 | expect(resolveModule("./styles.module.scss", "tst.js")).toBe(null); 54 | }); 55 | 56 | it("should resolve the relative module name", async () => { 57 | const targetSource = project.createSourceFile("target.js", ` 58 | export const HELLO = "world"; 59 | `); 60 | project.createSourceFile("dependant.js", ` 61 | import { HELLO } from "./target.js"; 62 | console.log(HELLO); 63 | `); 64 | expect(resolveModule("./target.js", "dependant.js")).toBe(targetSource); 65 | }); 66 | 67 | it("should resolve the file specified relative to base url", async () => { 68 | const targetSource = project.createSourceFile(join(baseUrl, "target.js"), ` 69 | export const HELLO = "world"; 70 | `); 71 | project.createSourceFile("dependant.js", ` 72 | import { HELLO } from "target.js"; 73 | console.log(HELLO); 74 | `); 75 | expect(resolveModule("target.js", "dependant.js")).toBe(targetSource); 76 | }); 77 | 78 | it("should warn if file requested by relative path can not be resolved", async () => { 79 | project.createSourceFile("dependant.js", ` 80 | import { HELLO } from "./target.js"; 81 | `); 82 | const res = resolveModule("./target.js", "dependant.js"); 83 | expect(res).toHaveProperty("type", "module-resolution"); 84 | }); 85 | 86 | it("should warn if a file that exists on the filesystem is excluded from project sources", async () => { 87 | project.getFileSystem().writeFileSync("./file.js", ""); 88 | 89 | project.createSourceFile("dependant.js", ` 90 | import * from "./file.js"; 91 | `); 92 | 93 | const res = resolveModule("./file.js", "dependant.js"); 94 | expect(res).toHaveProperty("type", "module-resolution"); 95 | }); 96 | 97 | it("should return null for a resolved file of an unsupported format", async () => { 98 | project.getFileSystem().writeFileSync("./file.png", ""); 99 | 100 | project.createSourceFile("dependant.js", ` 101 | import url from "./file.png"; 102 | `); 103 | 104 | const res = resolveModule("./file.png", "dependant.js"); 105 | expect(res).toBe(null); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/lib/resolveModule/resolveModule.ts: -------------------------------------------------------------------------------- 1 | import { Project, SourceFile, ts } from "ts-morph"; 2 | import { SUPPORTED_FILE_TYPES } from "../supportedFileTypes"; 3 | import { hasProp } from "../guards"; 4 | 5 | export type ResolveModule = (moduleName: string, containingFile: string) => SourceFile | ModuleResolutionWarning | null; 6 | const moduleResolutionType = "module-resolution"; 7 | export type ModuleResolutionWarning = { type: typeof moduleResolutionType, message: string }; 8 | 9 | const hasType = hasProp("type"); 10 | export const isModuleResolutionWarning = (val: unknown): val is ModuleResolutionWarning => hasType(val) && val.type === moduleResolutionType; 11 | 12 | export const setupModuleResolution = (project: Project, cwd: string): ResolveModule => { 13 | const { realpath, fileExists } = project.getModuleResolutionHost(); 14 | if (!realpath) { throw new Error("realpath not defined on module resolution host"); } 15 | 16 | const cache = ts.createModuleResolutionCache(cwd, realpath); 17 | const tsResolve = (moduleName: string, containingFile: string) => ts.resolveModuleName(moduleName, containingFile, project.getCompilerOptions(), project.getModuleResolutionHost(), cache); 18 | 19 | return (moduleName: string, containingFile: string) => { 20 | const resolved = tsResolve(moduleName, containingFile); 21 | const resolvedModule = resolved.resolvedModule; 22 | if (!resolvedModule) { 23 | if (!SUPPORTED_FILE_TYPES.some(ext => moduleName.endsWith(ext))) { 24 | return null; 25 | } 26 | 27 | if (!moduleName.startsWith(".")) { // TODO: this is not ideal, because missing project files resolved relative to baseurl would be considered external 28 | // Not a relative path 29 | return null; 30 | } 31 | 32 | const warning: ModuleResolutionWarning = { 33 | type: "module-resolution", 34 | message: `Could not resolve '${ moduleName }' referenced from '${ containingFile }'\nFailed resolutions: ${ JSON.stringify(resolved, null, 4) }`, 35 | }; 36 | return warning; 37 | } 38 | 39 | if (resolvedModule.isExternalLibraryImport) { return null; } 40 | 41 | const f = project.getSourceFile(resolvedModule.resolvedFileName); 42 | if (!f) { 43 | if (!SUPPORTED_FILE_TYPES.some(ext => moduleName.endsWith(ext))) { 44 | return null; 45 | } 46 | 47 | if (fileExists(resolvedModule.resolvedFileName)) { 48 | const warning: ModuleResolutionWarning = { 49 | type: "module-resolution", 50 | message: `Module '${ moduleName }' referenced from '${ containingFile }' is excluded from project configuration.`, 51 | }; 52 | return warning; 53 | } 54 | 55 | throw new Error(`File not found: ${ resolvedModule.resolvedFileName } referenced in ${ containingFile }.`); 56 | } 57 | 58 | return f; 59 | }; 60 | }; 61 | 62 | 63 | -------------------------------------------------------------------------------- /src/lib/supportedFileTypes.ts: -------------------------------------------------------------------------------- 1 | export const SUPPORTED_FILE_TYPES = [".js", ".jsx", ".ts", ".tsx", ".cjs", ".mjs"]; 2 | -------------------------------------------------------------------------------- /src/tasks/execute_from_local_registry.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # See ./publish_to_local_registry.sh for details of publishing the package locally 4 | 5 | # TODO: Ideally, test the tracker output is sane, instead of using `--version` 6 | 7 | if [ "${CI}" = "true" ]; then 8 | # In CI need to explicitly install the package. 9 | # For some reason, npx below doesn't use the registry specified in `NPM_CONFIG_REGISTRY` env. 10 | # Moreover, installing globally to avoid `ENOSELF` error in npm v6.14.15 11 | npm i radius-tracker@latest -g --registry=http://localhost:8080 12 | npx radius-tracker . --version 13 | else 14 | export NPM_CONFIG_REGISTRY=http://localhost:8080 15 | export NPM_CONFIG_PREFER_ONLINE=true 16 | npx -p radius-tracker@latest -- radius-tracker . --version 17 | fi; 18 | -------------------------------------------------------------------------------- /src/tasks/lib.ts: -------------------------------------------------------------------------------- 1 | import { work, cmd, detectLog } from "tasklauncher"; 2 | import { satisfies } from "semver"; 3 | 4 | import { engines as docsPackageEngines } from "../docs/package.json"; 5 | import { join, normalize } from "path"; 6 | import { isNotNull } from "../lib/guards"; 7 | 8 | type LintOptions = { fix?: boolean }; 9 | export const lint = (opt: LintOptions) => cmd(`eslint ./ --ext .ts,.tsx --ignore-path .gitignore --max-warnings 0${ opt.fix ? " --fix" : "" }`); 10 | 11 | type JestOptions = { foreground?: boolean }; // TODO: coverage 12 | export const jest = (opt: JestOptions) => cmd("jest ./src", opt.foreground ? () => Promise.resolve() : undefined); 13 | 14 | export const typecheck = cmd("tsc -p tsconfig.json --noEmit"); 15 | 16 | // For the purpose of test, build the docs if engine matches 17 | const buildDocs = satisfies(process.version, docsPackageEngines.node) 18 | ? cmd("yarn docs-build") 19 | : cmd("echo Doc build skipped due to engine mismatch"); 20 | 21 | export const test = work(jest, typecheck, lint, buildDocs); 22 | 23 | type BuildOptions = { 24 | test?: boolean, 25 | generateReportTemplate?: boolean, 26 | launchFromLocalRegistry?: boolean, 27 | }; 28 | export const buildTasks = (opt: BuildOptions) => { 29 | const generateReportTemplate = cmd("yarn cli report-generate-template"); 30 | 31 | const copyFiles: { from: string, to?: string }[] = [ 32 | { from: "./README.md" }, 33 | { from: "./package.json" }, 34 | 35 | // Non-js files for the report template generator 36 | ...["additional_styles.css", "generate_report_template.sh"] 37 | .flatMap(reportFile => ["cjs", "esm"].map(target => ({ 38 | from: `src/lib/cli/report/${ reportFile }`, 39 | to: `${ target }/cli/report/`, 40 | }))), 41 | ]; 42 | const copyTasks = work(...copyFiles.map(({ from, to }) => cmd(`cp ${ from } ${ normalize(join("build", to ?? from)) }`))); 43 | const buildLib = work(copyTasks) 44 | .after( 45 | cmd("tsc -b tsconfig-lib-cjs.json"), 46 | cmd("tsc -b tsconfig-lib-esm.json"), 47 | cmd("tsc -b tsconfig-lib-types.json"), 48 | ); 49 | 50 | const buildAll = work(...[ 51 | buildLib, 52 | opt.generateReportTemplate ? generateReportTemplate : null, 53 | ].filter(isNotNull)); 54 | 55 | const testTask = opt.test ? test : work(); 56 | const launchLocalRegistry = work(cmd("verdaccio -l 8080 -c ./src/tasks/verdaccio.yml", detectLog("http://localhost:8080"))) 57 | .after(cmd("rm -rf /tmp/verdaccio-storage")); 58 | 59 | const localregistryTask = opt.launchFromLocalRegistry 60 | ? work(cmd("./src/tasks/execute_from_local_registry.sh")).after( 61 | launchLocalRegistry, 62 | work(cmd("./src/tasks/publish_to_local_registry.sh")).after(launchLocalRegistry, buildAll, test), 63 | ) 64 | : work(); 65 | 66 | return work(buildAll, testTask, localregistryTask); 67 | }; 68 | -------------------------------------------------------------------------------- /src/tasks/publish_to_local_registry.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | NPM_AUTH_TOKEN="irrelevant" yarn publish build --non-interactive --registry=http://localhost:8080 3 | -------------------------------------------------------------------------------- /src/tasks/tasks.ts: -------------------------------------------------------------------------------- 1 | import yargs from "yargs"; 2 | import { hideBin } from "yargs/helpers"; 3 | import { exec } from "tasklauncher"; 4 | import { test, lint, jest, buildTasks } from "./lib"; 5 | 6 | yargs(hideBin(process.argv)) 7 | .command( 8 | "test", "Execute the test suite", 9 | () => exec(test), 10 | ) 11 | .command( 12 | "lint", "Run eslint", 13 | args => args 14 | .option("fix", { 15 | type: "boolean", 16 | default: false, 17 | }), 18 | args => exec(lint, args), 19 | ) 20 | .command( 21 | "jest", "Run jest", 22 | () => exec(jest, { foreground: true }), 23 | ) 24 | .command( 25 | "build", "Build the package", 26 | args => args 27 | .option("test", { 28 | type: "boolean", 29 | default: true, 30 | }) 31 | .option("generateReportTemplate", { 32 | type: "boolean", 33 | default: true, 34 | }) 35 | .option("launchFromLocalRegistry", { 36 | type: "boolean", 37 | default: true, 38 | }), 39 | args => exec(buildTasks, args), 40 | ) 41 | .strictCommands() 42 | .strictOptions() 43 | .demandCommand(1, "Specify a command to execute") 44 | .scriptName("npm run task") 45 | .parse(); 46 | -------------------------------------------------------------------------------- /src/tasks/verdaccio.yml: -------------------------------------------------------------------------------- 1 | store: 2 | memory: 3 | limit: 1000 4 | 5 | uplinks: 6 | npmjs: 7 | url: https://registry.npmjs.org/ 8 | 9 | packages: 10 | 'radius-tracker': 11 | access: $all 12 | publish: $anonymous 13 | 14 | '**': 15 | access: $all 16 | proxy: npmjs 17 | 18 | log: 19 | - { type: stdout, format: pretty, level: warn } 20 | -------------------------------------------------------------------------------- /tsconfig-base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "sourceMap": true, 6 | "strict": true, 7 | "lib": ["esnext"], 8 | "types": ["node", "jest"], 9 | "isolatedModules": true, 10 | "esModuleInterop": false, 11 | "allowSyntheticDefaultImports": false, 12 | "downlevelIteration": false, 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "skipLibCheck": true, 16 | "rootDir": "src", 17 | "allowJs": false, 18 | "noImplicitAny": true, 19 | "noImplicitReturns": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "importHelpers": true, 23 | "noEmitHelpers": true, 24 | "noEmitOnError": true, 25 | "noUncheckedIndexedAccess": true, 26 | "allowUnreachableCode": false, 27 | "forceConsistentCasingInFileNames": true, 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig-lib-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base.json", 3 | "compilerOptions": { 4 | "outDir": "build/cjs", 5 | "incremental": false, 6 | "declaration": false, 7 | "sourceMap": false, 8 | "declarationMap": false, 9 | "rootDir": "src/lib", 10 | "baseUrl": "src/lib" 11 | }, 12 | "include": ["src/lib"], 13 | "exclude": ["node_modules", "src/**/*.spec.ts"], 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig-lib-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-lib-cjs.json", 3 | "compilerOptions": { 4 | "outDir": "build/esm", 5 | "module": "ESNext" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig-lib-types.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base.json", 3 | "compilerOptions": { 4 | "outDir": "build/types", 5 | "incremental": false, 6 | "declaration": true, 7 | "emitDeclarationOnly": true, 8 | "rootDir": "src/lib", 9 | "baseUrl": "src/lib" 10 | }, 11 | "include": ["src/lib"], 12 | "exclude": ["node_modules", "src/**/*.spec.ts"], 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig-base.json", 3 | "compileOnSave": false, 4 | "compilerOptions": { 5 | "outDir": "./.tsbuildinfo/build-out", 6 | "tsBuildInfoFile": "./.tsbuildinfo/buildinfo", 7 | "incremental": true, 8 | }, 9 | "include": [ 10 | "src" 11 | ], 12 | "exclude": [ 13 | "node_modules" 14 | ], 15 | } 16 | --------------------------------------------------------------------------------