The tests in the previous lesson answer the question "does it work?". Another question remains, a different but equally important one: "is this code something we can live with a year from now?". There is a whole family of problems no test detects — inconsistent code, unused variables, promises with no await, two-hundred-line functions, the same logic copied in three places — and it is paid for on every future reading. Static analysis examines the code without running it and detects a good part of that in seconds. In this lesson we will look at why that work belongs in the pipeline rather than in human review, at exactly how Prettier, ESLint and tsc --noEmit differ, with concrete examples of what each one catches that the others do not, at how Reservalia's quality job is configured with --max-warnings 0, at what a quality gate is and why the winning criterion is "clean new code" instead of paying off all the debt at once. We will finish with pre-commit hooks and commit conventions. Vulnerability analysis and security SAST are not covered here: they are lesson 04-03.

Contents

  1. Why static analysis belongs in the pipeline
  2. Formatting, linting and type checking: three tools, three problems
  3. Reservalia's configuration and the quality job
  4. Deeper analysis: complexity, duplication and code smells
  5. Quality gates and the "clean new code" criterion
  6. Pre-commit hooks with Husky and lint-staged
  7. Commit conventions and their automated verification
  8. How to start on a project with thousands of warnings
  9. Common Mistakes and Tips
  10. Exercises
  11. Conclusion

  1. Why static analysis belongs in the pipeline

Marta reviews a pull request from Diego and writes six comments: four about single versus double quotes and indentation, one about an unused variable, and one pointing out that the new route does not validate the business identifier and would let anyone read other people's appointments.

The first five comments are waste. A machine can produce them in two seconds, without tiring, without exceptions and without anybody taking it personally. The sixth is the reason human review exists.

The principle. Automate everything that can be made objective so that human review can talk about design, about security and about whether the problem has been properly understood.

There are three further reasons, and all three matter. Consistency without arguments: when the tool decides the formatting, the debate ceases to exist — no Prettier configuration is objectively better than another; what is valuable is that there is one. No bias and no fatigue: someone reviewing the fifth PR of the day no longer sees unused variables; the tool always sees the same things. Immediate feedback with no emotional cost: a linter telling you an await is missing is information; a colleague telling you the same thing in a public comment is information and a small social bill.

  1. Formatting, linting and type checking: three tools, three problems

They are constantly confused with one another. They are three independent, complementary layers.

Prettier (formatting) ESLint (linting) tsc --noEmit (types)
Question it answers Is it written uniformly? Are there problematic patterns? Do the types fit together?
How it decides Reprints the code from its AST Rules over the AST A complete type system
Does it fix things itself? Always Sometimes (--fix) Never
Is it a matter of opinion? Yes, but it does not matter which you pick Yes, configured per team No: it is either correct or it is not
Speed at Reservalia ~2 s ~15 s ~20 s

Let us look at what each one catches that the others do not.

Prettier only. This code is perfectly valid and has no type errors and no bad practices; it is simply written differently from everything else in the repository:

const appointment={businessId:1,   start:"2026-03-02T10:00:00+01:00",
    durationMin : 30}

Prettier rewrites it without asking. Neither ESLint nor tsc would say a thing.

ESLint only. There is no type error here and the formatting is impeccable, but the code is wrong:

async function cancelAppointment(id: number) {
  sendCancellationEmail(id);          // ← returns a Promise and nobody awaits it
  await markCancelled(id);
}

tsc does not complain: calling an async function without await is legal. Nor does Prettier: the formatting is fine. The rule @typescript-eslint/no-floating-promises catches it instantly, and it is a real bug: if sending the email fails, the error vanishes into the void and nobody finds out.

tsc only. Here the formatting is correct and there is no suspicious pattern, but the program is broken:

import type { Appointment } from '@reservalia/shared-types';

function summary(appointment: Appointment): string {
  return `${appointment.business.name} · ${appointment.startTime}`;   // ← the field is called 'start'
}

Only the type system knows that Appointment has no startTime field. It is exactly the kind of error that shows up in production as an undefined in the customer's interface.

The practical conclusion is that all three layers are necessary and that none replaces the others. And there is a fourth, complementary one that does not compete with them: the complexity and duplication analysis in section 4.

  1. Reservalia's configuration and the quality job

Prettier is configured in .prettierrc.json, at the monorepo root:

{ "semi": true, "singleQuote": true, "printWidth": 100, "trailingComma": "all" }

Four decisions nobody will argue about again. What matters is not their content, but that they are written down in the repository.

ESLint lives in eslint.config.js. This is the core of Reservalia's configuration:

export default [{
  files: ['**/*.ts', '**/*.tsx'],
  languageOptions: { parser: tsParser, parserOptions: { project: './tsconfig.json' } },
  rules: {
    '@typescript-eslint/no-floating-promises': 'error',   // 1
    '@typescript-eslint/no-explicit-any':      'warn',    // 2
    'no-unused-vars':                          'error',
    'complexity':               ['warn', 12],             // 3
    'max-lines-per-function':   ['warn', 80],
    'eqeqeq':                                  'error',   // 4
  },
}];
  1. no-floating-promises is the rule from the previous example. In an application full of asynchronous operations — emails, database, payment gateway — it is the one that catches the most real bugs.
  2. no-explicit-any as a warning, not an error: turning it into an error overnight would break the entire project. The plan for promoting it to error is section 8.
  3. complexity and max-lines-per-function are design signals, not correctness ones; hence they are warnings.
  4. eqeqeq bans ==. In JavaScript, '0' == 0 is true and so is null == undefined: comparisons that look innocent and are not.

And now the pipeline job:

  quality:
    name: Quality
    runs-on: ubuntu-22.04
    timeout-minutes: 10
    steps:
      # ... checkout, setup-node and npm ci ...
      - name: Formatting
        run: npx prettier --check .              # 1

      - name: Linting
        run: npm run lint                        # 2  → eslint ... --max-warnings 0

      - name: Type checking
        run: npm run typecheck                   # 3  → tsc --noEmit
  1. prettier --check, not --write. In CI we want to check, not to modify: if the pipeline reformatted and pushed again, it would change the commit being verified and create a traceability mess. The error message states the file and the exact command for fixing it locally.
  2. --max-warnings 0 is the job's most important policy, and it was already in the package.json files from lesson 01-04. Without it, ESLint exits with code 0 even with 300 warnings, the pipeline turns green and the warnings pile up until nobody looks at them. With it, the number of warnings cannot grow: each new warning turns the PR red. The warn / error distinction is still useful for classifying severity, but in the pipeline both block.
  3. typecheck is separate from build. tsc --noEmit only checks and writes nothing, so it can run in parallel with the tests and gives feedback sooner.

All three steps are fast: the whole quality job takes under a minute and, being an independent job, it runs in parallel with test and build.

  1. Deeper analysis: complexity, duplication and code smells

Linters look file by file. Some problems are only visible from a project-wide perspective:

Measure What it indicates Indicative threshold
Cyclomatic complexity The number of possible paths within a function > 10-15 is worth splitting
Cognitive complexity How hard it is to understand (it penalises nesting) > 15 is hard to read
Duplication Percentage of repeated lines in the project > 3-5% deserves attention
Estimated technical debt The time it would take to fix what was detected Useful for comparing modules
Code smells Patterns that are not errors but are warnings Their trend, not their value

A real example from Reservalia. The calculateSlots function started at 20 lines; between split opening hours, public holidays, variable durations and manual blocks, it has reached 140 lines and a cyclomatic complexity of 23. No test fails — in fact it is well covered — but every new change takes twice as long and is twice as frightening. That is exactly what complexity measures: the future cost of touching it.

Beware of thresholds: they are signals to start a conversation, not truths. A complexity-18 function that translates a pricing table can be perfectly readable, and a complexity-8 one with three levels of nesting and cryptic names can be hellish.

  1. Quality gates and the "clean new code" criterion

A quality gate is a set of conditions a change must meet to be considered acceptable. Tools such as SonarQube (self-managed) or SonarCloud (SaaS) evaluate it and return a verdict the pipeline can use to block the merge.

The minimum configuration is a sonar-project.properties file at the root:

sonar.projectKey=reservalia_monorepo
sonar.sources=apps/api/src,apps/web/src,packages/shared-types/src
sonar.tests=apps/api/tests,apps/web/tests
sonar.javascript.lcov.reportPaths=apps/api/coverage/lcov.info
sonar.qualitygate.wait=true

sonar.qualitygate.wait=true makes the step wait for the verdict instead of finishing as soon as it has uploaded the data; without this, the pipeline would always pass and the gate would block nothing. The step in the quality job needs a secret:

      - name: Quality analysis
        uses: SonarSource/sonarcloud-github-action@v2
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

And here comes the decision that separates the adoptions that work from the ones that fail. Reservalia has three years of code and, the first time they run Sonar, 1,847 warnings appear. There are two routes:

Strategy What it demands Actual outcome
Pay off all the debt Stop for weeks to clean up 1,847 warnings It never happens; the gate gets switched off
Clean new code Only the code this PR touches has to comply Adopted the same day and the debt falls by itself

The "clean new code" (clean as you code) criterion applies the gate only to the lines added or modified in the PR. Its typical conditions:

  • 0 new errors and 0 new vulnerabilities in the new code.
  • Coverage of new code ≥ 80%.
  • Duplication of new code < 3%.

The advantages are enormous: it is achievable from day one, the team does not have to stop, and since every PR touches old code in order to modify it, the debt shrinks naturally in the areas that are genuinely worked on — which are the ones that matter. A module nobody has opened in two years may have 300 warnings; if nobody touches it, it bothers nobody.

  1. Pre-commit hooks with Husky and lint-staged

A pre-commit hook runs checks on your machine before the commit is created. Its value is the short loop: discovering a formatting problem in 2 seconds instead of after 3 minutes of pipeline. It is installed with npm i -D husky lint-staged, npx husky init and a .husky/pre-commit containing npx lint-staged. The configuration goes in the root package.json:

"lint-staged": {
  "*.{ts,tsx}": ["prettier --write", "eslint --max-warnings 0 --fix"],
  "*.{json,md,yml}": ["prettier --write"]
}

The key is in the name: lint-staged only processes the files you have staged, not the whole repository. That is why it takes a couple of seconds instead of twenty, and why people do not switch it off.

Now the important warning: local hooks NEVER replace the pipeline's control. Three reasons:

  1. They can be skipped. git commit --no-verify bypasses them, and in a hurry it gets used.
  2. They are on each person's machine. Anyone who clones the repository and does not run the installation has no hooks, and does not notice.
  3. They only see what you touched. A change in shared-types can break apps/web without any apps/web file being staged.

The correct model: hooks are a convenience (fast, optional feedback); the pipeline is the control (mandatory and unavoidable). If you have to choose only one, choose the pipeline.

  1. Commit conventions and their automated verification

A history of messages like fixes, wip, now it works is useless. Conventional commits impose a minimal format:

<type>(<scope>): <description>

feat(appointments): allow weekly recurring bookings
fix(schedule): do not offer slots that overlap the break
chore(deps): update vitest to 1.6.0

The usual types are feat, fix, chore, docs, test, refactor and perf. Verification is automated with commitlint, both locally (the commit-msg hook) and in the pipeline over the PR's commits. The configuration fits in one file:

{ "extends": ["@commitlint/config-conventional"] }

Why bother? Because a machine-readable format enables three concrete things we will see later: generating the changelog automatically, deriving the next version (feat → minor, fix → patch, BREAKING CHANGE → major) — both in lesson 02-06 — and filtering the history to answer "which fixes went into production this week?".

A style nuance worth settling: if the team uses squash when merging (lesson 02-07), the message that counts is the PR title, not the intermediate commits. In that case, validate the PR title and leave the working commits free.

  1. How to start on a project with thousands of warnings

Reservalia has 1,847 Sonar warnings and about 400 from ESLint. This is the plan that works, in four steps:

Step 1: freeze the situation. Enable the tools with --max-warnings 0 against an existing baseline, so that the number cannot grow. ESLint lets you generate a reference file with the current warnings; alternatively, start by applying the rules only to the folders that are already clean and bring in the rest in phases.

Step 2: format everything in one go. Run npx prettier --write . in a commit of its own, with no functional change, and add its SHA to .git-blame-ignore-revs so that git blame remains useful:

npx prettier --write .
git commit -am "chore: apply Prettier to the whole repository"
git rev-parse HEAD >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs

It is the only mass change that is advisable, because it is mechanical and verifiable: the tests still pass and no line changes meaning.

Step 3: enable the gate on new code only. The criterion from section 5. From that moment on, everything that comes in is clean.

Step 4: raise the bar in steps. Each month, one rule moves from warn to error when its number of violations is already close to zero. At Reservalia the agreed order is: no-floating-promises (already at error), then no-unused-vars, then no-explicit-any.

What does not work: the general clean-up week. It produces a 20,000-line diff that is impossible to review, a high regression risk, and three months later the problem is back because nothing was stopping it coming back.

Common Mistakes and Tips

Mistake 1: reviewing formatting in pull requests. If your team argues about quotes in review comments, they are not short on discipline: they are short of Prettier in the pipeline.

Mistake 2: a linter without --max-warnings 0. The pipeline goes green with hundreds of warnings inside and everybody learns to ignore them. It is the quietest way of having static analysis that is good for nothing.

Mistake 3: letting the pipeline reformat and push the result. It changes the commit being verified, triggers new runs and tangles the artifact's traceability. In CI, --check; locally, --write.

Mistake 4: relying only on pre-commit hooks. They are skipped with --no-verify, they do not exist for anyone who did not install them and they only see the staged files.

Mistake 5: demanding 100% cleanliness from day one. An unreachable gate gets switched off within two weeks and takes the part that was working down with it.

Tip 1: the same configuration locally and in CI. A versioned .prettierrc.json and eslint.config.js, and the same npm commands in both places. If Marta's editor formats differently from the pipeline, the war is eternal.

Tip 2: measure debt as a trend. The absolute number of warnings tells you nothing; the fact that it drops month by month does. Publish it in the job summary with $GITHUB_STEP_SUMMARY.

Tip 3: when you disable a rule, write down why. An // eslint-disable-next-line with no explanation is a lost decision; with one sentence, it is a documented decision.

Exercises

Exercise 1

For each fragment, say which tool detects it (Prettier, ESLint or tsc --noEmit) and why the other two do not:

// A
const total = price * quantity
    + discount;

// B
function find(id: string) { return appointments.find(a => a.id === id); }   // a.id is a number

// C
appointments.forEach(async (a) => { await notify(a); });                    // forEach does not wait

Exercise 2

The team enables Sonar and 1,847 warnings appear. The manager proposes: "two weeks of downtime to get it to zero, and from then on the gate requires zero warnings across the whole project". Argue against it and propose an alternative plan with concrete steps.

Exercise 3

Diego proposes removing the quality job from the pipeline because "we already have Husky with lint-staged locally, and that way we save a minute per PR". Give three arguments for why the proposal is dangerous.

Solutions

Solution 1.

  • A → Prettier. It is a purely formatting problem: a semicolon is missing and the line break is arbitrary. The code is valid and correctly typed, so tsc stays quiet; there is no problematic pattern, so ESLint stays quiet.
  • B → tsc --noEmit. Comparing a.id (a number) with id (a string) using === always gives false. Only the type system knows the shape of Appointment; the formatting is correct and the construct is idiomatic, so neither Prettier nor ESLint has anything to say. (With type information enabled, some @typescript-eslint rule can also detect it, precisely because it uses the same type engine.)
  • C → ESLint. forEach does not await asynchronous functions: the loop finishes before a single notification is sent. It is valid code (tsc stays quiet) and well formatted (Prettier stays quiet), but it is wrong. no-misused-promises catches it.

Solution 2. Arguments against: (a) two weeks without delivering value is an enormous, hard-to-justify cost; (b) a diff of thousands of lines is unreviewable, and the risk of introducing regressions in areas with no coverage is high; (c) requiring zero warnings across the whole project turns any PR that brushes an old file into an unplanned clean-up task, which pushes the team into dodging the gate; (d) a good part of those warnings are in code nobody touches, so fixing them adds no real value.

Alternative plan: (1) apply Prettier to everything in a mechanical commit and add it to .git-blame-ignore-revs; (2) enable --max-warnings 0 against the current baseline so that the number cannot grow; (3) configure the quality gate in clean new code mode with coverage ≥ 80% and 0 new errors; (4) promote one rule from warn to error each month, starting with the highest-impact ones; (5) publish the monthly warning trend so that the improvement is visible.

Solution 3. Three arguments: (1) hooks are skipped with git commit --no-verify and do not exist for anyone who clones the repository without installing them, so they are not a control but a convenience; (2) lint-staged only analyses the staged files, so a change in packages/shared-types that breaks apps/web would go unnoticed — only a full typecheck in the pipeline catches it; (3) without the required check on the PR, the branch protection rule from 02-07 has nothing to enforce, and the quality criterion goes back to depending on each person's goodwill. Besides, the saving is illusory: the quality job runs in parallel with test and build, so it adds no minute to the pipeline's total time.

Conclusion

Reservalia's pipeline no longer only checks that the code works, but that it is sustainable:

  • Static analysis belongs in the pipeline and not in human review: automating what can be made objective frees review up to talk about design, about security and about whether the problem has been properly understood.
  • Prettier, ESLint and tsc --noEmit solve three different problems and none replaces the others: uniform formatting, problematic patterns such as a promise with no await, and type coherence such as a field that does not exist.
  • The quality job runs prettier --check (never --write in CI), npm run lint with --max-warnings 0 — the policy that stops the debt growing — and npm run typecheck, in under a minute and in parallel with test and build.
  • Deeper analysis contributes complexity, duplication and code smells: measures of the future cost of touching the code, useful as a signal to start a conversation and not as absolute truth.
  • Quality gates are only adopted if they are achievable. The winning criterion is "clean new code": applying the conditions only to the lines the PR adds or modifies, so that the debt shrinks by itself in the areas that are genuinely worked on.
  • Pre-commit hooks (Husky + lint-staged) give feedback in two seconds, but they are skipped with --no-verify, they do not exist for anyone who did not install them and they only see the staging area: they are convenience, never control.
  • Commit conventions verified with commitlint make the history machine-readable, and that enables the automatic changelog and versioning derived from the commits.
  • And there is a realistic plan for a project with 1,847 warnings: freeze the baseline, format everything in a mechanical commit, apply the gate only to new code and raise the bar in steps.

Reservalia now has three jobs — quality, test and build — and a reasonable certainty about every commit. What it still does not have is something to keep: the image the build job produces evaporates when the runner finishes. In the next lesson, Artifacts, Versioning and Promotion, we will look at the principle of build once, deploy many times, at why an immutable artifact is the basis of traceability, at which versioning strategy to choose and why latest is dangerous, at how the same reservalia/api:a3f9c21 gets promoted from dev to staging and to prod, and at how to tell from a machine in production which commit it is running. And we will add the publish job to ci.yml.

CI/CD Course: Continuous Integration and Deployment

Module 1: Introduction to CI/CD

Module 2: Continuous Integration (CI)

Module 3: Continuous Deployment (CD)

Module 4: Advanced CI/CD Practices

Module 5: Implementing CI/CD in Real Projects

Module 6: Tools and Technologies

Module 7: Practical Exercises

Module 8: Additional Resources

© Copyright 2026. All rights reserved