Wringing IT

Permit A38

2021-03-20

I have a confession to make. While I started programming in 2001 and turned this into a career in early 2012, I've only been introduced to linting tools around 2015 or so, and to me the idea itself always seemed kind of foreign and unwelcome. After all style and readability are both very personal and subjective, so focusing more than a minimal amount of attention on this is something that I consider counter-productive at best.

Nevertheless any self-respecting organisation will eventually introduce a set of syntax rules and there's value in having this matter settled so as to not have to argue about it anymore.

The road to hell is paved with good intentions

One thing I noticed over the years regarding this topic is the same tendency for feature creep that is often observed in long-running projects. Going with the assumption that nothing is ever perfect developers try to come up with new rules that are supposed to somehow positively affect the codebase. The end result is often a byzantine set of "best practices" that have to be enforced manually and thus, inconsistently, because no linter has a rule for e.g. sorting and grouping imports based on their vaguely defined purpose.

Few things aggrevate me more than insisting that my job description should include going over a lengthy checklist of at times contradictory constraints each time I create a pull request, so I started thinking how to automate this process.

Prior art

ESLint has a decent extension system, but judging by the documentation it appeared a little too involved for my taste. I needed something that would be stupidly simple to use, something declarative in nature.

Solution

Linters normally perform their checks on an abstract syntax tree(AST) of the source. Being acyclic, this structure is representable in JSON. Most linter rules can be described as a two step process:

  1. Find nodes in the AST to which the rule applies(select).
  2. Check each node(filter).

A rule usually requires that either all or none of the nodes meet the defined criteria. A good example of this is func-names where, depending on the configuration, it's required that either all or none of the functions are explicitly named.

With this framework all I needed was a method to query/filter the AST. For that I've chosen JSONPath - a query language for JSON much like XPath is for XML.

Implementation

And thus Permit A38 was created - a linting tool leveraging JSONPath executed over the source AST, enabling the user to easily create even the most ridiculous syntax rules.

At first the syntax was somewhat awkward:

/*
 *  Rule object dissalowing the `readonly` keyword(141) in property declarations(162).
*/
const noReadonly = {
  select: '$..[?(@.kind==162)]',
  filter: '$..modifiers[?(@.kind==141)]',
  matchType: 'none'
}

So I figured I would introduce a few improvements:

  • @.kind===<number> would be replaced with just the name of the respective SyntaxKind:
const noReadonly = {
  select: '$..[?(PropertyDeclaration)]',
  filter: '$..modifiers[?(ReadonlyKeyword)]',
  matchType: 'none'
}
  • Each node that is part of a list would be decorated with a __previous field, containing a copy of the previous node, but without its own __previous field so as to not affect performance.
const sortedProperties = {
  select: `$..members[?(
    PropertySignature &&
    @.__previous &&
    @.__previous?PropertySignature
  )]`,
  filter: '$[?(@.name.escapedText > @.__previous.name.escapedText)]',
  matchType: 'all',
  type: 'single-select-filter'
}

Additionally @.__previous?PropertySignature is a shorthand for @.__previous.kind===161 (161 being the code for PropertySignature).

  • Comments are not normally part of the AST so I added them in the same manner that it is done in https://astexplorer.net/, but with one caveat: I'm using the TypeScript compiler API so comments may eventually land under different nodes than expected when looking at the output of AST explorer.

Benefits

The setup proved to be remarkably flexible and allowed me to check for errors that would usually require a custom ESLint rule, like:

  • Unintentional type declarations instead of assignments:
  class SomeClass extends BaseClass {
    field1: 'string-literal-type';
    field2 = 'what I really intended';
  }
  • Not-Actually-Immediately Invoked Function Expressions:
  const x = {
    a: 1,
    b: 'test',
    ...(() => ({
      c: 'never returned'
    })), // evaluates to undefined
  };
  • Unintentional arrow function returning an arrow function:
const arrowArrow = () => () => {
  // code here
};

Conclusion

All in all while this tool is not powerful enough to replace ESLint, it's useful in situations when one can't be bothered with creating an entire plugin just to check a few minor things.

Also for me it serves as an archive of mistakes that I commonly make.