The previous lesson ended with an uncomfortable observation: the three bugs you solved all had an earlier signal that nobody saw. An id mixing types, a catch that silently swallowed an error, two twin lines of which only one was adapted. None of them needed to run in order to look suspicious: it was enough to read the code carefully, and a machine does that better than you, in a hundred milliseconds and without getting tired. In this lesson you will set that machine up over Nómada Tasks: ESLint to spot dangerous patterns before anything runs, Prettier so that formatting stops being a topic of conversation, a Git hook so nothing badly formatted enters the repository, and a continuous integration workflow that checks it all over again on the server. And you will finish by settling the conventions no tool can automate: names, function size, comments that explain the why, and type documentation with JSDoc.

Contents

  1. What it costs not to have quality tools
  2. Static analysis: what a machine can know without running anything
  3. ESLint: installing and first run
  4. The flat configuration file
  5. Rules, levels and recommended configurations
  6. globals: browser, Node and tests
  7. Ten rules that prevent real bugs
  8. Turning a rule off without cheating
  9. --fix and the project scripts
  10. Useful plugins
  11. Prettier: formatting stops being an opinion
  12. ESLint versus Prettier, and how to make them coexist
  13. Editor integration
  14. Git hooks with Husky and lint-staged
  15. Continuous integration with GitHub Actions
  16. Conventions no tool can enforce
  17. Documenting types with JSDoc
  18. // @ts-check: type checking without TypeScript
  19. Quality metrics, used sensibly
  20. Nómada Tasks: setting it all up and fixing the warnings
  21. Common Mistakes and Tips
  22. Exercises
  23. Conclusion

  1. What it costs not to have quality tools

In a project with no automation, quality rests on human review. And human review has an attention budget problem: the reviewer has a limited amount of energy, and if it goes on things a machine would sort out on its own, there is none left for what only a human can see.

A typical review comment with no tooling:

"There are double quotes here and single quotes everywhere else in the file. The semicolon on line 34 is missing. The if is indented with 4 and we use 2. And I think status is unused."

Four comments; all four are detected and fixed by a tool in one second. What nobody said, because the attention went on formatting: that this if does not handle the done case, or that the function returns undefined on one branch.

The real cost breaks down into four parts:

Cost What it is What eliminates it
Arguing about style Quotes, indentation, semicolons, line length A deterministic formatter
Noise in diffs Formatting changes mixed with logic changes The same formatter, applied by everyone
Pattern bugs Unused variables, unexpected ==, promises with no await A static analyzer
Drift between people Every file written in a different style Shared configuration in the repository

The first two are about comfort. The third is about correctness, and it is the one that justifies the lesson: there is a whole family of bugs that can be found by reading the code, without running it.

  1. Static analysis: what a machine can know without running anything

A static analyzer turns your code into an abstract syntax tree (AST) —the very structure the JavaScript engine builds before running— and walks that tree looking for patterns. It runs nothing; it reasons about shape.

flowchart LR
    A["Source code<br/>board.js"] --> B["Analyzer<br/>→ AST"]
    B --> C["Rules<br/>walk the tree"]
    C --> D["Warnings and errors<br/>file:line:column"]
    C --> E["Automatic<br/>fixes (--fix)"]

What an analyzer can know:

  • That you declared const visible and never used it.
  • That you call bord.summary() and that identifier does not exist in any reachable scope.
  • That an async function contains no await at all (the async is probably unnecessary… or an await is missing).
  • That a switch case has no break and falls through to the next one (02-03).
  • That you compare with == instead of === (01-07).
  • That there is an assignment inside an if: if (status = 'done').

What it cannot know:

  • Whether openHours should add up the open tasks or all of them. That is semantics, and it is exactly case 3 from the previous lesson. That needs tests.
  • Whether the application does what Marta needs.
  • Whether your API will return id as a string or as a number, unless you tell it with types.

It is important to be clear about that boundary: static analysis and automated tests do not compete, they complement each other. The first is cheap, instant and covers a narrow family of bugs; the second costs effort to write and covers behavior. A serious project has both.

  1. ESLint: installing and first run

ESLint is the standard static analyzer of the JavaScript ecosystem. It is configurable down to the last detail and extensible with plugins, and those two characteristics explain both its power and its reputation for "complicated configuration".

Nómada Tasks has had no package.json so far: it is a project of ES modules served directly. We create one now, because from here on the project has tooling:

cd nomada-tasks
npm init -y                       # creates package.json
npm install --save-dev eslint     # ESLint is only needed in development

One detail worth settling in package.json from the start:

{
  "name": "nomada-tasks",
  "version": "1.0.0",
  "type": "module",
  "private": true,
  "scripts": {
    "lint": "eslint ."
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  }
}
  • "type": "module" tells Node that the .js files in this project are ES modules (05-04), not CommonJS. Without this line, any tool running your code in Node would complain about the import statements.
  • "private": true stops you publishing the package to npm by accident.
  • devDependencies rather than dependencies: ESLint is not part of the application, only of the development process. The browser never downloads it.

Running npm run lint with no configuration, ESLint warns that it cannot find a configuration file. Let us write it.

  1. The flat configuration file

Modern ESLint uses flat config: an eslint.config.js file that exports an array of configuration objects. Each object says which files it applies to and which rules govern there. Later objects layer over earlier ones.

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';

export default [
  // ── Layer 0 · what is NOT analyzed ────────────────────────────────────
  {
    ignores: ['coverage/**', 'dist/**', 'node_modules/**']
  },

  // ── Layer 1 · baseline for ALL the JavaScript in the project ──────────
  js.configs.recommended,

  // ── Layer 2 · the application code: browser + ES modules ──────────────
  {
    files: ['js/**/*.js'],
    languageOptions: {
      ecmaVersion: 'latest',            // newest syntax (private # fields, ?., ??=)
      sourceType: 'module',             // import/export, not require
      globals: {
        ...globals.browser              // window, document, fetch, localStorage, console…
      }
    },
    rules: {
      // the project's own rules go here (section 7)
    }
  },

  // ── Layer 3 · the service worker lives in ANOTHER global environment ──
  {
    files: ['sw.js'],
    languageOptions: {
      globals: { ...globals.serviceworker }   // self, caches, clients… but NOT document
    }
  },

  // ── Layer 4 · tooling files that run in Node ──────────────────────────
  {
    files: ['*.config.js', 'scripts/**/*.js'],
    languageOptions: {
      globals: { ...globals.node }            // process, __dirname (where it applies), console
    }
  }
];

Five things to understand about this file, because they are the ones that cause confusion:

  • It is real JavaScript, not JSON. You can import, compute and compose. That is why js.configs.recommended is an object inserted into the array.
  • Order matters. If two layers define the same rule, the last one wins. That is why the recommended configuration goes at the top and your adjustments below.
  • files decides the scope. An object with no files applies to everything. That is what makes layer 3 possible: sw.js has no document or window, and does have self and caches; declaring it avoids hundreds of false no-undef reports.
  • ignores in an object of its own (with no files) acts as a global ignore, the equivalent of the old .eslintignore.
  • globals is a helper package holding the catalogs of global variables for each environment. It is installed separately: npm i -D globals.

Layer 3 deserves one more comment. The service worker you wrote in 07-05 runs on a different thread with a different global object. Without that layer, ESLint would flag self, caches and clients as undefined, and —worse— it would not flag an accidental use of document, which in a worker is a genuine runtime error. Configuring environments properly is not bureaucracy: it is what turns no-undef into a real bug detector.

  1. Rules, levels and recommended configurations

Every ESLint rule is configured with a level and, optionally, options:

rules: {
  'no-unused-vars': 'error',                                    // plain level
  'no-console': ['warn', { allow: ['warn', 'error'] }],         // level + options
  'no-alert': 'off'                                             // turned off
}
Level Numeric value What happens Exit code
'off' 0 The rule is not checked
'warn' 1 It is reported, but does not fail 0 (success)
'error' 2 It is reported and fails 1 (failure)

The difference between warn and error becomes critical the moment you add continuous integration: error breaks the build, warn does not. Hence a very practical strategy:

  • error for anything that is a real bug or a risk: no-undef, eqeqeq, no-debugger.
  • warn for what is preference or in migration: rules you have just switched on over existing code and that still produce a hundred warnings.
  • And a hygiene rule: warnings must not pile up indefinitely. If a warning has been sitting there for six months, either it gets fixed or it gets switched off with a written reason. A lint run that prints 300 warnings is a lint run nobody reads.

js.configs.recommended switches on around sixty rules the community considers essential, and none about style. That is deliberate: ever since Prettier appeared, ESLint has been withdrawing formatting rules from its recommendation. That division of labor is the subject of section 12.

  1. globals: browser, Node and tests

The no-undef rule flags any identifier that is not declared. It is one of the most valuable —it would have caught a bord.summary() instantly— but it only works if ESLint knows which globals are legitimate in each file.

import globals from 'globals';

// Browser: window, document, fetch, localStorage, CustomEvent, AbortController…
globals.browser

// Service worker: self, caches, clients, skipWaiting…
globals.serviceworker

// Node: process, console, Buffer, URL…
globals.node

// Jest (you will need it in 08-03): describe, test, expect, beforeEach…
globals.jest

And if you need to declare a global of your own —for example, a constant injected by the deployment process— you do it with its write permission stated explicitly:

globals: {
  ...globals.browser,
  __APP_VERSION__: 'readonly'      // 'readonly' | 'writable' | 'off'
}

Marking it 'readonly' has an extra effect: the no-global-assign rule will warn if somebody tries to reassign it.

  1. Ten rules that prevent real bugs

These are the ones that justify the whole setup. They are not a matter of taste: each one corresponds to a family of bugs that has cost somebody an afternoon.

Rule What it detects Example in Nómada Tasks
no-unused-vars Unused variables, parameters and imports An import { WEIGHTS } left over from a refactor: dead code that confuses
no-undef Undeclared identifiers bord.summary(), a TODAY that was never imported
eqeqeq == and != instead of === / !== Exactly the 01-07 coercion that masked case 1 in 08-01
no-implicit-globals Declarations that pollute the global object A stray var status in a classic script
require-await An async function with no await inside async function save() that is actually synchronous: it promises asynchrony that is not there
no-return-await An unnecessary return await x inside a try… or outside it An extra stack frame for no gain
no-fallthrough A case that falls through to the next without break The status switch from 02-03
no-cond-assign Assignment inside a condition if (task.status = 'done') — it changes the status and is always true
no-debugger The debugger statement Exactly what you left behind in 08-01
no-constant-condition Conditions that are always true or always false `if (task.estimatedHours

And an eleventh that deserves separate explanation because it is the most quoted and the most misunderstood: no-floating-promises.

A floating promise is a promise whose result nobody collects:

// Can you see the bug?
function onSaveClick() {
  repository.save(board);
  updateTask(task.id, { status: 'done' });   // ← returns a promise nobody awaits
  showNotice('Saved');                        // ← a lie: it has not been saved yet
}

If updateTask fails, nobody catches the rejection: it becomes an unhandledrejection (05-06) and the user sees "Saved" over a change that never reached the server. It is a silent bug of the worst kind.

The important nuance: the real no-floating-promises rule requires type information, and that comes from TypeScript (section 18), not from ESLint over plain JavaScript. In a JavaScript-only project you cover it with a combination of approximations (require-await, no-async-promise-executor, review) and, above all, with a team convention: every call that returns a promise is either awaited or chained with an explicit .catch(). Writing it into the conventions is half the work; the other half is getting TypeScript to check it, and that is where // @ts-check starts to look attractive.

  1. Turning a rule off without cheating

There will be legitimate cases where a rule gets it wrong. ESLint lets you silence it with surgical precision:

// Only the next line
// eslint-disable-next-line no-console -- deliberate diagnostic log (see 08-01)
console.log('[nomada] diagnostics mode on');

// Only this line, at the end
const fallback = inMemoryStore();   // eslint-disable-line no-unused-vars

// A whole block
/* eslint-disable no-undef -- this file runs inside the service worker */
self.addEventListener('install', …);
/* eslint-enable no-undef */

Four hygiene rules for these comments:

  • Always name the rule. A bare // eslint-disable-next-line switches off every rule on that line, including ones that do not exist yet.
  • Write the reason after --. ESLint supports that syntax for exactly this purpose, and it stops anyone a year from now wondering whether it is still needed.
  • Prefer disable-next-line over a block disable. A disabled block tends to grow and hide new things.
  • If you disable it in five places, the rule is misconfigured. Change it in eslint.config.js or switch it off globally with a comment that justifies it. Five exceptions are not exceptions: they are the real rule.

There is also the reportUnusedDisableDirectives option (on by default in the modern recommended configuration), which warns when an eslint-disable is no longer needed because the code changed. It is automatic cleanup of your own exceptions.

  1. --fix and the project scripts

Many rules are auto-fixable: ESLint knows how to rewrite the code to satisfy them without changing its meaning.

npx eslint .              # only reports
npx eslint . --fix        # fixes what it can and reports the rest
npx eslint . --fix-dry-run --format json    # simulates, without writing

What gets fixed on its own and what does not:

Fixed automatically Requires human judgment
Quotes, semicolons, indentation no-unused-vars (is the variable superfluous, or is a use missing?)
===== when it is safe no-undef (is an import missing, or is it a typo?)
letconst if it is never reassigned require-await (is the async superfluous, or is an await missing?)
Import order (with a plugin) Excessive complexity

The mental rule: --fix solves the form, never the intent. Anything that involves deciding what you meant to do is left to you, and that is right.

The scripts the project will have from here on:

{
  "scripts": {
    "lint": "eslint .",
    "lint:fix": "eslint . --fix",
    "format": "prettier --write .",
    "format:check": "prettier --check .",
    "verify": "npm run lint && npm run format:check"
  }
}

verify is the script continuous integration will run, and the one you can fire before every commit while you get used to it.

  1. Useful plugins

An ESLint plugin contributes new rules. These three cover real needs of a project like this one:

Imports. An import plugin (eslint-plugin-import or its modern successor eslint-plugin-import-x) checks what the browser does not forgive in ES modules: paths that do not exist, forgotten extensions, circular dependencies and import ordering.

{
  files: ['js/**/*.js'],
  plugins: { import: importPlugin },
  rules: {
    'import/no-unresolved': 'error',          // the path './model/task.js' really exists
    'import/extensions': ['error', 'always'], // in the browser the extension is MANDATORY
    'import/no-cycle': 'error',               // A imports B, B imports A → unpredictable execution order
    'import/order': ['warn', { 'newlines-between': 'always' }]
  }
}

import/extensions set to 'always' is especially valuable here: bundlers tolerate import { Task } from '../model/task' without the extension, but the browser with <script type="module"> does not. Without this rule, the failure shows up at runtime and only in the browser. And import/no-cycle protects the acyclic dependency graph you drew in 05-04.

Accessibility. The best-known accessibility plugin (eslint-plugin-jsx-a11y) is designed for JSX, which you do not use. In an HTML-and-DOM project the equivalent check is done with other tools: an HTML validator, the DevTools accessibility audit, and above all axe, which you will wire into your end-to-end tests in 08-06. It is worth knowing the rule family exists and why it does not fit here: choosing a plugin because it sounds good and then discovering it does not analyze your kind of file is a very common way to lose an afternoon.

Tests. eslint-plugin-jest catches classic mistakes in the tests you will write in the next lesson: a test with no assertions at all, an expect outside a test, a forgotten test.only that leaves the rest of the suite unrun —perhaps the most dangerous failure of all, because the suite stays green while it checks nothing.

{
  files: ['**/*.test.js'],
  plugins: { jest: jestPlugin },
  languageOptions: { globals: { ...globals.jest } },
  rules: {
    'jest/no-focused-tests': 'error',     // a forgotten test.only
    'jest/no-disabled-tests': 'warn',     // a test.skip that has been there for months
    'jest/expect-expect': 'error',        // a test with no assertions proves nothing
    'jest/valid-expect': 'error'
  }
}

  1. Prettier: formatting stops being an opinion

Prettier is not a linter: it is a deterministic formatter. It discards your code's original formatting entirely, rebuilds it from its syntax tree and prints it following its own rules. Two enormous consequences:

  • The result does not depend on how it was written before. Two people with opposite styles produce byte-for-byte identical files.
  • There is barely anything to configure. Prettier deliberately offers few options, so that the argument does not simply move into the configuration.
npm install --save-dev prettier
// .prettierrc.json — the complete configuration for a project like this
{
  "semi": true,
  "singleQuote": true,
  "printWidth": 100,
  "tabWidth": 2,
  "trailingComma": "none",
  "arrowParens": "always",
  "endOfLine": "lf"
}

A comment on each option, because these are all the ones you have to decide:

  • semi: semicolons at the end. Using them avoids the whole class of automatic-insertion surprises (01-04).
  • singleQuote: single quotes, consistent with all the code in the course.
  • printWidth: 100: maximum width before wrapping. 80 is the classic; 100 breathes better on modern screens and avoids breaking .filter().map().reduce() chains.
  • trailingComma: trailing comma. "none" keeps the project's style; "all" produces cleaner diffs when you add items. Either is fine: what is not fine is every file using a different one.
  • endOfLine: "lf": Unix line endings. Without this, a mixed Windows/macOS team generates whole-file diffs from invisible changes.

And an exclusions file:

# .prettierignore
node_modules/
coverage/
dist/
*.min.js

Usage:

npx prettier --write .      # formats the whole project
npx prettier --check .      # only checks; fails if something is not formatted (for CI)

When to adopt it. Running prettier --write . over an existing project produces a gigantic commit touching every file. Do it in an isolated commit that does not change a single line of logic, with a clear message ("format: apply Prettier across the project"). And add its hash to a file so git blame ignores it:

echo "d4e5f6a7b8c9  # format: Prettier across the project" >> .git-blame-ignore-revs
git config blame.ignoreRevsFile .git-blame-ignore-revs

With that, git blame will keep attributing each line to whoever wrote its logic, not to the formatting commit. It is a small detail that saves a lot of future frustration.

  1. ESLint versus Prettier, and how to make them coexist

The most common confusion in the ecosystem. Both read your code and both can modify it, but they answer different questions:

ESLint Prettier
Question it answers Is this code correct and safe? Is this code well printed?
Kind of analysis Semantic: scopes, flow, variable usage Syntactic: it reprints the AST
Example of what it detects Unused variable, ==, debugger A 180-character line, mixed quotes
Configuration Extensive: hundreds of rules Minimal and deliberately limited
Can it fix things? Some rules, with --fix Everything, always
Is it debatable? Yes, every rule gets argued over No: you accept its judgment and stop arguing
If you remove it Bugs appear Arguments appear

The conflict and its solution. ESLint still ships some legacy formatting rules (indentation, quotes…). If you switch them on, ESLint and Prettier can demand opposite things and you will end up in a loop: you save, Prettier formats, ESLint complains; you fix it, Prettier undoes it.

The standard solution is eslint-config-prettier: a configuration that switches off every ESLint rule that clashes with Prettier. It goes last in the array, so its deactivation wins:

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';

export default [
  js.configs.recommended,
  { files: ['js/**/*.js'], languageOptions: { globals: globals.browser }, rules: { /* … */ } },
  prettier                       // ← ALWAYS last: turns off whatever would clash
];

There is also eslint-plugin-prettier, which runs Prettier inside ESLint and reports every formatting difference as an error. It is convenient (one single tool) but it fills the output with formatting errors mixed in with real ones and slows the analysis down. The widespread recommendation is a clean separation: Prettier formats, ESLint analyzes, eslint-config-prettier keeps them in their lanes.

flowchart LR
    A["You save the file"] --> B["Prettier<br/>reprints the formatting"]
    B --> C["ESLint<br/>analyzes correctness"]
    C --> D{"Errors?"}
    D -->|No| E["Done"]
    D -->|Yes| F["You fix them<br/>(or eslint --fix)"]
    F --> C

  1. Editor integration

Everything above becomes invisible —and therefore useful— when the editor applies it on its own.

// .vscode/settings.json — committed to the repository: the same for the whole team
{
  "editor.defaultFormatter": "esbenp.prettier-vscode",
  "editor.formatOnSave": true,
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": "explicit"
  },
  "eslint.useFlatConfig": true,
  "files.eol": "\n",
  "files.insertFinalNewline": true,
  "files.trimTrailingWhitespace": true
}

What each line does: Prettier formats on save; ESLint applies its automatic fixes on save too; line endings and trailing whitespace are normalized. The practical result is that you stop thinking about formatting: you type however it comes out, you save, and it ends up correct.

And an .editorconfig for anyone using a different editor:

# .editorconfig
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

That last section matters: in Markdown, two spaces at the end of a line mean a line break, so trimming them would break the text.

  1. Git hooks with Husky and lint-staged

The editor covers whoever has it properly configured. The repository needs a guarantee that does not depend on that. Git hooks are scripts Git runs at specific moments; the one we care about is pre-commit, which runs before the commit is created and can abort it.

  • Husky manages the hooks inside the repository (Git does not version .git/hooks, so without a tool everybody would have to install them by hand).
  • lint-staged runs the tools only over the files in the staging area. That is what makes the difference between a two-second hook and a two-minute one.
npm install --save-dev husky lint-staged
npx husky init                       # creates .husky/ and the pre-commit hook
# .husky/pre-commit
npx lint-staged
// package.json
{
  "lint-staged": {
    "*.js": ["eslint --fix", "prettier --write"],
    "*.{json,css,md,html}": ["prettier --write"]
  }
}

The complete flow, step by step:

flowchart TD
    A["git commit"] --> B["Husky runs<br/>.husky/pre-commit"]
    B --> C["lint-staged takes<br/>ONLY the staged files"]
    C --> D["eslint --fix<br/>prettier --write"]
    D --> E{"Any errors left<br/>unfixed?"}
    E -->|Yes| F["Commit ABORTED<br/>with the list"]
    E -->|No| G["The fixes are re-added<br/>to the staging area"]
    G --> H["Commit created"]

The key detail in step G: lint-staged re-adds the files its tools modified, so the commit contains the already-formatted code. You do not have to do anything.

Two essential warnings:

  • A slow hook gets disabled. If pre-commit takes thirty seconds, somebody will start using --no-verify and the guard will cease to exist. That is why lint-staged only looks at what is staged, and why the full test suite does not belong in pre-commit: it goes in pre-push or straight into continuous integration.
  • --no-verify exists and is sometimes legitimate (an emergency production commit at three in the morning). Precisely for that reason the same check has to be repeated on the server, where nobody can skip it.

  1. Continuous integration with GitHub Actions

Continuous integration (CI) runs the checks on a clean server, on every push and every pull request. It is the only layer nobody can dodge, and it also eliminates the classic "it works on my machine": the server starts from scratch, installs exactly what the lock file says and runs the same thing for everybody.

# .github/workflows/quality.yml
name: Quality

# When it runs
on:
  push:
    branches: [master]
  pull_request:

jobs:
  verify:
    runs-on: ubuntu-latest          # a clean virtual machine for every run

    steps:
      # 1 · Bring the repository code onto the machine
      - name: Check out the code
        uses: actions/checkout@v4

      # 2 · Install Node. 'cache: npm' reuses dependencies between runs
      - name: Set up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: lts/*
          cache: npm

      # 3 · Reproducible install: honors package-lock.json to the letter
      - name: Install dependencies
        run: npm ci

      # 4 · Static analysis. Fails if there are errors (level 'error')
      - name: Analyze with ESLint
        run: npm run lint

      # 5 · Formatting. It does not format: it checks. Fails if anything is unformatted
      - name: Check the formatting
        run: npm run format:check

Points worth understanding about this workflow:

  • npm ci instead of npm install. ci deletes node_modules, installs exactly the versions in package-lock.json and fails if the lock does not match package.json. It is deterministic; install may update the lock along the way.
  • node-version: lts/* uses the current long-term support release, without pinning a number that will go stale. If your project needs a specific version, declare it in the engines field of package.json and mirror it here.
  • format:check, not format. CI never modifies the code: it checks and it fails. Fixing is the author's job, on their machine.
  • on: pull_request is what makes the green or red mark appear on the proposed change, before merging.

When you get to 08-03 you will add an npm test step to this same workflow, and in 08-06 another one with the end-to-end tests. The structure is already in place.

The three layers of defense, ordered by how quickly they respond:

Layer When it acts Can it be bypassed Cost
Editor On save Yes (just do not install the extension) 0 s
Git hook On commit Yes (--no-verify) 1–3 s
Continuous integration On push / PR No 30–90 s

All three are the same check repeated, and that redundancy is deliberate: the sooner it fails, the cheaper it is to fix.

  1. Conventions no tool can enforce

With formatting and analysis automated, what is left is what requires judgment. These are the conventions worth writing into the project's CONTRIBUTING.md.

Names (picking up 01-04). A name is the documentation that never goes stale, because it is read at every use.

Element Convention In Nómada Tasks
Variables and functions camelCase, a verb if it acts openHours, createBacklog(), paintCard()
Classes PascalCase, a noun Task, Board, LocalRepository, ApiError
Module constants UPPERCASE_WITH_UNDERSCORES TODAY, WEIGHTS, TRANSITIONS, EVENTS
Private fields # in front (05-03) #status, #tasks, #store
Booleans An is/has prefix isOpen, isOverdue(), persistent
Files kebab-case.js, singular if it exports one thing local-repository.js, board-view.js
Handlers on + event, or handle + thing onCreate, handleClick

And three anti-names to stamp out: data, info, manage. They say nothing. data can be anything; pendingTasks says exactly what it is.

Function size. There is no magic number, but there is a reliable test: if you need a comment to separate two parts of a function, those two parts are two functions. As a practical reference: above 30 lines it is worth eyeing with suspicion, above 50 it almost certainly does two things. And the decisive criterion is not length but level of abstraction: a function that mixes deciding what to render with how to write it into the DOM is already wrong, whether it is ten lines or a hundred.

Comments that explain the why. A comment that repeats what the code says is noise that also ages badly:

// ❌ Noise: it repeats the code
// Increment the counter by one
counter += 1;

// ❌ Worse: it lies, because the code changed and the comment did not
// Returns the tasks sorted by date
return tasks.sort((a, b) => WEIGHTS[b.priority] - WEIGHTS[a.priority]);

// ✅ Useful: it explains a decision the code cannot tell you about
// We return fresh instances on every call so that two consumers
// (the application and the tests) do not share state by accident.
export function createBacklog() { … }

// ✅ Useful: it documents an external constraint
// The browser does NOT distinguish a dead network from blocked CORS: both arrive as
// TypeError, deliberately, so as not to leak information about other domains.
throw new ApiError('Could not reach the server.', { code: 'network' });

// ✅ Useful: it flags a trap
// dataset ALWAYS returns strings; without Number() the === in findById fails.
const id = Number(li.dataset.id);

The rule: the code says what it does; the comment says why it is like that and not otherwise. If you need a comment to explain what it does, the fix is usually to rename or extract a function, not to add a comment.

  1. Documenting types with JSDoc

JSDoc documents types with structured comments. In plain JavaScript it brings two immediate benefits: autocompletion and warnings in the editor (which understands JSDoc natively), and documentation you can read without leaving the file.

/**
 * Filters and sorts the tasks for presentation, without modifying the board.
 *
 * @param {import('../model/board.js').Board} board  Data source
 * @param {object} options
 * @param {string|null} [options.assignee]  Exact name, or null for no filter
 * @param {string} [options.text='']        Case-insensitive search by title
 * @param {'priority'|'date'|'hours'} [options.sort='priority']
 * @returns {import('../model/task.js').Task[]}  A NEW array; the board is untouched
 * @throws {ValidationError} If `sort` is not one of the accepted values
 *
 * @example
 * visibleTasks(board, { assignee: 'Iván' });   // → 3 tasks, 25 h
 */
export function visibleTasks(board, { assignee = null, text = '', sort = 'priority' } = {}) {
  // …
}

And for types that repeat, @typedef defines them once:

/**
 * @typedef {object} BoardSummary
 * @property {number} total       Every task on the board
 * @property {number} open        The ones that are not in the 'done' status
 * @property {number} totalHours  Sum of estimatedHours across all of them
 * @property {number} openHours   Sum of estimatedHours across the open ones
 * @property {number} overdue     Open tasks with a dueDate in the past (R10)
 * @property {number} effort      Sum of hours × priority weight
 */

/**
 * @param {string} today  Reference ISO date
 * @returns {BoardSummary}
 */
summary(today) { … }

That @typedef documents the project's canonical numbers once and for all, and makes the editor autocomplete summary.openHours with its description alongside. In a six-layer project that is worth more than any external document, because it lives next to the code and is updated with it.

A word on dosage: document the exported functions with JSDoc —the public surface of each module— and leave the internal ones with a good name. Documenting everything produces files with more comment than code, and nobody reads any of it.

  1. // @ts-check: type checking without TypeScript

Here is the next step up, and you can climb it without rewriting anything. The TypeScript compiler can analyze .js files using the information in JSDoc. It is switched on with a comment on the first line:

// @ts-check
import { Task } from './task.js';

/** @param {number} id */
export function find(id) { … }

find('7');
//   ~~~ Argument of type 'string' is not assignable to parameter of type 'number'.

That warning is exactly case 1 from the previous lesson, caught in the editor, without running anything, without opening the browser and without Marta having to report a thing. A bug that cost a full investigation would have been a red underline while it was being typed.

To switch it on across the whole project without adding the comment file by file:

// jsconfig.json
{
  "compilerOptions": {
    "checkJs": true,
    "strict": true,
    "target": "esnext",
    "module": "esnext",
    "moduleResolution": "bundler",
    "noEmit": true
  },
  "include": ["js/**/*.js"]
}

"noEmit": true is the essential part: TypeScript generates no files at all, it only checks. Your code stays runnable JavaScript exactly as it is, served straight to the browser.

The honest comparison:

JavaScript + JSDoc + @ts-check TypeScript
Compilation needed No Yes
Type coverage Good, somewhat limited in advanced cases Complete
Verbosity High (long comments) Low (native syntax)
Adoption cost Very low, file by file Medium to high
Rules like no-floating-promises Available with the typed plugin Available

For Nómada Tasks, @ts-check is the sensible option: zero changes to deployment and a net that catches the whole family of type bugs. Full TypeScript is a project-level decision covered in Next Steps, where it is placed within the complete map of what comes after this course.

  1. Quality metrics, used sensibly

There are numeric quality metrics, and two are worth knowing.

Cyclomatic complexity. It counts the independent execution paths through a function: 1 as a baseline, +1 for each if, else if, case, for, while, catch, &&, || and ?:. It is, almost literally, the minimum number of tests needed to cover every branch, and that is why it matters here.

// Complexity 1: a single path
export function statusBadge(status) {
  return BADGES[status] ?? '?';
}

// Complexity 5: four decisions + the baseline
function validateForm(form, data, today) {
  const errors = [];
  for (const field of form.elements) {                  // +1
    if (field.willValidate && !field.checkValidity()) { // +1 (if) +1 (&&)
      errors.push({ field, message: field.validationMessage });
    }
  }
  if (data.dueDate < today) errors.push(…);             // +1
  if (data.tags.length > 5) errors.push(…);             // +1
  return errors;
}

ESLint measures it with the complexity rule:

rules: {
  complexity: ['warn', { max: 10 }],
  'max-depth': ['warn', 4],           // block nesting
  'max-lines-per-function': ['warn', { max: 60, skipComments: true, skipBlankLines: true }]
}

As a rough guide: below 10, comfortable; between 10 and 20, look at whether it can be split; above 20, there are almost certainly two functions in there.

Technical debt. It is the metaphor, not a metric: today's shortcuts are paid back with interest tomorrow in the form of development time. Some tools express it in estimated hours to fix. That number is an estimate of an estimate; it is useful for comparing how one project evolves over time, not for comparing projects and not for showing off.

And the warning that gives this section its title: do not fall into number fetishism. Three real pathologies:

  • Chasing the metric instead of the goal. Lowering complexity by splitting a function into four incoherent pieces makes the code worse and the number better.
  • Confusing "no warnings" with "done well". Case 3 in 08-01 —openHours adding up to 48 instead of 45— passed every ESLint rule. A clean lint says nothing about whether the program is correct.
  • Using metrics to evaluate people. As soon as a number becomes a target, it stops being a good measure (Goodhart's law). Metrics are a thermometer for the code, not a grade.

  1. Nómada Tasks: setting it all up and fixing the warnings

Let us assemble the complete configuration and see what it finds in the real code.

npm install --save-dev eslint @eslint/js globals eslint-config-prettier prettier husky lint-staged
npx husky init
// eslint.config.js — the project's complete configuration
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';

export default [
  { ignores: ['node_modules/**', 'coverage/**', 'dist/**'] },

  js.configs.recommended,

  // The application: browser + ES modules
  {
    files: ['js/**/*.js'],
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: { ...globals.browser }
    },
    rules: {
      // — Correctness —
      eqeqeq: ['error', 'always', { null: 'ignore' }],   // allows `x == null` (null and undefined at once)
      'no-undef': 'error',
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-implicit-globals': 'error',
      'require-await': 'error',
      'no-cond-assign': ['error', 'always'],
      'no-constant-condition': 'error',
      'no-fallthrough': 'error',

      // — Hygiene —
      'no-debugger': 'error',                             // ← the 08-01 one
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'prefer-const': 'error',
      'no-var': 'error',
      'object-shorthand': 'warn',

      // — Size —
      complexity: ['warn', { max: 12 }],
      'max-depth': ['warn', 4]
    }
  },

  // The service worker: different global, different rules
  {
    files: ['sw.js'],
    languageOptions: { globals: { ...globals.serviceworker } },
    rules: { 'no-console': 'off' }        // in a SW, the console is the only window
  },

  // Tooling that runs in Node
  {
    files: ['*.config.js', 'scripts/**/*.js'],
    languageOptions: { sourceType: 'module', globals: { ...globals.node } },
    rules: { 'no-console': 'off' }
  },

  prettier                                 // ← last, always
];

And the first run over the code from Modules 1 to 7:

$ npm run lint

/nomada-tasks/js/view/board-view.js
   14:10  error    'WEIGHTS' is defined but never used            no-unused-vars
   96:5   warning  Unexpected console statement                   no-console

/nomada-tasks/js/model/board.js
   52:9   error    Unexpected 'debugger' statement                no-debugger

/nomada-tasks/js/data/tasks-api.js
   61:9   error    Expected '===' and instead saw '=='            eqeqeq
   88:1   error    Async method 'deleteTask' has no 'await'       require-await

/nomada-tasks/js/view/controller.js
   38:15  error    'task' is not defined                          no-undef

/nomada-tasks/sw.js
   23:3   warning  Unexpected console statement                   no-console

✖ 7 problems (5 errors, 2 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

Let us go one by one, because every fix has its nuance:

1 · 'WEIGHTS' is defined but never used. An orphan import from a refactor. Delete the line. Cost: zero. Benefit: whoever reads the file will not go hunting for where a weight that is no longer used gets used.

2 · Unexpected 'debugger' statement. The debugger from the previous lesson, on its way to the repository. This is the warning that pays for the whole configuration on its own: a debugger in production freezes the application for anyone with DevTools open.

3 · Expected '===' and instead saw '=='.

// Before
if (response.status == 204) return null;
// After
if (response.status === 204) return null;

There was no real bug here (status is always a number), but this is one of those rules that does not admit case-by-case exceptions: keeping == in the code forces you to reason every single time about whether the coercion is safe. --fix corrects it on its own.

4 · Async method 'deleteTask' has no 'await'. This one is a genuine bug:

// Before — the async is a lie: nobody catches the network error here
export async function deleteTask(id) {
  fetch(buildUrl(`/tasks/${id}`), { method: 'DELETE' });   // ← floating promise
  return true;                                              // ← always a lie
}

// After
export async function deleteTask(id) {
  const response = await fetch(buildUrl(`/tasks/${id}`), { method: 'DELETE' });
  await check(response);
  return true;
}

The original version returned true before the server answered. If the delete failed with a 403, the interface removed the card anyway and the error was lost as an unhandledrejection. A three-word rule has found a data-consistency bug.

5 · 'task' is not defined. A variable that was renamed in half of its occurrences:

// Before
const clickedTask = board.findById(id);
if (task.status === 'done') return;      // ← 'task' no longer exists: ReferenceError

This code threw a ReferenceError at runtime, but only on the branch that is almost never taken. ESLint sees it without running anything. It is the perfect example of why no-undef deserves the error level.

6 and 7 · no-console. Debug logs from 08-01. The service worker ones are allowed by layer 3 (there, the console is the only window). The view ones are replaced by the log() from exercise 2 of the previous lesson, which respects levels and does not clutter the console in production.

Result after the fixes:

$ npm run lint && npm run format:check
Checking formatting...
All matched files use Prettier code style!

Five errors, of which two were real bugs nobody had noticed: a delete that lied about its result and a latent ReferenceError. Neither would have turned up in a quick review, and both cost less than a minute of configuration.

Common Mistakes and Tips

  • Switching on hundreds of rules at once in an existing project. Out come 400 warnings, nobody looks at them and the tool loses all credibility. Start with recommended plus the ten rules from section 7, leave the rest at warn and raise the bar once the floor is clean.
  • Forgetting eslint-config-prettier, or putting it before your own rules. It goes last in the array. If it goes earlier, your formatting rules override it and the save-format-complain loop comes back.
  • Not configuring globals per environment. Without it, no-undef produces false positives in sw.js (with self and caches) and in the test files (with describe and expect), and the typical reaction —switching the rule off— disarms the most useful detector you have.
  • Forgetting the extension in your import statements. Bundlers forgive it; the browser from 05-04 does not. Switch it on with import/extensions set to 'always'.
  • // eslint-disable-next-line with no rule name and no reason. It switches off every rule on that line, future ones included, and nobody will know whether it is still needed.
  • Mixing the formatting commit with logic changes. The diff becomes unreadable and review becomes impossible. Formatting in an isolated commit, and its hash in .git-blame-ignore-revs.
  • Git hooks so slow that people use --no-verify. The pre-commit hook should only look at staged files. The full test suite goes to pre-push or to CI.
  • Believing that a clean lint means the code works. Case 3 from 08-01 passed every rule. Static analysis checks form; behavior is checked with tests, and that starts in the next lesson.
  • Tip: pin your versions and use npm ci in CI. An automatic ESLint update can switch on new rules and turn a repository nobody has touched red.
  • Tip: npx eslint . --max-warnings=0 turns warnings into errors for CI. It is how you stop warn entries piling up without having to promote them all to error in the editor.
  • Tip: write the conventions into CONTRIBUTING.md. What is not written down gets argued about in every review; what is written down gets cited in one line.

Exercises

Exercise 1 — A complete per-environment configuration. Write the eslint.config.js for Nómada Tasks covering five different environments: (a) js/**/*.js as browser ES modules; (b) sw.js as a service worker; (c) **/*.test.js with the Jest globals and the test plugin's rules; (d) cypress/**/*.js with cy, Cypress, describe and it as read-only globals; (e) the configuration files at the root, which run in Node. Justify in comments why each environment needs its own layer and what false positive it avoids.

Exercise 2 — Catching bugs with static analysis. For each of these fragments, state which rule detects it, whether --fix can repair it, and what the correct fix is:

// A
export async function sync(board) {
  repository.save(board);
  return { ok: true };
}

// B
function nextStatus(status) {
  switch (status) {
    case 'pending':
      return 'in-progress';
    case 'in-progress':
      log('info', 'closing');
    case 'done':
      return null;
  }
}

// C
if (task.estimatedHours = 0) {
  throw new ValidationError('Invalid hours', 'estimatedHours', 0);
}

// D
const open = board.openTasks;
const closed = board.tasks.filter((t) => t.status == 'done');
return closed.length;

Exercise 3 — Document the module with JSDoc and switch on @ts-check. Take js/model/board.js and add complete JSDoc documentation: a @typedef for BoardSummary with its six fields, types for every public method (add, changeStatus, filter, summary, hoursByAssignee), @throws where appropriate and an @example with the canonical backlog numbers. Switch on // @ts-check in the file and describe which error the editor would flag in each of these three incorrect uses: board.add({ id: 7, title: 'X' }), board.changeStatus('3', 'done') and board.summary().

Solutions

Solution 1

// eslint.config.js
import js from '@eslint/js';
import globals from 'globals';
import prettier from 'eslint-config-prettier';
import jest from 'eslint-plugin-jest';

export default [
  { ignores: ['node_modules/**', 'coverage/**', 'dist/**', 'cypress/videos/**', 'cypress/screenshots/**'] },

  js.configs.recommended,

  // (a) The application. Browser globals: without them, `document` and `fetch`
  //     would trigger no-undef in every view and data file.
  {
    files: ['js/**/*.js'],
    languageOptions: {
      ecmaVersion: 'latest',
      sourceType: 'module',
      globals: { ...globals.browser }
    },
    rules: {
      eqeqeq: ['error', 'always', { null: 'ignore' }],
      'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
      'no-implicit-globals': 'error',
      'require-await': 'error',
      'no-debugger': 'error',
      'no-console': ['warn', { allow: ['warn', 'error'] }],
      'prefer-const': 'error',
      'no-var': 'error',
      complexity: ['warn', { max: 12 }]
    }
  },

  // (b) Service worker. A different global: `self`, `caches` and `clients` exist;
  //     `document` does NOT. Declaring it here means an accidental use of
  //     `document` in the SW does get flagged as an error, which is what we want.
  {
    files: ['sw.js'],
    languageOptions: { sourceType: 'module', globals: { ...globals.serviceworker } },
    rules: { 'no-console': 'off' }
  },

  // (c) Tests. Without globals.jest, `describe`, `test` and `expect` would be no-undef
  //     in every file, and the typical reaction (turning no-undef off) would disarm the rule.
  {
    files: ['**/*.test.js', 'test/**/*.js'],
    plugins: { jest },
    languageOptions: { globals: { ...globals.jest, ...globals.node } },
    rules: {
      'jest/no-focused-tests': 'error',   // a forgotten test.only leaves the suite unrun
      'jest/no-disabled-tests': 'warn',
      'jest/expect-expect': 'error',
      'jest/valid-expect': 'error',
      'no-console': 'off'                 // in a test, the odd console call is acceptable
    }
  },

  // (d) Cypress. `cy` and `Cypress` are globals injected by the runner;
  //     besides, the E2E test code runs in the browser.
  {
    files: ['cypress/**/*.js'],
    languageOptions: {
      globals: {
        ...globals.browser,
        cy: 'readonly',
        Cypress: 'readonly',
        describe: 'readonly',
        it: 'readonly',
        beforeEach: 'readonly',
        expect: 'readonly'
      }
    },
    rules: { 'no-unused-expressions': 'off' }   // the .should() style triggers it falsely
  },

  // (e) Configuration and scripts: they run in Node, not in the browser.
  //     `process` and `console` are legitimate here and are not in js/**.
  {
    files: ['*.config.js', 'scripts/**/*.js'],
    languageOptions: { sourceType: 'module', globals: { ...globals.node } },
    rules: { 'no-console': 'off' }
  },

  prettier
];

Solution 2

Case Rule --fix? Fix
A require-await No The function is async with no await: either the async is superfluous, or an await is missing. Here it is missing: await repository.save(board) if it is asynchronous, or drop the async. The real bug is that { ok: true } is returned with no guarantee that anything was saved
B no-fallthrough No After log(...) a return or a break is missing: 'in-progress' falls into 'done' and returns null instead of the next status. There is also no default (rule default-case)
C no-cond-assign No It is = instead of ===. The condition assigns 0 to estimatedHours (going through the setter, which would in turn throw for R3) and evaluates to 0, which is falsy: the validation never fires. Correct: if (task.estimatedHours === 0)
D eqeqeq + no-unused-vars Yes for eqeqeq t.status == 'done'===; and open is declared and never used: delete the line
// A · fixed
export async function sync(board) {
  await repository.save(board);
  return { ok: true };
}

// B · fixed
function nextStatus(status) {
  switch (status) {
    case 'pending':
      return 'in-progress';
    case 'in-progress':
      log('info', 'closing');
      return 'done';
    case 'done':
      return null;
    default:
      throw new ValidationError(`Unknown status: "${status}".`, 'status', status);
  }
}

// C · fixed
if (task.estimatedHours === 0) { … }

// D · fixed
return board.tasks.filter((t) => t.status === 'done').length;

Solution 3

// @ts-check
import { Task } from './task.js';
import { ValidationError } from './errors.js';

/**
 * Aggregate figures for the board on a reference date.
 *
 * @typedef {object} BoardSummary
 * @property {number} total       Every task on the board
 * @property {number} open        The ones that are not in the 'done' status
 * @property {number} totalHours  Sum of estimatedHours across all of them
 * @property {number} openHours   Sum of estimatedHours across the open ones
 * @property {number} overdue     Open tasks with a dueDate in the past (R10)
 * @property {number} effort      Sum of hours × priority weight
 */

export class Board {
  /** @type {Task[]} */
  #tasks = [];

  /**
   * @param {string} name
   * @param {Task[]} [tasks=[]]  Added one by one, applying R1
   */
  constructor(name, tasks = []) { … }

  /**
   * Adds a task to the board.
   * @param {Task} task  A Task instance, not a plain object
   * @returns {Board} the board itself, for chaining
   * @throws {ValidationError} if it is not a Task, or if the id already exists (R1)
   */
  add(task) { … }

  /**
   * Applies a status transition to a task on the board.
   * @param {number} id     The task's numeric identifier
   * @param {'pending'|'in-progress'|'done'} next
   * @returns {Board}
   * @throws {ValidationError} if the task does not exist or the transition violates R6
   */
  changeStatus(id, next) { … }

  /**
   * @param {(task: Task) => boolean} predicate
   * @returns {Task[]} a new array; the board is not modified
   */
  filter(predicate) { … }

  /**
   * @param {string} today  Reference ISO date, 'yyyy-MM-dd'
   * @returns {BoardSummary}
   *
   * @example
   * const board = new Board('Taller Nómada', createBacklog());
   * board.summary('2026-09-20');
   * // { total: 6, open: 5, totalHours: 48,
   * //   openHours: 45, overdue: 1, effort: 124 }
   */
  summary(today) { … }

  /**
   * Open hours grouped by person. Tasks with no assignee (R8)
   * are grouped under the key 'unassigned'.
   * @returns {Record<string, number>}
   *
   * @example
   * board.hoursByAssignee();   // { Iván: 25, Lucía: 14, Marta: 6 }
   */
  hoursByAssignee() { … }
}

The three errors the editor would flag with @ts-check:

1) board.add({ id: 7, title: 'X' })
   Argument of type '{ id: number; title: string; }' is not assignable to
   parameter of type 'Task'.  Type is missing the following properties: status,
   isOpen, effort, changeStatus…
   → It is exactly what R1 checks at runtime, but before anything runs.

2) board.changeStatus('3', 'done')
   Argument of type 'string' is not assignable to parameter of type 'number'.
   → Case 1 from 08-01 (the id that arrived as a string), caught in the editor.

3) board.summary()
   Expected 1 arguments, but got 0.
   → With no date, `isOverdue` would receive undefined and `overdue` would silently
     come out as 0: a mute bug, of the same kind as the miscounted counter in case 3.

Conclusion

Nómada Tasks no longer depends on individual discipline. You know what a static analyzer is and where its boundary lies: it can assert that a variable is unused, that an identifier does not exist, that an async awaits nothing or that there is an assignment inside an if; it cannot know whether openHours should add up to 45 or 48. That is why static analysis and tests do not compete: they cover different families of bugs, and a serious project has both.

You have ESLint properly configured: a flat, layered eslint.config.js with files delimiting each environment —the application in the browser, the service worker with its own global, Node for the tooling—, globals correctly declared so that no-undef is a real detector and not a source of false positives, the three levels (off/warn/error) used with judgment, and the ten rules that prevent real bugs: no-unused-vars, no-undef, eqeqeq, no-implicit-globals, require-await, no-fallthrough, no-cond-assign, no-debugger and the rest. You know how to silence a rule by naming it and justifying it, you know what --fix repairs and what it does not —form yes, intent never—, and you know the plugins that add value: imports (with import/extensions at 'always', essential in the browser, and import/no-cycle to protect the 05-04 graph), tests (with no-focused-tests catching the forgotten test.only), and why the JSX accessibility plugin does not fit a plain-DOM project.

You have Prettier as a deterministic formatter and you understand why it does not compete with ESLint but splits the work with it: one answers "is it well printed?" and the other "is it correct?". You know how to combine them without loops by putting eslint-config-prettier last, how to adopt it in an isolated commit and how to neutralize it in git blame. And you have the three layers of defense in place: the editor formatting and fixing on save, a pre-commit hook with Husky and lint-staged that only looks at staged files so that nobody is tempted by --no-verify, and a GitHub Actions workflow with npm ci, npm run lint and npm run format:check that nobody can dodge, ready to receive the npm test step from the next lesson.

And you have what no tool can give you: the conventions. Names that document (openHours, isOverdue(), LocalRepository, #status) and the anti-names to stamp out; the criterion for function size —if you need a comment to separate two parts, they are two functions—; comments that explain the why rather than repeating the what; JSDoc documenting the public surface of each module with @typedef BoardSummary capturing the canonical numbers; // @ts-check with jsconfig.json to catch the id that arrives as a string while you are typing it, with no compilation and no change to deployment; and metrics —cyclomatic complexity, technical debt— used as a thermometer and never as a target. The real run found seven problems and two of them were genuine bugs: a deleteTask that returned true without waiting for the server, and a latent ReferenceError on a rarely taken branch.

But notice what none of those seven lines mentioned: that the board summary must come to 45 open hours out of 48, that the done → in-progress transition is forbidden by R6, that a task with no title must throw a ValidationError, that the canonical backlog has a weighted effort of 124. That is not form, it is behavior, and no static rule can check it: you have to run the code with known inputs and compare the output with what you expect. That is an automated test, and that is where the three debts you wrote down in the previous lesson are heading. In Unit Testing with Jest you will set up the test runner, write the complete battery for Task and Board —and discover that all that insistence in 03-03 on pure functions, and that clean boundary between model and view you have been maintaining for six modules, were from the very beginning what was going to make it possible to test everything without opening a browser.

JavaScript Course: From Beginner to Advanced

Module 1: Introduction to JavaScript

Module 2: Control Structures

Module 3: Functions

Module 4: Objects and Arrays

Module 5: Advanced Objects and Functions

Module 6: The Document Object Model (DOM)

Module 7: Browser APIs and Advanced Topics

Module 8: Testing and Debugging

Module 9: Performance and Optimization

Module 10: JavaScript Frameworks and Libraries

Module 11: Final Project

© Copyright 2026. All rights reserved