---
title: 'Static Analysis and Formatting'
source: 'https://academia.sh/en/courses/javascript-ecosystem/static-analysis-and-formatting'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# Static Analysis and Formatting

The limit of parse-level checking, the rule set and severity levels, the discipline of suppressing a warning, keeping formatting separate from analysis, and resilience under repetition.

The tools covered so far transformed code: they merged modules, downleveled syntax,
produced position mappings. This lesson covers a class of tool that does not transform
code — **static analysis** tools that read the source and judge it against a rule set.

The Shell Programming course introduced the same class of tool for shell scripts; the
criteria and the discipline are the same. Here the language's own features and
package-level layout are added on top. The common thread across both is that the tool
never executes the program it examines — its judgment comes entirely from reading
structure, which is also the source of its blind spots.

## Checking at the Parsing Level

The most basic check is whether the source can be parsed at all. Runtimes offer an
option that does this without running the code:

```javascript
// file: broken.js
function measure(text) {
  return text.split(/\s+/.length;
}
```

```
$ node --check broken.js 2>&1 | sed "s|$(pwd -P)/||" | head -6
broken.js:3
  return text.split(/\s+/.length;
                          ^^^^^^

SyntaxError: missing ) after argument list
    at wrapSafe (node:internal/modules/cjs/loader:1804:18)
```

The exit code is nonzero; this means the check can be placed in a script. The internal
frames on the message's last line vary with the runtime's version; the first four lines
are stable. Running this check costs almost nothing next to actually executing the
file — no module is loaded, no side effect runs — which is why it belongs early in a
pipeline, before slower steps that assume the input already parses.

This check's limit is parsing itself. Every source that can be parsed passes — whatever
it means:

```javascript
// file: suspicious.js
function exceedsThreshold(number, threshold) {
  if (number = threshold) {
    return true;
  }
  return false;
}
console.log(exceedsThreshold(3, 5));
```

```
$ node --check suspicious.js && echo "syntax check: passed"
syntax check: passed
$ node suspicious.js
true
```

The condition performed an assignment, not a comparison. The program parsed, ran, and
gave the wrong answer: 3 does not exceed 5. The language considers this notation valid
because assignment is an expression and produces a value. Catching it needs a judgment
beyond parsing — this is where a rule set comes in.

## The Rule Set and Severity Levels

An analyzer parses the source and runs rules over the resulting structure. Each rule
looks for a pattern, produces a finding when it matches, and the finding carries a
**severity level**. The severity is what turns a finding into a decision: two rules can
report the exact same pattern and still lead to different outcomes, one blocking the
build and the other only noted for later.

A small analyzer is enough to see how rules work. The implementation below is
line-based; real tools work over an abstract syntax tree, but the rule, severity, and
suppression structure is the same.

```javascript
// file: analyze.mjs
import { readFile } from 'node:fs/promises';

const RULES = [
  { name: 'loose-equality', severity: 'error', test: (s) => /[^=!<>]==[^=]|[^!]!=[^=]/.test(s),
    message: 'use strict comparison instead of loose comparison' },
  { name: 'empty-catch', severity: 'error', test: (s) => /catch\s*(\([^)]*\))?\s*\{\s*\}/.test(s),
    message: 'an empty catch block hides the error' },
  { name: 'console', severity: 'warning', test: (s) => /console\.(log|debug)\(/.test(s),
    message: 'direct console output in library code' },
];

const IGNORE = /\/\/\s*analyze-ignore:\s*([\w-]+)/;

export async function analyze(file) {
  const lines = (await readFile(file, 'utf8')).split('\n');
  const findings = [];
  lines.forEach((line, i) => {
    const ignored = line.match(IGNORE)?.[1];
    for (const rule of RULES) {
      if (rule.name === ignored) continue;
      if (rule.test(line)) {
        findings.push({ file, line: i + 1, ...rule });
      }
    }
  });
  return findings;
}

const findings = await analyze(process.argv[2]);
for (const f of findings) {
  console.log(`${f.file}:${f.line}  ${f.severity.padEnd(6)} ${f.name.padEnd(16)} ${f.message}`);
}
const hasError = findings.some((f) => f.severity === 'error');
console.log(`${findings.length} findings`);
process.exit(hasError ? 1 : 0);
```

Suppose the measurement library's statistics module is written so that it triggers all
three rules:

```javascript
// file: src/statistics.mjs
export function averageLength(words) {
  if (words.length == 0) return 0;
  return words.reduce((t, s) => t + s.length, 0) / words.length;
}

export function longestWord(words) {
  console.log('longestWord called');   // analyze-ignore: console
  try {
    return words.reduce((longest, s) => (s.length > longest.length ? s : longest), '');
  } catch {}
}
```

```
$ node analyze.mjs src/statistics.mjs; echo "exit code: $?"
src/statistics.mjs:3  error  loose-equality   use strict comparison instead of loose comparison
src/statistics.mjs:11  error  empty-catch      an empty catch block hides the error
2 findings
exit code: 1
```

The console write on the seventh line produced no finding: the end-of-line comment
suppressed that rule. The exit code follows the same exit-code contract as the Shell
Programming course: a nonzero code lets the calling script stop the process.

## Classifying Rules

Not every rule carries the same weight. Three classes are distinguished.

**Correctness rules.** The code is very likely misbehaving: an unused assignment, an
assignment in a condition, unreachable code, a dropped promise. These are worth marking
at error level.

**Suspicion rules.** The code may not be wrong, but the pattern is a sign of a defect:
an empty catch, a coercing comparison, an overly broad variable. Warning level is
appropriate; deliberate uses need suppression.

**Formatting rules.** These have no effect on behavior: indentation, quote style,
semicolons. These are not an analyzer's job; they are the next section's subject.

The practical importance of this split is that error level carries a cost: every rule
at error level has the authority to stop the workflow. When that authority is given to
formatting preferences, the team starts seeing the analyzer as an obstacle, and
suppression comments multiply. The three classes also age differently: a correctness
rule earns its place once and rarely needs revisiting, while a suspicion rule's value
depends on how often its warning turns out to matter in this particular codebase, and
is worth reviewing periodically.

## The Discipline of Suppression

A suppression comment is a necessary mechanism: no rule set can anticipate every
legitimate use. Misusing it is just as easy.

Three rules keep it working. Suppression must be **narrow**: one line, not the whole
file; one rule, not all rules. Suppression must be **justified**: the comment next to it
must say why it is suppressed. Suppression must be **countable**: if the number of
suppressions is measured, a rise is a signal.

If a rule keeps getting suppressed, the problem is the rule itself. Either it does not
fit the project and should be turned off, or it is defined wrong and should be
narrowed.

## Formatting Is a Separate Job

A **formatter** is a tool that puts a source's notation into a single arrangement
without changing its meaning. It differs from an analyzer in two ways: it does not
report findings, it writes directly; and it does not choose what is correct, it chooses
what is **the one** form.

The property a correct formatter has to carry is **idempotence**: applying it again to
output it has already produced must change nothing. Without this property, the tool
produces a change on every run and drowns version control in noise.

```javascript
// file: format.mjs
import { readFile, writeFile } from 'node:fs/promises';

export function format(source) {
  const lines = source
    .replace(/\t/g, '  ')
    .split('\n')
    .map((s) => s.replace(/\s+$/, ''));
  while (lines.length > 0 && lines.at(-1) === '') lines.pop();
  return lines.join('\n') + '\n';
}

const file = process.argv[2];
const before = await readFile(file, 'utf8');
const once = format(before);
const twice = format(once);
await writeFile(file, once);
console.log('changed:', before !== once);
console.log('idempotent:', once === twice);
```

Suppose the input is a file containing a tab character and trailing whitespace. The
`cat -et` call shows tabs as `^I` and line ends as `$`:

```
$ printf 'export function measure(text)   {\n\treturn text.split(/\\s+/).length;   \n}\n' > unformatted.mjs
$ cat -et unformatted.mjs
export function measure(text)   {$
^Ireturn text.split(/\s+/).length;   $
}$
$ node format.mjs unformatted.mjs
changed: true
idempotent: true
$ cat -et unformatted.mjs
export function measure(text)   {$
  return text.split(/\s+/).length;$
}$
$ node format.mjs unformatted.mjs
changed: false
idempotent: true
```

The second run changed nothing. The example formatter only performs two transforms;
real tools parse the source and rewrite it from the tree, also deciding line length,
brace placement, and alignment. The result of the rewrite approach is that the output
is **entirely independent** of the input's original notation.

The rationale for the split is a cost calculation. Formatting debates consume a
measurable share of review time and settle nothing technical. Handing the decision to a
tool zeroes out that time. In exchange, the team gives up individual formatting
preferences.

## Automation and Its Limits

Both tools are only useful if they actually run. The usual placement is: format on
save in the editor, check before commit, a final check in the build pipeline. Without
that last link the others stay optional; if only the last link exists, feedback arrives
late. The earlier a check runs, the cheaper the fix is — a warning shown while a line is
still being typed costs a keystroke; the same warning caught only in the build pipeline
costs a new commit, a second review pass, and a wait for the pipeline to run again.

Static analysis's limit is a matter of principle: a rule set knows how the code is
written, not what it does. False findings are unavoidable, and so are missed defects.
An analyzer does not replace tests; the two catch different classes of defect. A clean
report from an analyzer does not say the code is correct — it says it avoids known
defect patterns.

## Summary

- A syntax check tests parseability and gives a result without running the code; it
  does not catch semantic defects such as an assignment in a condition.
- A static analyzer runs a rule set over parsed source; every finding carries a
  severity level, and the exit code can stop the workflow.
- Rules split into correctness, suspicion, and formatting; error level is given only to
  correctness rules.
- Suppression must be narrow, justified, and countable; a rule that is suppressed
  constantly is defined wrong.
- A formatter applies a single notation without changing meaning and must be
  idempotent; handing the decision to a tool frees review time from formatting debates.
- Static analysis does not replace tests; a clean report reports the absence of known
  defect patterns, not correctness.

## Next Step

Every tool set up in this course is a program invoked from the command line: the
bundler, the analyzer, the formatter, the test runner. Every developer having to
remember to invoke them with the same options should not be a memory exercise. The
course's last lesson covers how these invocations are named and stored in a project,
and how a reproducible set of commands is set up.
