create.rst (14019B)
1 Adding a New Linter to the Tree 2 =============================== 3 4 Linter Requirements 5 ------------------- 6 7 For a linter to be integrated into the mozilla-central tree, it needs to have: 8 9 * Any required dependencies should be installed as part of ``./mach bootstrap`` 10 * A ``./mach lint`` interface 11 * Running ``./mach lint`` command must pass (note, linters can be disabled for individual directories) 12 * Taskcluster/Treeherder integration 13 * In tree documentation (under ``docs/code-quality/lint``) to give a basic summary, links and any other useful information 14 * Unit tests (under ``tools/lint/test``) to make sure that the linter works as expected and we don't regress. 15 16 The review group in Phabricator is ``#linter-reviewers``. 17 18 Linter Basics 19 ------------- 20 21 A linter is a yaml file with a ``.yml`` extension. Depending on how the type of linter, there may 22 be python code alongside the definition, pointed to by the 'payload' attribute. 23 24 Here's a trivial example: 25 26 no-eval.yml 27 28 .. code-block:: yaml 29 30 EvalLinter: 31 description: Ensures the string eval doesn't show up. 32 extensions: ['js'] 33 type: string 34 payload: eval 35 36 Now ``no-eval.yml`` gets passed into :func:`LintRoller.read`. 37 38 39 Linter Types 40 ------------ 41 42 There are four types of linters, though more may be added in the future. 43 44 1. string - fails if substring is found 45 2. regex - fails if regex matches 46 3. external - fails if a python function returns a non-empty result list 47 4. structured_log - fails if a mozlog logger emits any lint_error or lint_warning log messages 48 49 As seen from the example above, string and regex linters are very easy to create, but they 50 should be avoided if possible. It is much better to use a context aware linter for the language you 51 are trying to lint. For example, use eslint to lint JavaScript files, use ruff to lint Python 52 files, etc. 53 54 Which brings us to the third and most interesting type of linter, 55 external. External linters call an arbitrary python function which is 56 responsible for not only running the linter, but ensuring the results 57 are structured properly. For example, an external type could shell out 58 to a 3rd party linter, collect the output and format it into a list of 59 :class:`Issue` objects. The signature for this python 60 function is ``lint(files, config, **kwargs)``, where ``files`` is a list of 61 files to lint and ``config`` is the linter definition defined in the ``.yml`` 62 file. 63 64 Structured log linters are much like external linters, but suitable 65 for cases where the linter code is using mozlog and emits 66 ``lint_error`` or ``lint_warning`` logging messages when the lint 67 fails. This is recommended for writing novel gecko-specific lints. In 68 this case the signature for lint functions is ``lint(files, config, logger, 69 **kwargs)``. 70 71 72 Linter Definition 73 ----------------- 74 75 Each ``.yml`` file must have at least one linter defined in it. Here are the supported keys: 76 77 * description - A brief description of the linter's purpose (required) 78 * type - One of 'string', 'regex' or 'external' (required) 79 * payload - The actual linting logic, depends on the type (required) 80 * include - A list of file paths that will be considered (optional) 81 * exclude - A list of file paths or glob patterns that must not be matched (optional) 82 * extensions - A list of file extensions to be considered (optional) 83 * exclude_extensions - A list of file extensions to be excluded (optional) 84 * setup - A function that sets up external dependencies (optional) 85 * support-files - A list of glob patterns matching configuration files (optional) 86 * find-dotfiles - If set to ``true``, run on dot files (.*) (optional) 87 * ignore-case - If set to ``true`` and ``type`` is regex, ignore the case (optional) 88 89 Note that a linter may not have both ``extensions`` and ``exclude_extensions`` specified at the 90 same time. 91 92 In addition to the above, some ``.yml`` files correspond to a single lint rule. For these, the 93 following additional keys may be specified: 94 95 * message - A string to print on infraction (optional) 96 * hint - A string with a clue on how to fix the infraction (optional) 97 * rule - An id string for the lint rule (optional) 98 * level - The severity of the infraction, either 'error' or 'warning' (optional) 99 100 For structured_log lints the following additional keys apply: 101 102 * logger - A StructuredLog object to use for logging. If not supplied 103 one will be created (optional) 104 105 106 Example 107 ------- 108 109 Here is an example of an external linter that shells out to the Python ruff linter, 110 let's call the file ``ruff_lint.py`` (`in-tree version <https://searchfox.org/mozilla-central/source/tools/lint/python/ruff.py>`__): 111 112 .. code-block:: python 113 114 import json 115 import os 116 import subprocess 117 from collections import defaultdict 118 from shutil import which 119 120 from mozlint import result 121 122 123 RUFF_NOT_FOUND = """ 124 Could not find ruff! Install ruff and try again. 125 """.strip() 126 127 128 def lint(paths, config, **lintargs): 129 binary = which('ruff') 130 if not binary: 131 print(RUFF_NOT_FOUND) 132 return 1 133 134 135 cmd = ["ruff", "check", "--force-exclude", "--format=json"] + paths 136 output = subprocess.run(cmd, stdout=subprocess.PIPE, env=os.environ).output 137 138 # all passed 139 if not output: 140 return [] 141 142 try: 143 issues = json.loads(output) 144 except json.JSONDecodeError: 145 log.error(f"Could not parse output: {output}") 146 147 results = [] 148 for issue in issues: 149 # convert ruff's format to mozlint's format 150 res = { 151 "path": issue["filename"], 152 "lineno": issue["location"]["row"], 153 "column": issue["location"]["column"], 154 "lineoffset": issue["end_location"]["row"] - issue["location"]["row"], 155 "message": issue["message"], 156 "rule": issue["code"], 157 "level": "error", 158 } 159 160 if issue["fix"]: 161 res["hint"] = issue["fix"]["message"] 162 163 results.append(result.from_config(config, **res)) 164 165 return {"results": results, "fixed": fixed} 166 167 Now here is the linter definition that would call it: 168 169 .. code-block:: yaml 170 171 ruff: 172 description: Python Linter 173 include: ["."] 174 extensions: ["py"] 175 support-files: 176 - "**/.ruff.toml" 177 - "**/ruff.toml" 178 - "**/pyproject.toml" 179 type: external 180 payload: py.ruff:lint 181 182 Notice the payload has two parts, delimited by ':'. The first is the module 183 path, which ``mozlint`` will attempt to import. The second is the object path 184 within that module (e.g, the name of a function to call). It is up to consumers 185 of ``mozlint`` to ensure the module is in ``sys.path``. Structured log linters 186 use the same import mechanism. 187 188 The ``support-files`` key is used to list configuration files or files related 189 to the running of the linter itself. If using ``--outgoing`` or ``--workdir`` 190 and one of these files was modified, the entire tree will be linted instead of 191 just the modified files. 192 193 Result definition 194 ----------------- 195 196 When generating the list of results, the following values are available. 197 198 .. csv-table:: 199 :header: "Name", "Description", "Optional" 200 :widths: 20, 40, 10 201 202 "linter", "Name of the linter that flagged this error", "" 203 "path", "Path to the file containing the error", "" 204 "message", "Text describing the error", "" 205 "lineno", "Line number that contains the error", "" 206 "column", "Column containing the error", "" 207 "level", "Severity of the error, either 'warning' or 'error' (default 'error')", "Yes" 208 "hint", "Suggestion for fixing the error", "Yes" 209 "source", "Source code context of the error", "Yes" 210 "rule", "Name of the rule that was violated", "Yes" 211 "lineoffset", "Denotes an error spans multiple lines, of the form (<lineno offset>, <num lines>)", "Yes" 212 "diff", "A diff describing the changes that need to be made to the code", "Yes" 213 214 215 Automated testing 216 ----------------- 217 218 Every new checker must have associated tests. If your linter is ``mylinter`` then the 219 test file should be named ``tools/lint/test/test_mylinter.py`` and any example files 220 named like ``tools/lint/test/files/mylinter/my-example-file``. Be sure that your test 221 has been added as a section ``["test_mylinter.py"]`` in the manifest ``tools/lint/test/python.toml``. 222 223 They should be pretty easy to write as most of the work is managed by the Mozlint 224 framework. The key declaration is the ``LINTER`` variable which must match 225 the linker declaration. 226 227 As an example, the `ruff test <https://searchfox.org/mozilla-central/source/tools/lint/test/test_ruff.py>`_ looks like the following snippet: 228 229 .. code-block:: python 230 231 import mozunit 232 LINTER = 'ruff' 233 234 def test_lint_ruff(lint, paths): 235 results = lint(paths('bad.py')) 236 assert len(results) == 2 237 assert results[0].rule == 'F401' 238 assert results[1].rule == 'E501' 239 assert results[1].lineno == 5 240 241 if __name__ == '__main__': 242 mozunit.main() 243 244 As always with tests, please make sure that enough positive and negative cases 245 are covered. 246 247 To run the tests: 248 249 .. code-block:: shell 250 251 $ ./mach python-test --subsuite mozlint 252 253 To run a specific test: 254 255 .. code-block:: shell 256 257 ./mach python-test --subsuite mozlint tools/lint/test/test_black.py 258 259 More tests can be `found in-tree <https://searchfox.org/mozilla-central/source/tools/lint/test>`_. 260 261 Tracking fixed issues 262 --------------------- 263 264 All the linters that provide ``fix support`` returns a dictionary instead of a list. 265 266 ``{"results":result,"fixed":fixed}`` 267 268 * results - All the linting errors it was not able to fix 269 * fixed - Count of fixed errors (for ``fix=False`` this is 0) 270 271 Some linters (example: `codespell <https://searchfox.org/mozilla-central/rev/0379f315c75a2875d716b4f5e1a18bf27188f1e6/tools/lint/spell/__init__.py#145-163>`_) might require two passes to count the number of fixed issues. 272 Others might just need `some tuning <https://searchfox.org/mozilla-central/rev/0379f315c75a2875d716b4f5e1a18bf27188f1e6/tools/lint/file-whitespace/__init__.py#28,60,85,112>`_. 273 274 For adding tests to check your fixed count, add a global variable ``fixed = 0`` 275 and write a function to add your test as mentioned under ``Automated testing`` section. 276 277 278 Here's an example 279 280 .. code-block:: python 281 282 fixed = 0 283 284 285 def test_lint_codespell_fix(lint, create_temp_file): 286 # Typo has been fixed in the contents to avoid triggering warning 287 # 'informations' ----> 'information' 288 contents = """This is a file with some typos and information. 289 But also testing false positive like optin (because this isn't always option) 290 or stuff related to our coding style like: 291 aparent (aParent). 292 but detects mistakes like mozilla 293 """.lstrip() 294 295 path = create_temp_file(contents, "ignore.rst") 296 lint([path], fix=True) 297 298 assert fixed == 2 299 300 301 Bootstrapping Dependencies 302 -------------------------- 303 304 Many linters, especially 3rd party ones, will require a set of dependencies. It 305 could be as simple as installing a binary from a package manager, or as 306 complicated as pulling a whole graph of tools, plugins and their dependencies. 307 308 Either way, to reduce the burden on users, linters should strive to provide 309 automated bootstrapping of all their dependencies. To help with this, 310 ``mozlint`` allows linters to define a ``setup`` config, which has the same 311 path object format as an external payload. For example (`in-tree version <https://searchfox.org/mozilla-central/source/tools/lint/ruff.yml>`__): 312 313 .. code-block:: yaml 314 315 ruff: 316 description: Python linter 317 include: ['.'] 318 extensions: ['py'] 319 type: external 320 payload: py.ruff:lint 321 setup: py.ruff:setup 322 323 The setup function takes a single argument, the root of the repository being 324 linted. In the case of ``ruff``, it might look like: 325 326 .. code-block:: python 327 328 import subprocess 329 from shutil import which 330 331 def setup(root, **lintargs): 332 # This is a simple example. Please look at the actual source for better examples. 333 if not which("ruff"): 334 subprocess.call(["pip", "install", "ruff"]) 335 336 The setup function will be called implicitly before running the linter. This 337 means it should return fast and not produce any output if there is no setup to 338 be performed. 339 340 The setup functions can also be called explicitly by running ``mach lint 341 --setup``. This will only perform setup and not perform any linting. It is 342 mainly useful for other tools like ``mach bootstrap`` to call into. 343 344 345 Adding the linter to the CI 346 --------------------------- 347 348 First, the job will have to be declared in Taskcluster. 349 350 This should be done in the `mozlint Taskcluster configuration <https://searchfox.org/mozilla-central/source/taskcluster/kinds/source-test/mozlint.yml>`_. 351 You will need to define a symbol, how it is executed and on what kind of change. 352 353 For example, for ruff, the configuration is the following: 354 355 .. code-block:: yaml 356 357 py-ruff: 358 description: run ruff over the gecko codebase 359 treeherder: 360 symbol: py(ruff) 361 run: 362 mach: lint -l ruff -f treeherder -f json:/builds/worker/mozlint.json . 363 when: 364 files-changed: 365 - '**/*.py' 366 - '**/.ruff.toml' 367 368 If the linter requires an external program, you will have to install it in the `setup script <https://searchfox.org/mozilla-central/source/taskcluster/docker/lint/system-setup.sh>`_ 369 and maybe install the necessary files in the `Docker configuration <https://searchfox.org/mozilla-central/source/taskcluster/docker/lint/Dockerfile>`_. 370 371 .. note:: 372 373 If the defect found by the linter is minor, make sure that it is logged as 374 a warning by setting `{"level": "warning"}` in the 375 :class:`~mozlint.result.Issue`. This means the defect will not cause a 376 backout if landed, but will still be surfaced by reviewbot at review time, 377 or when using `-W/--warnings` locally.