Shuunen/repo-checker

View on GitHub
src/files/package.file.ts

Summary

Maintainability
A
2 hrs
Test Coverage
/* eslint-disable security/detect-non-literal-regexp */
import { ellipsis } from 'shuutils'
import { dataDefaults } from '../constants'
import { FileBase } from '../file'
import { log } from '../logger'
import { findInFolder, join } from '../utils'

// eslint-disable-next-line no-restricted-syntax
export class PackageJsonFile extends FileBase {

  // eslint-disable-next-line max-statements
  private checkProperties () {
    this.couldContainsSchema('https://json.schemastore.org/package')
    this.couldContains('a "bugs" property', this.regexForStringProp('bugs'))
    this.couldContains('a "description" property', this.regexForStringProp('description'))
    this.couldContains('a "files" property', this.regexForArrayProp('files'))
    this.couldContains('a "homepage" property', this.regexForStringProp('homepage'))
    this.couldContains('a "keywords" property', this.regexForArrayProp('keywords'))
    this.couldContains('a "private" property', this.regexForBooleanProp('private'))
    this.couldContains('a "repository" property', this.regexForObjectProp('repository'))
    this.shouldContains('a "author" property', this.regexForStringProp('author'))
    this.shouldContains('a "name" property', this.regexForStringProp('name'))
    this.shouldContains('a "version" property', this.regexForStringProp('version'))
    const hasLicence = this.shouldContains('a "license" property', this.regexForStringProp('license'))
    if (hasLicence) this.shouldContains(`a ${this.data.license} license`, this.regexForStringValueProp('license', this.data.license))
  }

  private checkEchoes () {
    ['build', 'check', 'lint', 'test'].forEach(task => {
      if (!this.fileContent.includes(`"${task}":`)) return
      this.couldContains(`a final echo for task "${task}"`, new RegExp(`"${task}": ".+ && echo [\\w\\s]*${task} \\w+"`, 'u'))
    })
  }

  private checkScriptsTs () {
    if (!this.data.isUsingTypescript) return
    this.shouldContains('a typescript build or check', /\btsc\b/u)
    if (!this.fileContent.includes('vue-cli-service lint')) this.shouldContains('a typescript lint', /eslint [^"]+\.ts/u)
  }

  private checkScriptsPrePost () {
    this.couldContains('a pre-script for version automation', /"preversion": "/u, 1, 'like : "preversion": "pnpm check",')
    if (this.data.isPublishedPackage) this.couldContains('a post-script for version automation', /"postversion": "/u, 1, 'like : "postversion": "git push && git push --tags && npm publish",')
    else this.couldContains('a post-script for version automation', /"postversion": "/u, 1, 'like : "postversion": "git push && git push --tags",')
    if (this.fileContent.includes('"prepublish"')) this.shouldContains('"prepare" instead of "prepublish" (deprecated)', /"prepublish"/u, 0)
  }

  private checkScripts () {
    this.checkScriptsTs()
    this.shouldContains('a script section', this.regexForObjectProp('scripts'))
    this.checkScriptsPrePost()
    if (this.fileContent.includes('watchlist')) this.couldContains('watchlist eager param', /-eager --/u, 1, 'like watchlist src tests -eager -- pnpm test')
    if (this.data.isUsingDependencyCruiser) this.shouldContains('a depcruise usage', /depcruise\s/u, 1, false, 'like "depcruise src --config"')
    if (this.fileContent.includes('"build": "') && this.data.isUsingShuutils) this.couldContains('a unique-mark task', /unique-mark/u, 1, 'like "mark": "unique-mark public/my-file && echo mark success",')
    const isOk = this.couldContains('pnpm instead of npm run', /npm run/u, 0, 'use pnpm instead of npm run for performance', true)
    if (!isOk && this.canFix) this.fileContent = this.fileContent.replace(/npm run/gu, 'pnpm').replace(/pnpm (?<task>[\w:]+) -- -/gu, 'pnpm $<task> -') // don't use -- for pnpm
    this.couldContains('a check script', /"check": "/u, 1, 'like "check": "pnpm build && pnpm lint ...')
    this.couldContains('no ci script', /"ci": "/u, 0, 'avoid using "ci" script, use "check" instead')
  }

  private suggestAlternatives () {
    this.couldContains('no fat color dependency, use shuutils or nanocolors', /"(?:chalk|colorette|colors)"/u, 0)
    this.couldContains('no fat fs-extra dependency, use native fs', /"fs-extra"/u, 0)
    this.couldContains('no utopian shuunen-stack dependency', /"shuunen-stack"/u, 0)
    this.couldContains('no fat & slow jsdom dependency, use happy-dom instead', /jsdom/u, 0)
    this.couldContains('no fat task runner, use pnpm xyz && pnpm abc for sequential or zero-deps package : npm-parallel', /"npm-run-all"/u, 0)
    if (this.fileContent.includes('esbuild-plugin-run')) this.couldContains('not fat ts runner, use "typescript-run" like "dev": "ts-run src --watch" or "ts-run src -w src another-folder"')
  }

  private regexForStringProp (name = ''): RegExp {
    return new RegExp(`"${name}": ".+"`, 'u')
  }

  private regexForStringValueProp (name = '', value = ''): RegExp {
    return new RegExp(`"${name}": "${value}"`, 'u')
  }

  private regexForObjectProp (name = ''): RegExp {
    return new RegExp(`"${name}": \\{\n`, 'u')
  }

  private regexForArrayProp (name = ''): RegExp {
    return new RegExp(`"${name}": \\[\n`, 'u')
  }

  private regexForBooleanProp (name = ''): RegExp {
    return new RegExp(`"${name}": (?:false|true),\n`, 'u')
  }

  private checkDependenciesTesting () {
    const hasUt = /"(?<tool>mocha|uvu|vitest)"/u.exec(this.fileContent)?.groups?.tool !== undefined
    this.test(hasUt, 'one unit testing dependency from : vitest, mocha, uvu', true)
    const hasCoverage = /"(?<tool>c8|@vitest\/coverage-c8|@vitest\/coverage-v8|nyc)"/u.exec(this.fileContent)?.groups?.tool !== undefined
    this.test(hasCoverage, 'one coverage dependency from : nyc, c8, v8', true)
  }

  private checkDependenciesUnwanted () {
    if (this.data.shouldAvoidSass) this.shouldContains('no sass dependency (fat & useless)', /sass/u, 0)
    this.shouldContains('no cross-var dependency (old & deprecated)', /"cross-var"/u, 0)
    this.shouldContains('no tslint dependency (deprecated)', /tslint/u, 0)
    this.shouldContains('no eslint-plugin-promise 5 dependency (require eslint 7)', /"eslint-plugin-promise": "\^?5/u, 0)
  }

  private checkDependenciesPrecision () {
    const hasNoPatch = this.couldContains('no patch precision', /\s{4}".+":\s"\^?\d+\.\d+\.\d+"/gu, 0, 'patch precision is rarely useful', true)
    // eslint-disable-next-line prefer-named-capture-group, regexp/prefer-named-capture-group
    if (!hasNoPatch && this.canFix) this.fileContent = this.fileContent.replace(/(\s{4}".+":\s"\^?\d+\.\d+)\.\d+/gu, '$1')
  }

  private checkDependenciesUsagesEslint () {
    if (this.data.isUsingTailwind) this.couldContains('an eslint tailwindcss plugin', /"eslint-plugin-tailwindcss"/u)
    const isUsingEslintCli = this.fileContent.includes('eslint ')
    if (isUsingEslintCli) {
      const hasCacheFlag = this.couldContains('eslint cache flag', /eslint[^\n"&']+--cache/u, 1, 'like "eslint --cache ..."', true)
      if (!hasCacheFlag && this.canFix) this.fileContent = this.fileContent.replace('eslint ', 'eslint --cache ')
      this.couldContains('no eslint ignore flag, solution 1 : just remove it (useless most of the time, check "DEBUG=eslint:cli-engine npx eslint ..." to see linted files) or solution 2 : use ignorePatterns inside .eslintrc.json. The objective here is to let the eslint cli & vscode eslint use the same config', /eslint[^\n"&']+--ignore-path/u, 0)
    }
  }

  public async start (): Promise<void> {
    const hasFile = await this.checkFileExists('package.json')
    if (!hasFile) return
    await this.inspectFile('package.json')
    await this.checkMainFile()
    this.checkProperties()
    this.checkScripts()
    this.checkEchoes()
    await this.checkDependencies()
    this.suggestAlternatives()
  }

  private async checkMaxSize (filePath: string, maxSizeKo: number): Promise<void> {
    const hasMaxSize = this.test(maxSizeKo !== dataDefaults.maxSizeKo, 'main file maximum size is specified in data file (ex: maxSizeKo: 100)', true)
    if (!hasMaxSize) return
    const hasFile = await this.checkFileExists(filePath)
    this.test(hasFile, `main file specified in package.json (${filePath}) exists on disk (be sure to build before run repo-check)`)
    /* c8 ignore next */
    if (!hasFile) return
    const sizeKo = await this.getFileSizeInKo(filePath)
    const isSizeOk = sizeKo <= maxSizeKo
    this.test(isSizeOk, `main file size (${sizeKo}ko) should be less or equal to max size allowed (${maxSizeKo}Ko)`)
  }

  private async checkMainFile (): Promise<void> {
    const mainFilePath = /"main": "(?<path>.*)"/u.exec(this.fileContent)?.groups?.path
    if (mainFilePath === undefined) {
      log.debug('no main file specified in package.json')
      return
    }
    await this.checkMaxSize(mainFilePath, this.data.maxSizeKo)
  }

  private async checkDependenciesUsagesUvu (): Promise<void> {
    const badAsserts = await findInFolder(join(this.folderPath, 'tests'), /from 'assert'/u)
    const logMaxLength = 50
    this.test(badAsserts.length === 0, `assert dependency used in "${ellipsis(badAsserts.join(','), logMaxLength)}", import { equal } from 'uvu/assert' instead (works also as deepEqual alternative)`)
  }

  private async checkDependenciesUsagesNode () {
    const badUsages = await findInFolder(this.folderPath, /ts-node(?:-esm)? (?:(?!transpileOnly).)*$/gmu)
    this.test(badUsages.length === 0, `ts-node without --transpileOnly detected in file(s) : ${badUsages.join(', ')}`, true)
  }

  private async checkDependenciesUsages () {
    if (this.data.isUsingEslint) this.checkDependenciesUsagesEslint()
    if (this.fileContent.includes('"uvu"')) await this.checkDependenciesUsagesUvu()
    if (this.fileContent.includes('ts-node')) await this.checkDependenciesUsagesNode()
  }

  private async checkDependencies (): Promise<void> {
    const hasDependencies = this.checkContains(this.regexForObjectProp('dependencies'))
    const hasDevelopmentDependencies = this.checkContains(this.regexForObjectProp('devDependencies'))
    /* c8 ignore next */
    if (!hasDependencies && !hasDevelopmentDependencies) return
    this.checkDependenciesUnwanted()
    this.checkDependenciesPrecision()
    this.checkDependenciesTesting()
    await this.checkDependenciesUsages()
  }

}