Lesson 13 / 14
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.
Contents
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:
// 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:
// 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.
// 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:
// 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.
// 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.