← All writing

Linting, but this time it's personal

One thing that has really really annoyed me using Agentic coding tools is the models littering comments everywhere. They spread like the plague, one missed comment and before you know it there’s code in your commentbase.

I’ve had mixed experiences with trying to add some variation of “No comments” to AGENTS.md. They still leave them. Normally I’d catch them when I review my own PRs. But that’s wasting my own energy on a task that isn’t reviewing the changes.

This is where linting got personal for me. I started experimenting with hooks on a vendetta to remove useless comments. It has since grown into a more personal configuration to try and optimise my changes for readability.

Micro-history

The original Linter tool was developed in 1978, to perform static analysis on C source code. The idea behind it was to allow the developer to concentrate on the development of the program.

Modern Era

Modern linters can enforce coding conventions across a repository, catch potential bugs and automatically fix certain issues. They are often blazingly fast too. 🦀🔥

The typical flow may go something like:

If you are anything like me you’d forget to run the linter during development. I would create my pull request only to find it cannot be merged because it errors in CI/CD.

Hooks

Hooks are lifecycle events that either happen per session or per turn. I am using Claude Code hooks (other brands of hooks are available). The main events we are going to focus on here are:

  • PostToolUse used to run a hook after a tool call succeeds.
  • PreToolUse used to run before a tool call executes and can block them from executing.

The tools we are interested in here are the ones Claude uses to write with:

  • Bash executes shell commands.
  • Edit makes targeted edits to specific files.
  • Write creates a new file or overwrites existing ones.

When Claude has finished using the Edit or Write tool, Claude Code fires the PostToolUse event. Our hook configuration has a PostToolUse entry with the matcher Edit|Write.

This can trigger a shell command, in this case it runs a linter dispatch script with the argument post-edit.

settings.json
{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "node \"$HOME/.claude/lint/dispatch.mjs\" post-edit",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

My first lint rule

I will use Go to demonstrate what I was trying to avoid:

example.go
package main

import "fmt"

func main() {
	// print a greeting
	printer("Hello World")
}

// Takes a string parameter and prints it using fmt.Println
func printer(s string) {
	fmt.Println(s)
}

After reading enough LLM written code, I decided on a strict no inline comment policy. Out of all the inline comments I’ve seen models write almost all of them are redundant. They usually follow the same format, either narrating the code or explaining why something wasn’t done a certain way.

In my experience the Doc comments (DocBlock in PHP or TSDoc in TS) are usually more reasonable. But they also can grow into a mega comment depending on the function.

Personal Linting

The primary languages I use day to day are: PHP, TypeScript and sometimes Go. I needed to create a solution that would work in each of the languages. These tools will only be run on files that are modified. They needed to run after every write, so they had to be fast.

All the numbers that follow are from a micro benchmark: one file of roughly 500 lines, full of linting errors, run five times per tool.

Go was the easiest one. I could just use the go/analysis package. Once Go’s build cache is warm, it takes 0.12s and 16 MB.

For PHP, I tried Mago, a Rust-based static analyser and linter, vs PHPStan, a PHP based static analyser. Both of these tools supported custom rules, which I needed. Unsurprisingly, Mago was much quicker: 0.10s vs 3.1s for PHPStan, or 1.5s with its result cache. On one file, Mago also used less memory: 104 MB vs 218 MB, or 108 MB for PHPStan with its cache.

That changes on an actual codebase. Mago reads everything into memory first, even for one file, so on our largest project it needs about 1.7 GB. PHPStan only loads each class it needs, so its memory grows gradually.

For TypeScript, I wanted to use Biome, but it didn’t support matching comments, which was my original use case.

I decided to do something experimental: I tried the official TypeScript 7.1 Beta compiler, written in Go, and tsrs, a Rust port of the TypeScript 7.1 checker.

I chose tsrs because it was the faster of the two and the most memory efficient. It type-checked the file in 0.15s with 37 MB, compared to 0.33s and 70 MB for the 7.1 Beta.

Dispatch script

When a file is edited in a project, be it PHP, TS or Go. The dispatch.mjs script first checks the file extension to determine the language. Then it looks for go.mod, composer.json or tsconfig.json to find the root of the project.

This then runs the linting tool that I have chosen for the language on modified files.

In order for this to work correctly it needs to establish a baseline. This is a lint of the entire project in its pre-edited state. This is done on a per session basis. Any existing violations of my personal rules are recorded as part of the baseline because we only care about new changes.

Git is used to identify when a change happened outside of Claude’s session so like a merge or a pull.

When Claude calls the Edit or Write tool, Claude Code uses the PreToolUse Hook to run our dispatch script to capture the before state of the files.

Lint roller in action

Whenever Claude litters my changes with comments, it receives a fine:

hook-output.json
{
  "decision": "block",
  "reason": [
    "Personal lint found 1 error(s) in your change to example.go. Fix them now:",
    "- example.go:6, [no-inline-comment] Inline comment: // print a greeting"
  ]
}

This gives Claude the information it needs to remove the problematic line instantly and now, I never have to write another comment on a PR saying “remove this comment you £%^&”.

← Back to all writing