И снова здравствуйте. На связи Омельницкий Сергей. Сегодня я поделюсь с Вами одной из своих головных болей, а именно — что делать, когда проект пишут много разноуровневых программистов на примере angular приложения.



Так повелось, что я долгое время работал только со своей командой, где мы уже давно согласовывали правила оформления, комментирования, отступы и т.п. Притерлись к ним и жили дружно и счастливо. На радостях я даже опубликовал статью на Хабр по нашему кодстайлу. Поэтому из чего-то магического мы использовали только tslint на пре-коммит.


И тут мы разрослись. Появился новый проект с унаследованным кодом, а к нему в придачу новые разработчики в размере 4-х добрых молодцев. И чет тут пошло не по плану.



Я думаю многие знают, что работа с унаследованным кодом не кайф. На моей памяти я получил только один проект от которого был в восторге, а остальное… Так о чем я?) Ах да.


Откровенно говоря архитектура в проекте оставляла желать лучшего, а комментарии и типизация нам только снилась. В какой-то момент я приуныл от того, что наша документашка по правилам оформления не работает, комментарии не пишутся, тип — что это?). Вот с этим нужно было что-то делать.


Для тех кому не терпится узнать все шаги сразу:
  • Мы разделили tslint на мягкие правила ( для pre-commit ) и жесткие правила ( для ide, чтоб напоминала о том, что разработчики забыли сделать )


  • Повесили на pre-commit автофиксацию возможных правил от жесткого tslint


  • Написали правила для prettier


  • Танцевали с бубном чтоб запустить ng lint с lint-staged



Шаг первый — разделяй и властвуй


Когда мне пришла идея ужесточить правила линтера я подумал, что мы повесимся. Код-то унаследованный. В нем нужно разбираться, а в таком объеме можно закопаться. Было принято решения создать 2-й линтер для ide, которое бы мозолил глаза и заставлял писать jsdoc для методов и св-в, писать интерфейсы или зласчастный onPush и т.п.


Итак в корне у нас начало лежать 2 tslin файла:


tsconfig.json
{
  "rulesDirectory": [
    "node_modules/codelyzer"
  ],
  "rules": {
    "arrow-return-shorthand": true,
    "callable-types": true,
    "class-name": true,
    "comment-format": [
      true,
      "check-space"
    ],
    "curly": true,
    "deprecation": {
      "severity": "warn"
    },
    "eofline": true,
    "forin": true,
    "import-blacklist": [
      true,
      "rxjs/Rx"
    ],
    "import-spacing": true,
    "indent": [
      true,
      "spaces"
    ],
    "interface-over-type-literal": true,
    "label-position": true,
    "max-line-length": [
      true,
      200
    ],
    "member-access": false,
    "member-ordering": [
      true,
      {
        "order": [
          "static-field",
          "instance-field",
          "static-method",
          "instance-method"
        ]
      }
    ],
    "no-arg": true,
    "no-bitwise": true,
    "no-console": [
      true,
      "debug",
      "info",
      "time",
      "timeEnd",
      "trace"
    ],
    "no-construct": true,
    "no-debugger": true,
    "no-duplicate-super": true,
    "no-empty": false,
    "no-empty-interface": true,
    "no-eval": true,
    "no-inferrable-types": [
      false,
      "ignore-params"
    ],
    "no-duplicate-imports": true,
    "no-misused-new": true,
    "no-non-null-assertion": true,
    "no-redundant-jsdoc": true,
    "no-shadowed-variable": false,
    "no-string-literal": false,
    "no-string-throw": true,
    "no-switch-case-fall-through": true,
    "no-trailing-whitespace": [
      true,
      "ignore-comments",
      "ignore-jsdoc"
    ],
    "no-unnecessary-initializer": true,
    "no-unused-expression": true,
    "no-use-before-declare": false,
    "no-var-keyword": true,
    "object-literal-sort-keys": false,
    "one-line": [
      true,
      "check-open-brace",
      "check-catch",
      "check-else",
      "check-whitespace"
    ],
    "prefer-const": true,
    "quotemark": [
      true,
      "single"
    ],
    "radix": false,
    "semicolon": [
      true,
      "always"
    ],
    "triple-equals": [
      true,
      "allow-null-check"
    ],
    "typedef-whitespace": [
      true,
      {
        "call-signature": "nospace",
        "index-signature": "nospace",
        "parameter": "nospace",
        "property-declaration": "nospace",
        "variable-declaration": "nospace"
      }
    ],
    "unified-signatures": true,
    "variable-name": false,
    "whitespace": [
      true,
      "check-branch",
      "check-decl",
      "check-operator",
      "check-separator",
      "check-type"
    ],
    "directive-selector": [
      true,
      "attribute",
      "app",
      "camelCase"
    ],
    "component-selector": [
      true,
      "element",
      "app",
      "kebab-case"
    ],
    "no-output-on-prefix": false,
    "no-inputs-metadata-property": true,
    "no-outputs-metadata-property": true,
    "no-host-metadata-property": true,
    "no-input-rename": false,
    "no-output-rename": true,
    "use-lifecycle-interface": true,
    "use-pipe-transform-interface": true,
    "component-class-suffix": true,
    "directive-class-suffix": true,
    "no-consecutive-blank-lines": true
  }
}

tslint.ide_only.json
{
  "rulesDirectory": [
    "node_modules/codelyzer"
  ],
  "rules": {
    "completed-docs": [
      true,
      {
        "properties": true,
        "methods": true
      }
    ],
    "no-angle-bracket-type-assertion": true,
    "no-any": true,
    "prefer-output-readonly": true,
    "prefer-on-push-component-change-detection": true,
    "array-type": [
      true,
      "array"
    ],
    "typedef": [
      true,
      "call-signature",
      "arrow-call-signature"
    ],
    "arrow-return-shorthand": true,
    "callable-types": true,
    "class-name": true,
    "comment-format": [
      true,
      "check-space"
    ],
    "curly": true,
    "deprecation": {
      "severity": "warn"
    },
    "eofline": true,
    "forin": true,
    "import-blacklist": [
      true,
      "rxjs/Rx"
    ],
    "import-spacing": true,
    "indent": [
      true,
      "spaces"
    ],
    "interface-over-type-literal": true,
    "label-position": true,
    "max-line-length": [
      true,
      200
    ],
    "member-access": [
      true,
      "check-parameter-property",
      "check-accessor"
    ],
    "member-ordering": [
      true,
      {
        "order": [
          "public-static-field",
          "protected-static-field",
          "private-static-field",
          "public-instance-field",
          "protected-instance-field",
          "private-instance-field",
          "constructor",
          "public-static-method",
          "protected-static-method",
          "private-static-method",
          "public-instance-method",
          "protected-instance-method",
          "private-instance-method"
        ]
      }
    ],
    "no-arg": true,
    "no-bitwise": true,
    "no-console": true,
    "no-construct": true,
    "no-debugger": true,
    "no-duplicate-super": true,
    "no-empty": false,
    "no-empty-interface": true,
    "no-duplicate-switch-case": true,
    "no-eval": true,
    "no-inferrable-types": [
      false,
      "ignore-params"
    ],
    "no-duplicate-imports": true,
    "one-variable-per-declaration": true,
    "no-misused-new": true,
    "no-non-null-assertion": true,
    "prefer-template": [
      true,
      "allow-single-concat"
    ],
    "ordered-imports": true,
    "no-redundant-jsdoc": true,
    "no-shadowed-variable": false,
    "no-string-literal": false,
    "no-string-throw": true,
    "no-switch-case-fall-through": true,
    "no-trailing-whitespace": [
      true,
      "ignore-comments",
      "ignore-jsdoc"
    ],
    "ban": [
      true,
      {
        "name": [
          "Object",
          "assign"
        ],
        "message": "Используйте cloneDeep (lodash) для копирования объекта"
      }
    ],
    "max-classes-per-file": [
      true,
      1
    ],
    "cyclomatic-complexity": [
      true,
      6
    ],
    "static-this": true,
    "no-unnecessary-initializer": true,
    "no-unused-expression": true,
    "no-var-keyword": true,
    "object-literal-sort-keys": false,
    "one-line": [
      true,
      "check-open-brace",
      "check-catch",
      "check-else",
      "check-whitespace"
    ],
    "prefer-const": true,
    "quotemark": [
      true,
      "single"
    ],
    "radix": false,
    "semicolon": [
      true,
      "always"
    ],
    "triple-equals": [
      true,
      "allow-null-check"
    ],
    "typedef-whitespace": [
      true,
      {
        "call-signature": "nospace",
        "index-signature": "nospace",
        "parameter": "nospace",
        "property-declaration": "nospace",
        "variable-declaration": "nospace"
      }
    ],
    "unified-signatures": true,
    "variable-name": false,
    "whitespace": [
      true,
      "check-branch",
      "check-decl",
      "check-operator",
      "check-separator",
      "check-type"
    ],
    "directive-selector": [
      true,
      "attribute",
      "app",
      "camelCase"
    ],
    "component-selector": [
      true,
      "element",
      "app",
      "kebab-case"
    ],
    "no-output-on-prefix": false,
    "no-inputs-metadata-property": true,
    "no-outputs-metadata-property": true,
    "no-host-metadata-property": true,
    "no-input-rename": false,
    "no-output-rename": true,
    "use-lifecycle-interface": true,
    "use-pipe-transform-interface": true,
    "component-class-suffix": true,
    "directive-class-suffix": true,
    "no-consecutive-blank-lines": true
  }
}

В файле src/tslint мы заменили стандартный tslint на ide


src/tslint.json
{
    "extends": "../tslint.ide_only.json",
    "rules": {
        "directive-selector": [
            true,
            "attribute",
            "app",
            "camelCase"
        ],
        "component-selector": [
            true,
            "element",
            "app",
            "kebab-case"
        ]
    }
}

И поправил запуск нашего линтера в скритах package.json


ng lint --tslint-config ./tslint.json --fix`

После чего мы стали вешаться от подчеркнутых вещах, которые нужно править.


Шаг второй — поправить пару моментов



У tslint есть правила с has fixer. Так давай воспользуемся.


tslint --project tslint.ide_only.json --fix --force

Здесь мы запускаем правила жесткого линтера с автофиксацией доступных параметров и говорим, чтобы эта команда не возвращала ошибок ( тут наша цель все-таки делать автоисправление ).


Шаг третий — пиши красиво


Когда каждый пишет в своей манере это в конечном счете утомляет. Код нужно писать так, чтоб казалось, что это делает один человек. Для этого я прикрутил prettier, со следующими настройками:


.prettierr.yaml
printWidth: 200     # Максимальное кол-во символов в строке
tabWidth: 2         # Пробелов в Табе
singleQuote: true   # Использовать одинарные кавычки
trailingComma: all  # Использовать запятые где возможно
arrowParens: always # Стрелочные ф-ии выглядят (x) => x
overrides:
  - files: "*.ts"   # Проверка файлов *.ts
    options:
      parser: typescript  # Язык в файлах *.ts

И добавил команду: prettier --write --config .prettierr.yaml


Шаг четвертый — И как ты прикажешь все это запускать?


Давайте теперь подробнее разберем как же все это запускать. Для того, чтоб это все работало нам нужно скачать следующие либы:


npm i -D prettier lint-staged husky

С помощью husky мы повесим запуск наших команд на git хук — pre-commit. lint-staged будет запускать нам команды в зависимости от измененных файлов ( так же подставлять эти файлы к нам в команды).


Хотелось бы еще сразу обрисовать проблему, с которой столкнулся я. У нас в проекте мы используем ng lint. Когда мы используем его в связке с lint-staged, то в нашу команду добавляются измененные файлы. У ng lint есть для этого ключ --files, но, как я понял, он не видит пачку файлов, и ему нужно на каждый файл добавлять этот ключ. Для этого мне пришлось создать файл:


lint.sh
#!/bin/bash

PROJECT=$1
shift
SOURCES=$@
DESTINATIONS=""
DELIMITER=""

for src in $SOURCES
do
    DELIMITER=" --files "
    DESTINATIONS="$DESTINATIONS$DELIMITER${src}"
done

ng lint $PROJECT --tslint-config ./tslint.json $DESTINATIONS

Для запуска этого файла мы должны передать название проекта. Оно находится в файле angular.json в свойстве project. В моем случае это partner-account и partner-account-e2e. Мне нужен 1-й.


Вернусь к настройке. Наш package.json теперь выглядит так:


  "husky": {
    "hooks": {
      "pre-commit": "lint-staged --relative"
    }
  },
  "lint-staged": {
    "*.{ts,js}": [
      "prettier --write --config .prettierr.yaml",
      "tslint --project tslint.ide_only.json --fix --force",
      "sh lint.sh partner-account",
      "git add"
    ],
    "*.{html,scss,css}": [
      "prettier --write --config .prettierr.yaml",
      "git add"
    ]
  },

Обратите внимание на lint-staged --relative. Параметр --relative там обязателен. Теперь при коммите у нас запускается lint-staged. Он в свою очередь отбирает файлы и запускает в зависимости он них список команд.


К сожалению это не отменяет ревью кода, но он стал гораздо чище. Замечу, что я реже стал напоминать разработчикам про модификаторы доступа, описание методов и св-в, а их творчество стало написано в едином стиле ( ну почти :D ).


P.S. — Спасибо за картинки нашему PM.

Комментарии (5)


  1. ilnuribat
    20.08.2019 16:14

    eslint.org/blog/2019/01/future-typescript-eslint
    Есть смысл не использовать tslint, а сразу прикрутить eslint



  1. VolCh
    21.08.2019 06:30

    Почему предпочитаете интерфейсы типам?


  1. Valery4
    23.08.2019 22:24
    +1

    Параметр --relative там обязателен

    Можете пояснить почему?
    У нас тоже исользуютеся Husky, только «pre-push» хук. И всё работает без ключа relative.
    Возможно дело в том что у нас не Angular CLI проект и вообще не Angular?


    1. Sergamers Автор
      23.08.2019 23:01
      +1

      Ага. Этот параметр необходим, чтоб путь был не от корня, а от папки проекта. Это важно только для команды `ng lint ...`, т.к. он начнет ругаться, что файлы не являются частью проекта.