Tech Lead NotesRohit’s interview study guide
Chapter 03

Engineering Standards

How a lead sets standards a team actually follows: writing them down, recording architecture decisions as ADRs, a definition of done with automated quality gates, API and documentation standards, and enforcing all of it with tools (ESLint, Prettier, hooks, CI, CODEOWNERS, rulesets) instead of meetings. The tool configurations on this page were run with ESLint 10.13.0, Prettier 3.9.10, lint-staged 17.6.0 and Redocly CLI 2.71.2; workflows pass actionlint.

5 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Setting coding standards a team will follow

Added

A coding standard is the set of decisions a team makes once so nobody has to make them again in every file and every review: how code is formatted and named, which patterns are preferred, how errors are handled, what a pull request must contain. Its value isn't that the chosen style is better than another; it's that the argument is over. Review time goes to design and correctness instead of quote marks, and code written by six people in two countries reads like one person wrote it.

The mental model: standards are the team's paved road. You can still walk off it when you have a reason, but the road is where everything is easy: the linter is configured, the templates exist, the examples are there, and reviewers wave you through. A standard that's only a wiki page is a sign by the side of a dirt track; nobody reads it, and the review becomes the place where it's enforced, one argument at a time.

What makes standards stick, in practice:

  • Adopt, don't invent. Start from a published guide (Google publishes style guides for TypeScript, JavaScript, HTML/CSS, Java, Shell and more at google.github.io/styleguide; the ESLint and typescript-eslint recommended configs encode another set) and write down only your deviations.
  • Automate everything a tool can check (see Enforcing standards with tooling, not meetings). The written standard is then short: the judgement calls a tool can't make.
  • Give the reason next to each rule. "Use === because == coerces types" gets followed; "use ===" gets argued with.
  • Make it changeable. Anyone can propose a change through a pull request to the standards file or an ADR; the team decides; the tooling is updated in the same PR.
  • Apply it to new and touched code, not by rewriting the codebase in one go.

Google's code review guide states the principle that makes this work ("The Standard of Code Review", Google Engineering Practices, checked October 2026): "On matters of style, the style guide is the absolute authority," and any purely style point the guide doesn't cover is a matter of personal preference. A reviewer may suggest, but not block, on preference. Here's what a short, enforceable standards file can look like for a TypeScript web UI and Node API team:

docs/engineering-standards.md (excerpt)
# Engineering standards

Tooling enforces most of this; this page covers what it can't. To change a
standard, open a PR against this file (and the config that enforces it).

## Formatting and lint (enforced in CI, not in review)
- Prettier formats everything. Don't comment on formatting in review.
- ESLint `recommendedTypeChecked` + our rules in `eslint.config.js`.
  Warnings fail CI. Disabling a rule needs a reason:
  `// eslint-disable-next-line <rule> -- <why>`

## TypeScript
- `strict: true`. No `any` in new code; use `unknown` and narrow.
- Validate every external input at the boundary (HTTP bodies, device
  responses, env vars) with a schema; trust types only after that.

## Errors and logging
- API errors use RFC 9457 problem details (`application/problem+json`).
- Log with the request id; never log tokens, passwords or personal data.

## Pull requests
- One concern per PR, ideally under 400 changed lines. Title in
  Conventional Commits form (it becomes the squash commit).
- Tests for every behaviour change; a bug fix starts with a failing test.
- Changes under `src/api/` update `openapi.yaml` in the same PR.

## Decisions
- Significant or hard-to-reverse choices get an ADR in `docs/adr/`.
What’s happening
  1. The first paragraph sets the rule about the rules: tooling enforces what it can, this page covers the rest, and the way to change a standard is a pull request to this file plus the config. That makes the standard a living, versioned artefact with history, instead of a decree.
  2. "Don't comment on formatting in review" is the single most valuable line. Once Prettier runs in CI, formatting comments are noise; saying so explicitly gives reviewers permission to stop and authors permission to ignore them.
  3. Escape hatches are allowed but visible: eslint-disable-next-line <rule> -- <why> keeps the reason next to the exception. ESLint (10.13.0, as tested) also warns about disable comments that no longer suppress anything, so stale exceptions surface on their own.
  4. The TypeScript and error sections are judgement rules a linter only partly checks: "validate external input at the boundary" and "use problem details" are design standards, so they live here, with the concrete mechanism named.
  5. The PR section links standards to the workflow: size, title format, tests, and the API-first rule, which a CI script can check (see the PR policy model in Enforcing standards with tooling, not meetings). It's short enough to read in two minutes, which is the point: a 40-page standard is a standard nobody has read.
AdvancedRolling standards out to an existing codebase
  • Formatting: all at once. Reformat the whole repository in one commit that contains nothing else, and add that commit's hash to a .git-blame-ignore-revs file so git blame skips it (git config blame.ignoreRevsFile .git-blame-ignore-revs; GitHub's blame view also reads that file name). Doing it file by file mixes formatting noise into every real change for months.
  • Lint rules: ratchet. A new strict rule on an old codebase can produce a thousand violations. Record them as a baseline and fail only when the count goes up; ESLint has this built in as bulk suppressions (shown with real output in Enforcing standards with tooling, not meetings).
  • Architecture rules: through ADRs. "New services use the shared auth middleware" is a decision with context and consequences; record it as an ADR and link it from the standards page.
  • Boy-scout rule, with limits. Improve what you touch, but put clean-ups in their own commit or PR so reviews stay readable.
Block or suggest?

Reviewing a PR, you see (a) a function named getData that actually deletes stale records, (b) a for loop you'd personally write as .map(), and (c) a 30-line block that Prettier would format differently. Which do you block on?

Show answer

Only (a).

What’s happening
  1. (a) is a correctness and readability problem, not a style preference: a function whose name says "get" but which deletes data will mislead the next caller and could cause data loss. Block, and suggest a name like deleteStaleRecords.
  2. (b) is personal preference. Unless the standards say "prefer array methods", it's a suggestion at most, labelled as such ("nit:" or "optional:"), and the author can decline it. Google's review guide puts it plainly: a purely style point that isn't in the style guide is personal preference.
  3. (c) shouldn't reach a human at all. If CI runs prettier --check, the PR can't be merged until it's formatted; if it doesn't, the fix is to add that check, not to leave a comment.
  4. The general rule: block on correctness, security, design and agreed standards; suggest on preference; automate formatting.

#Architecture decision records

Added

An architecture decision record (ADR) is a short document that captures one significant decision: the situation that forced it, what was decided, and what follows from it. ADRs are kept in the repository, numbered, and never rewritten once accepted; when a decision changes, a new ADR supersedes the old one. Over time they become the answer to the question every new team member and every reviewer eventually asks: "why on earth is it built like this?"

The mental model: ADRs are the ship's log of the architecture. A log doesn't describe the ship (that's the design doc and the code); it records what was decided, when, and why, given what was known at the time. Nobody edits old log entries to make the captain look wise; you add a new entry that says "changed course because…".

The format comes from Michael Nygard's post "Documenting Architecture Decisions" (Cognitect blog, 15 November 2011). His records have five parts: a Title (a short noun phrase with a number), Context (the forces at play, stated as facts: technical, political, social, project), Decision (the response, in active voice: "We will…"), Status (proposed, accepted, deprecated or superseded, with a pointer to the replacement), and Consequences (everything that follows, positive, negative and neutral). He kept them as Markdown files in the repository (doc/arch/adr-NNN.md), numbered sequentially and never reused, each one or two pages long; superseded records stay, marked as such. Later templates build on it: MADR (Markdown Architectural Decision Records, version 4.0.0, 17 September 2024) adds considered options with pros and cons and decision drivers. Thoughtworks moved "Lightweight Architecture Decision Records" to the Adopt ring of its Technology Radar in November 2017 (after Trial in 2016 and March 2017) and kept it there in May 2018.

Proposed opened as a PR Accepted PR merged Rejected kept, with why Superseded by ADR n Deprecated no longer applies ADR n Accepted "Supersedes ADR 2"
Statuses change; files don't disappear. A superseding ADR and the one it replaces point at each other.

Here's a filled example in Nygard's format, for a decision a device-management web UI team might plausibly face. It's illustrative: the numbers in it are the example's own assumptions, not measurements of any real product.

docs/adr/0004-push-device-status-over-sse.md (illustrative example)
# 4. Push device status to the web UI over server-sent events

Date: 2026-10-12

## Status

Accepted. Supersedes [ADR 2](0002-poll-device-status.md).

## Context

The fleet page shows the online/in-call/offline status of every device a
customer manages. ADR 2 chose polling `GET /v1/devices?fields=status`
every 30 s. Since then:

- Customers with 2,000+ devices see status up to 30 s stale, and support
  tickets ask why a device "in a call" shows as idle.
- Polling is now 40% of API requests, almost all returning no change.
- Status changes already flow through our event bus (ADR 3).
- Updates only go server → browser; the UI never streams data back.
- Some customer networks proxy traffic and strip WebSocket upgrades.

## Decision

We will push status changes to the browser with server-sent events
(`GET /v1/device-events`, `text/event-stream`) from a small gateway that
subscribes to the event bus. The UI loads the current state with one
`GET /v1/devices` and then applies events. On reconnect the browser sends
`Last-Event-ID` and the gateway replays missed events from the last
5 minutes; older gaps trigger a full reload.

## Consequences

- Status reaches the UI in about a second instead of up to 30 s.
- Polling traffic goes away; the gateway holds one long-lived HTTP
  connection per open fleet page, so it needs its own scaling and
  connection limits, and load balancer idle timeouts above our 25 s
  heartbeat.
- SSE is plain HTTP, so it passes through the proxies that break
  WebSockets. It is one-way; if we later need browser → server streaming
  we will revisit this (WebSocket).
- We now operate a new service with its own on-call runbook.
- ADR 2 is superseded; its polling endpoint stays for API clients.
What’s happening
  1. The title is the decision, not the topic: "Push device status to the web UI over server-sent events", not "Real-time updates". Someone scanning the ADR index learns the architecture from the titles alone. The number (4) and the file name are permanent.
  2. Status points both ways: this record says it supersedes ADR 2, and ADR 2's status is changed to "Superseded by ADR 4". That's the only edit an accepted ADR ever gets. Reading ADR 2 still tells you why polling was right at the time.
  3. Context is facts and forces, not the solution: staleness complaints, the share of traffic, an existing event bus, the one-way data flow, and the proxies that break WebSockets. Each bullet is something a reviewer can check, and together they make the decision almost inevitable, which is the sign of a good context section.
  4. The decision is concrete and in active voice ("We will…"): the endpoint, the media type, how the initial state is loaded, and what happens on reconnect (Last-Event-ID, a 5-minute replay window). Vague decisions ("use real-time technology") can't be implemented or reviewed.
  5. Consequences include the costs: a new service to scale and run, load balancer timeouts to configure, one-way only, an on-call runbook. Nygard's format asks for all consequences, not only the good ones; an ADR that lists only benefits is a sales pitch, and reviewers should send it back.
  6. It fits on one page, and it took an hour to write. That's the trade: an hour now against the week a future team would spend rediscovering why the fleet page doesn't just use WebSockets.
AdvancedA blank ADR template

A blank template to copy

Nygard's five sections, plus two optional ones borrowed from MADR (options considered and decision drivers), which help most when a decision was genuinely contested:

docs/adr/0000-template.md
# N. <Decision, as a short noun phrase>

Date: YYYY-MM-DD
Deciders: <names or teams>

## Status

Proposed | Accepted | Rejected | Deprecated | Superseded by [ADR n](…)

## Context

The forces at play, as neutral facts: requirements, constraints, numbers,
what exists today, what's changed. Link the issue, incident or design doc.

## Options considered (optional)

1. <Option> — pros / cons
2. <Option> — pros / cons

## Decision

We will … (active voice, specific enough to implement and review).

## Consequences

What becomes easier, what becomes harder, new risks and costs, follow-up
work, and what would make us revisit this decision.
What’s happening
  1. N. and the file name NNNN-kebab-title.md give each record a permanent, sortable id; 0000-template.md sorts to the top and is never a real decision.
  2. Date and Deciders answer "who decided, and when" without digging through Git history, which matters once people leave the team.
  3. The status line lists every allowed value, so a reviewer (or a CI check) can see at once whether a status is valid.
  4. Options considered is where a contested decision shows its work: two or three real alternatives with honest pros and cons. Skip it for obvious decisions; for contentious ones it's what stops the debate from restarting every six months.
  5. The last prompt in Consequences, "what would make us revisit this", is the most useful line in the template: it tells a future team which change in circumstances makes the decision worth reopening.
AdvancedChecking ADRs in CI

Because ADRs are files with a fixed shape, a small script can keep them honest. This model writes five sample records to a temporary docs/adr/ folder and checks them the way a CI step would:

adr-check.mjs
// A CI check for docs/adr/: every record has Nygard's sections and a known status, numbers are
// sequential and never reused, and "superseded" records point at a record that exists.
// It writes four sample ADRs to a temporary folder and checks them like a real repository would.
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const root = mkdtempSync(join(tmpdir(), "adr-"));
const adrDir = join(root, "docs/adr");
mkdirSync(adrDir, { recursive: true });
const adr = (file, title, status, extra = "") =>
  writeFileSync(join(adrDir, file), `# ${title}\n\n## Status\n\n${status}\n\n## Context\n\n…\n\n## Decision\n\n…\n${extra}`);
adr("0001-record-architecture-decisions.md", "1. Record architecture decisions", "Accepted", "\n## Consequences\n\n…\n");
adr("0002-poll-device-status.md", "2. Poll device status every 30 s", "Superseded by [ADR 4](0004-push-device-status-over-sse.md)", "\n## Consequences\n\n…\n");
adr("0003-use-openapi-first.md", "3. Write the OpenAPI document before the code", "Accepted"); // forgot Consequences
adr("0004-push-device-status-over-sse.md", "4. Push device status over server-sent events", "Proposed", "\n## Consequences\n\n…\n");
adr("0006-feature-flags.md", "6. Use feature flags for unfinished work", "Acepted", "\n## Consequences\n\n…\n"); // typo, and 5 is missing

const STATUSES = /^(Proposed|Accepted|Rejected|Deprecated|Superseded by \[ADR (\d+)\]\(.+\))$/;
const REQUIRED = ["Status", "Context", "Decision", "Consequences"];
const problems = [];
try {
  const files = readdirSync(adrDir).filter((f) => /^\d{4}-[a-z0-9-]+\.md$/.test(f)).sort();
  const records = files.map((file) => {
    const text = readFileSync(join(adrDir, file), "utf8");
    const status = (text.match(/## Status\n\n(.+)\n/) || [])[1] ?? "";
    return { file, number: Number(file.slice(0, 4)), text, status, title: text.split("\n")[0].replace(/^# \d+\. /, "") };
  });
  const byNumber = new Map(records.map((r) => [r.number, r]));
  records.forEach((r, i) => {
    if (r.number !== i + 1) problems.push(`${r.file}: expected number ${i + 1} (numbers are sequential, never reused)`);
    const sections = [...r.text.matchAll(/^## (.+)$/gm)].map((m) => m[1]);
    for (const s of REQUIRED) if (!sections.includes(s)) problems.push(`${r.file}: missing "## ${s}"`);
    const m = r.status.match(STATUSES);
    if (!m) problems.push(`${r.file}: unknown status "${r.status}"`);
    else if (m[2]) {
      const next = byNumber.get(Number(m[2]));
      if (!next) problems.push(`${r.file}: superseded by ADR ${m[2]}, which doesn't exist`);
      else if (next.status !== "Accepted") problems.push(`${r.file}: superseded by ADR ${m[2]}, which is still "${next.status}"`);
    }
  });
  console.log("| ADR | Title | Status |\n|---|---|---|");
  for (const r of records) console.log(`| ${r.file.slice(0, 4)} | ${r.title} | ${r.status.replace(/\]\(.+\)/, "]")} |`);
  // A real CI step would end with process.exit(problems.length ? 1 : 0); this model only prints.
  console.log(problems.length ? `\n${problems.length} problems:\n- ${problems.join("\n- ")}` : "\nAll ADRs OK");
} finally {
  rmSync(root, { recursive: true, force: true });
}
Result
| ADR | Title | Status |
|---|---|---|
| 0001 | Record architecture decisions | Accepted |
| 0002 | Poll device status every 30 s | Superseded by [ADR 4] |
| 0003 | Write the OpenAPI document before the code | Accepted |
| 0004 | Push device status over server-sent events | Proposed |
| 0006 | Use feature flags for unfinished work | Acepted |

4 problems:
- 0002-poll-device-status.md: superseded by ADR 4, which is still "Proposed"
- 0003-use-openapi-first.md: missing "## Consequences"
- 0006-feature-flags.md: expected number 5 (numbers are sequential, never reused)
- 0006-feature-flags.md: unknown status "Acepted"
What’s happening
  1. The model writes five files: a clean ADR 1, ADR 2 marked "Superseded by ADR 4", ADR 3 missing its Consequences section, ADR 4 still "Proposed", and a file numbered 0006 with a misspelt status (and no 0005).
  2. It reads every NNNN-title.md file in order and parses each record's number, title (from the # heading) and status line (the paragraph under ## Status), then builds a lookup by number for the cross-checks.
  3. Numbering: the fifth file should be number 5, so 0006 is reported. Nygard's rule is that numbers are sequential and never reused, which keeps every link like "see ADR 5" stable forever.
  4. Shape and statuses: ADR 3 is missing ## Consequences, and Acepted isn't a known status. These are exactly the mistakes reviewers miss, because they're reading the argument, not the headings.
  5. Cross-references: ADR 2 says it's superseded by ADR 4, but ADR 4 is still only "Proposed". Either ADR 4 should be accepted in the same PR or ADR 2 shouldn't have been changed yet. The index table at the top is generated from the files, so it's never out of date; teams publish it as docs/adr/README.md.
  6. A real CI step would exit non-zero when there are problems; the model only prints, so its output can be shown here. The same idea, one-off checks on the shape of documents, works for runbooks and RFCs too.
Edit the old ADR or write a new one?

ADR 7 (accepted a year ago) says the web UI calls the device API directly from the browser. The team now wants a backend-for-frontend (BFF) service between them. A colleague opens a PR that rewrites ADR 7's Decision section. What should happen instead?

Show answer

Write ADR 12 (the next free number) that supersedes ADR 7, and change only ADR 7's status line.

What’s happening
  1. An accepted ADR is a historical record: it explains why direct calls were right a year ago, given what was known. Rewriting it erases that reasoning and makes the history misleading; anyone reading old PRs or incidents that cite "ADR 7" would now find a different decision.
  2. The new decision gets a new number, say ADR 12, with its own context (what changed: new security requirements, too many round trips, a second client), its decision ("We will add a BFF…") and its consequences (a new service, extra latency, a place to aggregate calls).
  3. ADR 12's status says "Accepted. Supersedes ADR 7", and ADR 7's status becomes "Superseded by ADR 12". That status line is the only change to ADR 7.
  4. Both changes go in the same PR, so the cross-references are consistent the moment it merges; the CI check above would catch a supersede link pointing at a record that isn't accepted.

#Definition of done and quality gates

Added

A definition of done (DoD) is the team's shared checklist for when a piece of work is finished: not "the code is written" but "it's tested, reviewed, documented, merged and releasable". It exists because "done" otherwise means something different to each person: the developer means "works on my machine", the tester means "passed QA", the product owner means "customers can use it". Agreeing one definition removes a whole class of surprises at the end of a sprint.

The Scrum Guide (Schwaber and Sutherland, November 2020, still the current edition at scrumguides.org in October 2026) defines the DoD as a formal description of the state of the Increment when it meets the quality measures required for the product. If the organisation has a DoD standard, every team must follow it as a minimum; otherwise the Scrum Team creates one. Work that doesn't meet it can't be released or even presented at the Sprint Review; it goes back to the Product Backlog. Several teams on one product must share the same DoD.

The mental model: acceptance criteria say what this one story must do ("the fleet page shows offline devices in grey"); the definition of done says how well every story must be finished (tests, review, docs, accessibility, monitoring). A quality gate is the part of the DoD a machine can check, wired into CI so that a pull request physically can't merge until it passes. The best DoDs are mostly gates, plus a handful of human checks.

docs/definition-of-done.md
# Definition of done (web UI and device API)

A story is done when ALL of this is true. CI checks the first block;
the PR template carries the second; the PO confirms the third.

## Automated gates (required checks, merge is blocked until green)
- [ ] Build passes; Prettier, ESLint (0 warnings) and `tsc` are clean
- [ ] Unit and integration tests pass; changed lines >= 80% covered
- [ ] No new high/critical vulnerabilities (dependency + code scanning)
- [ ] Bundle within budget (+5% max); API changes update openapi.yaml
- [ ] Accessibility checks pass on changed pages (axe, no serious issues)

## In the pull request (author ticks, reviewer verifies)
- [ ] Reviewed and approved by a code owner
- [ ] Docs/runbook updated (README, API docs, alerts, feature flag noted)
- [ ] Tested on the supported browsers / a real device if device-facing

## With the product owner
- [ ] Acceptance criteria demoed to the PO on the staging environment
- [ ] Merged to main and deployed to staging; release note drafted
What’s happening
  1. The opening line says who checks what: CI, the PR template, or the product owner. A DoD item with no checker is a wish; assigning each line to a mechanism is what makes it real.
  2. The automated block is a list of required status checks. Each line maps to a CI job (lint, types, tests, coverage on changed lines, a vulnerability scan, a bundle-size check, an accessibility scan), and the branch rule makes them required, so "done" can't be claimed with a red build.
  3. Coverage is measured on changed lines, not the whole codebase: an old codebase at 45% shouldn't block every PR, but new code should be tested. The threshold (80%) is a team choice; the trend matters more than the number.
  4. The PR block is what tools can't judge: is the runbook updated, was it tried on a real device? These live in the PR template as checkboxes, so they're in front of the author and reviewer at exactly the moment they matter.
  5. The PO block closes the loop with acceptance criteria: the story is shown working on staging against its own criteria. Notice what's not here: "deployed to production". Whether that's part of done depends on the team's delivery model; with continuous deployment it often is.
  6. One page, every line checkable. If the team regularly can't meet an item, either fix the process (automate it, budget time for it) or drop it openly; a DoD that's quietly ignored is worse than a short honest one.
AdvancedDoD, acceptance criteria and definition of ready
Applies toWho writes itExample
Acceptance criteriaone backlog itemproduct owner with the team"Offline devices are shown in grey with the time they were last seen"
Definition of doneevery itemthe whole team (or the organisation)"Tests pass, reviewed, docs updated, deployed to staging"
Definition of readyitems entering a sprintthe team"Has acceptance criteria, designs attached, dependencies known"
Quality gateevery pull request or buildencoded in CI"Changed-line coverage >= 80%, 0 lint errors"

The definition of ready isn't part of the Scrum Guide; it's a common team practice. Use it lightly: an over-strict DoR becomes a wall between product and engineering ("not ready, not our problem").

AdvancedA definition-of-done gate in code

The automated and human halves can be checked together. This model evaluates three pull requests: CI results for the gates, and the ticked boxes in the PR description for the human items:

dod-gate.mjs
// A definition-of-done gate: automated checks come from the CI summary, human items from the
// pull request's checklist. A PR is mergeable only when both are complete.
const gates = [
  ["build", (ci) => ci.build === "pass", () => "build failed"],
  ["unit + integration tests", (ci) => ci.failedTests === 0, (ci) => `${ci.failedTests} failing tests`],
  ["lint (0 errors)", (ci) => ci.lintErrors === 0, (ci) => `${ci.lintErrors} lint errors`],
  ["coverage of changed lines >= 80%", (ci) => ci.changedLineCoverage >= 80, (ci) => `changed-line coverage ${ci.changedLineCoverage}%`],
  ["no new high/critical vulnerabilities", (ci) => ci.newHighVulns === 0, (ci) => `${ci.newHighVulns} new high/critical vulnerabilities`],
  ["bundle within budget (+5%)", (ci) => ci.bundleKb <= ci.budgetKb * 1.05, (ci) => `bundle ${ci.bundleKb} KB > ${ci.budgetKb * 1.05} KB`],
  ["API change => OpenAPI updated", (ci) => !ci.touchesApi || ci.touchesOpenApi, () => "src/api changed but openapi.yaml did not"],
];
const humanItems = ["Acceptance criteria demoed to the PO", "Docs/runbook updated", "Reviewed by a code owner"];

function evaluate(pr) {
  const failures = gates.filter(([, ok]) => !ok(pr.ci)).map(([, , why]) => why(pr.ci));
  const ticked = new Set([...pr.body.matchAll(/^- \[x\] (.+)$/gim)].map((m) => m[1].trim()));
  const unticked = humanItems.filter((item) => !ticked.has(item));
  return { failures, unticked, done: failures.length === 0 && unticked.length === 0 };
}

const allTicked = humanItems.map((i) => `- [x] ${i}`).join("\n");
const prs = [
  { id: 101, ci: { build: "pass", failedTests: 0, lintErrors: 0, changedLineCoverage: 91, newHighVulns: 0, bundleKb: 208, budgetKb: 200, touchesApi: true, touchesOpenApi: true }, body: allTicked },
  { id: 102, ci: { build: "pass", failedTests: 0, lintErrors: 0, changedLineCoverage: 64, newHighVulns: 0, bundleKb: 190, budgetKb: 200, touchesApi: true, touchesOpenApi: false }, body: allTicked },
  { id: 103, ci: { build: "pass", failedTests: 2, lintErrors: 0, changedLineCoverage: 88, newHighVulns: 1, bundleKb: 240, budgetKb: 200, touchesApi: false, touchesOpenApi: false },
    body: "- [x] Acceptance criteria demoed to the PO\n- [ ] Docs/runbook updated\n- [x] Reviewed by a code owner" },
];

for (const pr of prs) {
  const { failures, unticked, done } = evaluate(pr);
  console.log(`PR #${pr.id}: ${done ? "DONE - can merge" : "NOT DONE"}`);
  for (const f of failures) console.log(`  gate:  ${f}`);
  for (const u of unticked) console.log(`  human: "${u}" not ticked`);
}
Result
PR #101: DONE - can merge
PR #102: NOT DONE
  gate:  changed-line coverage 64%
  gate:  src/api changed but openapi.yaml did not
PR #103: NOT DONE
  gate:  2 failing tests
  gate:  1 new high/critical vulnerabilities
  gate:  bundle 240 KB > 210 KB
  human: "Docs/runbook updated" not ticked
What’s happening
  1. Each gate is a name, a test and a message. The tests read a CI summary object (in real life, the JSON your pipeline's jobs upload as artefacts): build status, failed tests, lint errors, changed-line coverage, new high or critical vulnerabilities, bundle size against a budget, and whether src/api/ changed without openapi.yaml.
  2. The human items are found by matching - [x] … lines in the PR body (case-insensitive x, the GitHub task-list syntax). Anything in the list that isn't ticked is reported by name.
  3. PR #101 passes everything. Its bundle is 208 KB against a 200 KB budget, but the gate allows +5% (210 KB), so it's within tolerance: budgets with a margin stop trivial changes from failing while still catching real growth.
  4. PR #102 is blocked by two gates: changed-line coverage of 64% and an API change with no OpenAPI update. Its checklist is fully ticked, which shows why the human half alone isn't enough: ticking a box doesn't write the test.
  5. PR #103 fails three gates (two failing tests, one new high-severity vulnerability, a 240 KB bundle) and has an unticked docs item. Reporting every reason at once, instead of stopping at the first, saves the author round trips, which matters a lot when the reviewer is twelve hours away.
AdvancedThe PR template that carries the human items

The pull request template that carries the human items

GitHub fills a new PR's description from .github/pull_request_template.md (it also accepts pull_request_template.md at the repository root or in docs/, and several templates in a PULL_REQUEST_TEMPLATE/ folder, according to GitHub Docs, checked October 2026):

.github/pull_request_template.md
## What and why
<!-- One or two sentences. Link the ticket: DEV-1234 -->

## How to test
<!-- Steps a reviewer can follow, or "covered by tests in …" -->

## Screenshots / recordings
<!-- UI changes: before and after. Device-facing: which device/firmware -->

## Definition of done
- [ ] Acceptance criteria demoed to the PO
- [ ] Docs/runbook updated
- [ ] Reviewed by a code owner
- [ ] Feature flag noted (name, default, removal ticket) or N/A
What’s happening
  1. "What and why" is the part that becomes the body of the squash commit, so it's worth one or two good sentences; the comment prompts keep it short.
  2. "How to test" is the most useful section for a reviewer in another time zone: they can verify the change without a call.
  3. Screenshots are cheap for the author and save the reviewer from running the branch just to see a UI change; for device-facing work, naming the device and firmware it was tried on makes the testing claim checkable.
  4. The DoD checklist uses the exact wording the gate script matches, so ticking a box is visible to tools as well as people. The feature-flag line is there because flags are easy to add and easy to forget to remove.
Is this story done?

A story's acceptance criteria are all met and demoed. The code is merged, but the PR's changed-line coverage was 52% (the gate is 80%) because an admin merged it with the "bypass rules" option to hit the sprint deadline. Is it done?

Show answer

No. It doesn't meet the definition of done, so it isn't done, whatever the demo showed.

What’s happening
  1. Acceptance criteria and the DoD are two separate tests: the criteria say the story does the right thing, the DoD says it's finished to the team's quality bar. Passing one doesn't excuse the other.
  2. The coverage gate is part of the DoD, and it failed. Bypassing the rule got the code merged, but merging isn't the same as done.
  3. Under the Scrum Guide, an item that doesn't meet the DoD can't be presented as done at the Sprint Review and goes back to the Product Backlog; in practice, the team adds the missing tests (often a small follow-up story) before calling it complete.
  4. The lead's follow-up is about the bypass, not the person: who can bypass, when it's acceptable (a production incident, recorded), and making sure a deadline isn't the reason. Repeated bypasses mean the gate or the planning needs to change.

#API and documentation standards

Added

On a team that builds a web UI and the APIs it (and other clients, such as device software or customers' integrations) depends on, the API is the contract between teams, and documentation is how anyone outside the room learns it. API standards make every endpoint look like it was designed by one person: the same naming, errors, pagination, versioning and auth. Documentation standards make sure each kind of reader (a new developer, an integrator, the on-call engineer at 3 a.m.) finds the document they need.

The mental model: an API style guide is a style guide for the contract, and the OpenAPI document is the contract itself, machine-readable, so it can be linted, diffed in review, turned into docs and client SDKs, and tested against. "API first" means writing or changing that document in the same pull request as the code (or before it), so the contract is reviewed as a contract. The Zalando RESTful API Guidelines, for example, require teams to define APIs first, before coding, using OpenAPI.

You don't need to write an API style guide from scratch. Published ones worth adopting or borrowing from: Google's API Improvement Proposals (aip.dev, design documents that summarise Google's API design decisions), the Microsoft REST API Guidelines (github.com/microsoft/api-guidelines, with Azure and Microsoft Graph variants), and the Zalando RESTful API and Event Guidelines. The current OpenAPI Specification is 3.2.0 (September 2025, spec.openapis.org); 3.1 is still the most widely supported by tools, and it's the version used below. Errors should use RFC 9457 Problem Details for HTTP APIs (2023): application/problem+json with type, title, status, detail and instance.

openapi.yaml (excerpt; passes Redocly CLI 2.71.2 lint)
openapi: 3.1.0
info:
  title: Device Management API
  version: 1.4.0
  description: Manage the video-conferencing devices in a customer's fleet.
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
servers:
  - url: https://api.example.com
security:
  - bearerAuth: []
paths:
  /v1/devices/{deviceId}/reboot:
    post:
      operationId: rebootDevice
      summary: Reboot a device
      description: >
        Queues a reboot. The device reboots when it next checks in, usually within
        30 seconds. Poll the returned job, or listen for the device.rebooted event.
      tags: [devices]
      parameters:
        - $ref: "#/components/parameters/DeviceId"
        - name: Idempotency-Key
          in: header
          required: true
          description: A client-generated UUID; retries with the same key return the same job.
          schema: { type: string, format: uuid }
      responses:
        "202":
          description: Reboot queued.
          headers:
            Location:
              description: URL of the job to poll.
              schema: { type: string }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Job" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: The device is in a call; retry after the call ends.
          content:
            application/problem+json:
              schema: { $ref: "#/components/schemas/Problem" }
components:
  securitySchemes:
    bearerAuth: { type: http, scheme: bearer }
  parameters:
    DeviceId:
      name: deviceId
      in: path
      required: true
      description: The device's id, for example dev_8Hx2.
      schema: { type: string, pattern: "^dev_[A-Za-z0-9]+$" }
  responses:
    NotFound:
      description: No device with this id is visible to the caller.
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
  schemas:
    Job:
      type: object
      required: [jobId, status]
      properties:
        jobId: { type: string, examples: [job_91] }
        status: { type: string, enum: [queued, running, succeeded, failed] }
    Problem:
      description: RFC 9457 problem details.
      type: object
      required: [type, title, status]
      properties:
        type: { type: string, format: uri-reference }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        instance: { type: string }
tags:
  - name: devices
    description: Devices in the fleet.
What’s happening
  1. The path follows the house rules: version prefix /v1, plural resource noun devices, an id, and a sub-resource for the action (/reboot). It's a POST because rebooting isn't idempotent by nature, which is why the operation requires an Idempotency-Key header: a retried request with the same key returns the same job instead of rebooting twice.
  2. operationId: rebootDevice is camelCase and unique: code generators use it as the SDK method name (client.rebootDevice(...)), so it's part of the contract, not decoration. summary becomes the heading in the generated docs; description says what actually happens (queued, typically within 30 seconds, how to find out the result).
  3. The response is 202 Accepted with a Location header and a Job, because the reboot happens later. The schema's enum for status lists every state a client must handle; adding a state later is a contract change to discuss, not a silent edit.
  4. Errors use RFC 9457 problem details (application/problem+json with the shared Problem schema). 404 is a reusable response under components, so every endpoint describes "not found" the same way; 409 documents the one domain-specific failure (the device is in a call) and what to do about it.
  5. Reuse through components: the DeviceId parameter (with a pattern that documents the id format), the NotFound response and the schemas are defined once and referenced with $ref. Consistency comes from the document's structure, not from people remembering.
  6. It's checked by a tool: npx redocly lint openapi.yaml with Redocly's built-in recommended rules validated it with one warning, no-server-example.com (the server URL points at example.com), which the team config in the next section turns off on purpose. Run on every PR, the linter is what turns the style guide into a gate.
AdvancedMaking the style guide executable

Redocly CLI's recommended ruleset is a starting point; a redocly.yaml next to the spec raises the rules the team cares about to errors. Here's that config, and the real result of linting the same document with one badly designed operation added (GET /v1/getCallHistory with only tags and a 200 response):

redocly.yaml
extends:
  - recommended
rules:
  operation-operationId: error
  operation-4xx-response: error
  operation-tag-defined: error
  no-server-example.com: off # our docs use example.com on purpose
npx redocly lint bad.yaml --format=stylish (Redocly CLI 2.71.2)
validating bad.yaml...
bad.yaml:
  46:5   error    operation-operationId   Operation object should contain `operationId` field.
  47:14  error    operation-tag-defined   Operation tags should be defined in global tags.
  46:5   error    operation-summary       Operation object should contain `summary` field.
  48:7   error    operation-4xx-response  Operation must have at least one `4XX` response.

bad.yaml: validated in 20ms

❌ Validation failed with 4 errors.
run `redocly lint --generate-ignore-file` to add all problems to the ignore file.
What’s happening
  1. extends: [recommended] keeps Redocly's defaults; the rules block overrides individual rules by name. Missing operationId, a missing 4XX response and an undeclared tag become errors, and the example.com server warning is turned off with a comment saying why.
  2. The bad operation trips four rules. Two were warnings under recommended alone (operationId, 4XX), one is newly enabled (operation-tag-defined: the calls tag isn't declared in the global tags list), and operation-summary is already an error in the recommended set.
  3. The exit code is non-zero, so in CI the PR is blocked; the good document from the main example, linted with the same config, passes with no problems. (validated in 20ms is the real timing of that run, and will differ on other machines.)
  4. Things the built-in rules don't cover, such as "no verbs in paths" or "collection GETs take limit and cursor", can be written as custom rules (Redocly and Spectral both support them) or as a small script, as the next model shows.
AdvancedHouse rules as a script

When a rule is specific to your team, a few lines of code over the parsed OpenAPI document are often simpler than a plugin. This model encodes six house rules and reports like a linter:

api-lint.mjs
// A team API style guide written as code: a few rules checked against an OpenAPI 3.1 document,
// reported the way linters such as Spectral or Redocly report them (path, severity, message).
const spec = {
  openapi: "3.1.0",
  info: { title: "Device API", version: "1.4.0" },
  paths: {
    "/v1/devices": {
      get: { operationId: "listDevices", summary: "List devices", tags: ["devices"],
        parameters: [{ name: "limit", in: "query" }, { name: "cursor", in: "query" }],
        responses: { 200: {}, 400: { content: { "application/problem+json": {} } } } },
    },
    "/v1/devices/{deviceId}/reboot": {
      post: { operationId: "rebootDevice", summary: "Reboot a device", tags: ["devices"],
        responses: { 202: {}, 404: { content: { "application/problem+json": {} } } } },
    },
    "/v1/calls": {
      get: { operationId: "listCalls", summary: "List calls", tags: ["calls"], parameters: [{ name: "page", in: "query" }],
        responses: { 200: {}, 400: { content: { "application/problem+json": {} } } } },
    },
    "/v1/getCallHistory": {
      get: { operationId: "get_call_history", tags: ["calls"], responses: { 200: {} } },
    },
  },
};

const VERBS = /^(get|create|update|delete|fetch|list)[A-Z]/;
const rules = [
  ["path-kebab-case", "error", (path) => path.split("/").filter((s) => s && !s.startsWith("{")).every((s) => /^[a-z0-9-]+$/.test(s)), "path segments must be lower kebab-case"],
  ["path-no-verbs", "error", (path) => !path.split("/").some((s) => VERBS.test(s)), "use a resource noun; the HTTP method is the verb"],
  ["operation-id-camel", "error", (_, op) => /^[a-z][a-zA-Z0-9]*$/.test(op.operationId ?? ""), "operationId must be camelCase (it becomes the SDK method name)"],
  ["operation-summary", "warn", (_, op) => Boolean(op.summary), "every operation needs a summary (it is the docs heading)"],
  ["problem-details", "error", (_, op) => Object.entries(op.responses).some(([code, r]) => code >= 400 && r.content?.["application/problem+json"]), "declare at least one 4xx/5xx response as application/problem+json (RFC 9457)"],
  ["list-paginated", "warn", (path, op, method) => method !== "get" || path.endsWith("}") || !/s$/.test(path) || ["limit", "cursor"].every((p) => op.parameters?.some((x) => x.name === p)), "collection GETs take limit and cursor"],
];

let errors = 0, warnings = 0;
for (const [path, item] of Object.entries(spec.paths)) {
  for (const [method, op] of Object.entries(item)) {
    for (const [name, severity, ok, message] of rules) {
      if (ok(path, op, method)) continue;
      severity === "error" ? errors++ : warnings++;
      console.log(`${severity.padEnd(5)}  ${`${method.toUpperCase()} ${path}`.padEnd(26)} ${name.padEnd(19)} ${message}`);
    }
  }
}
console.log(`\n${errors} errors, ${warnings} warnings`);
Result
warn   GET /v1/calls              list-paginated      collection GETs take limit and cursor
error  GET /v1/getCallHistory     path-kebab-case     path segments must be lower kebab-case
error  GET /v1/getCallHistory     path-no-verbs       use a resource noun; the HTTP method is the verb
error  GET /v1/getCallHistory     operation-id-camel  operationId must be camelCase (it becomes the SDK method name)
warn   GET /v1/getCallHistory     operation-summary   every operation needs a summary (it is the docs heading)
error  GET /v1/getCallHistory     problem-details     declare at least one 4xx/5xx response as application/problem+json (RFC 9457)

4 errors, 2 warnings
What’s happening
  1. The spec is a parsed OpenAPI 3.1 document (in CI you'd load openapi.yaml with a YAML parser). Each rule is a name, a severity, a predicate over (path, operation, method) and a message that explains the rule's reason, not just its letter.
  2. /v1/devices and the reboot endpoint pass every rule. /v1/calls is a collection GET that pages with page instead of limit and cursor, so list-paginated warns: offset paging is allowed but discouraged, which is why it's a warning.
  3. /v1/getCallHistory breaks five rules at once: a camelCase segment, a verb in the path (get…; the HTTP method is already the verb), a snake_case operationId (it would become client.get_call_history() in a TypeScript SDK), no summary, and no problem-details error response.
  4. The output has the shape of a real linter (severity, location, rule, message), so it reads the same in a CI log. A rule written down this way can't be forgotten in review, and its message teaches the standard to whoever trips it.
AdvancedDocumentation standards: READMEs, runbooks and Diátaxis

The Diátaxis framework (by Daniele Procida, diataxis.fr) sorts documentation into four kinds, each serving a different need: tutorials (learning by doing, a first experience), how-to guides (steps to solve one specific problem), reference (accurate descriptions of the machinery: the API reference generated from OpenAPI is this) and explanation (background and reasoning: ADRs and design docs are this). Most bad documentation mixes them: a reference page that turns into a tutorial halfway, or a README that tries to be all four.

Two documents every service in the repository should have, with fixed headings so people know where to look:

README.md (service template)
# device-status-gateway

Pushes device status changes to the web UI over server-sent events (ADR 4).

## Run it locally
npm ci && npm run dev   # needs Node 24 and a local event bus: docker compose up bus

## Test
npm test                # unit + integration; npm run test:e2e for the browser suite

## Configuration
| Variable | Default | Meaning |
|---|---|---|
| BUS_URL | nats://localhost:4222 | event bus to subscribe to |
| REPLAY_WINDOW_S | 300 | how far back Last-Event-ID replay goes |

## Owners and links
@acme/api-team · runbook: docs/runbook.md · API: openapi.yaml · ADRs: docs/adr/
What’s happening
  1. One sentence of purpose, with the ADR that explains why it exists. A new developer learns what it is and where the reasoning lives before anything else.
  2. "Run it locally" and "Test" are copy-pasteable commands, with their prerequisites stated on the same line. The best test of a README is a new team member, ideally in the other office, getting it running without asking anyone.
  3. Configuration is a table of every environment variable with its default and meaning: reference material, kept next to the code so it's updated in the same PR that adds a variable.
  4. Owners and links point to the other kinds of document (runbook, API reference, ADRs) instead of repeating them, which is Diátaxis in practice: the README is a hub, not an encyclopedia.
docs/runbook.md (one alert, template)
## Alert: SSE gateway connections dropping

**Means:** more than 5% of fleet pages reconnected in the last 10 minutes.
**Impact:** status on the fleet page may lag; no data loss (UI reloads on gaps).
**Check:** dashboard "SSE gateway" → reconnect rate, LB idle timeout errors.
**Likely causes:** gateway restart loop; load balancer idle timeout below 25 s
heartbeat; event bus disconnect.
**Fix:** roll back the last gateway deploy (`deploy rollback sse-gateway`), or
raise the LB idle timeout. Escalate to @acme/api-team on-call after 30 min.
What’s happening
  1. A runbook entry is a how-to guide for one alert, written for someone who didn't build the system and has just been woken up. The heading is the alert's exact name, so searching for the page title finds it.
  2. Means and Impact come first: the reader decides how urgent this is before doing anything. "No data loss" is the most reassuring sentence an on-call engineer can read.
  3. Check and Likely causes turn the builder's knowledge into a short decision tree; the 25 s heartbeat ties back to the consequence recorded in the ADR.
  4. Fix gives exact commands and an escalation rule with a time limit. Every alert without a runbook entry is a standards violation worth a ticket.
Breaking or not?

In the Job schema above, a PR (a) adds an optional response field estimatedSeconds, (b) adds a new value cancelled to the status enum, and (c) renames jobId to id. Which changes are breaking for existing clients?

Show answer

(c) definitely; (b) very likely; (a) normally not.

What’s happening
  1. (c) Renaming a required response field breaks every client that reads jobId: their code gets undefined. That needs a new API version, or a transition period where both fields are returned and jobId is marked deprecated: true.
  2. (b) Adding an enum value looks additive, but clients often switch over the documented values; a TypeScript client generated from the old spec has status: "queued" | "running" | "succeeded" | "failed", and a polling loop that waits for "succeeded or failed" may now wait forever on cancelled. Treat it as breaking unless the API declared from the start that clients must tolerate unknown values.
  3. (a) Adding an optional field to a response is normally safe: well-behaved clients ignore fields they don't know. (Adding a required request field would be breaking, because old clients don't send it.)
  4. This is why the standard says API changes update openapi.yaml in the same PR: a breaking-change check (oasdiff's breaking command compares two specs, for example) can then flag (b) and (c) automatically, before review.

#Enforcing standards with tooling, not meetings

Added

Every standard a person has to remember will be forgotten by someone, usually under deadline, and every standard a reviewer has to enforce turns review into an argument. The alternative is to move each check as early and as automatic as it can go: formatting on save, lint in the editor, a pre-commit hook that fixes what it can, CI checks that are required to merge, and repository rules that even admins have to deliberately bypass. Humans are then left with the judgement calls.

The mental model is layers of nets, from fine to strong. The editor catches most problems within seconds, but it's optional. The pre-commit hook catches problems before they're shared, but it can be skipped with --no-verify. CI can't be skipped, but it's minutes away. Branch rules make CI's verdict binding. Code review is the last and most expensive net, so it should only see what the others can't catch.

Editor format on save lint as you type Pre-commit lint-staged: ESLint + Prettier on staged files CI format, lint, types tests, PR policy Branch rules required checks code owner review no force pushes Code review design and correctness only seconds optional seconds --no-verify skips it minutes can't be skipped makes CI binding bypass is explicit hours the expensive net Move every check as far left as it can go; leave humans the judgement calls.
Fast, optional checks on the left; slower, binding ones on the right. The same configs run in every layer.

The foundation is one lint config that every layer runs. This is an ESLint 10 flat config for a TypeScript web UI and API (ESLint 10, released February 2026, removed the old .eslintrc format entirely, so eslint.config.js is the only option), run against a small file with four deliberate problems (shown first, in the note).

eslint.config.js (run with ESLint 10.13.0, typescript-eslint 8.72.1)
import js from "@eslint/js";
import { defineConfig, globalIgnores } from "eslint/config";
import prettier from "eslint-config-prettier/flat";
import globals from "globals";
import tseslint from "typescript-eslint";

export default defineConfig([
  globalIgnores(["dist/", "coverage/", "src/generated/"]),
  js.configs.recommended,
  tseslint.configs.recommendedTypeChecked,
  {
    languageOptions: {
      globals: { ...globals.browser, ...globals.node },
      parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
    },
    rules: {
      eqeqeq: ["error", "always"],
      "no-console": ["warn", { allow: ["warn", "error"] }],
      "@typescript-eslint/no-floating-promises": "error",
      "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
    },
  },
  { files: ["**/*.js", "**/*.mjs"], extends: [tseslint.configs.disableTypeChecked] },
  prettier, // last: turns off every rule that would fight Prettier
]);
Result: npx eslint . on src/devices.ts (exit code 1; path shortened)
/…/device-ui/src/devices.ts
   8:48  error    'unused' is defined but never used. Allowed unused args must match /^_/u                                                                                            @typescript-eslint/no-unused-vars
   9:3   warning  Unexpected console statement. Only these console methods are allowed: warn, error                                                                                   no-console
  10:41  error    Expected '===' and instead saw '=='                                                                                                                                 eqeqeq
  14:3   error    Promises must be awaited, end with a call to .catch, end with a call to .then with a rejection handler or be explicitly marked as ignored with the `void` operator  @typescript-eslint/no-floating-promises

✖ 4 problems (3 errors, 1 warning)
What’s happening
  1. defineConfig([...]) (from eslint/config) takes a list of config objects that apply in order, later ones overriding earlier ones. globalIgnores keeps build output and generated code (such as an API client generated from OpenAPI) out of linting entirely.
  2. The presets come first: js.configs.recommended (core JavaScript mistakes) and typescript-eslint's recommendedTypeChecked, which uses the TypeScript compiler's type information. projectService: true tells it to find the right tsconfig.json for each file; that's what lets it see that fetchDevices() returns a Promise.
  3. The team's own rules follow, with their severities: === everywhere, console.log only as a warning (with warn and error allowed), floating promises as an error, and unused variables as an error unless the argument name starts with _ (the documented way to say "unused on purpose").
  4. The output maps one-to-one onto the rules: the unused unused argument (line 8), the console.log (line 9, the only warning), == (line 10), and line 14, where fetchDevices() is called without await or .catch, so a network failure would become an unhandled rejection nobody sees. That last one is the kind of bug a human reviewer easily misses and a type-aware rule never does.
  5. Plain .js/.mjs files (configs, scripts) are linted without type information (disableTypeChecked), and eslint-config-prettier goes last to switch off any rule that would conflict with Prettier's formatting. ESLint finds problems; Prettier decides layout; they never argue.
  6. ESLint exits with code 1 because there are errors, which is what fails a hook or a CI job. In CI, run it with --max-warnings=0 so warnings can't pile up unnoticed either.
AdvancedPrettier and EditorConfig

Prettier owns formatting; EditorConfig makes editors agree on the basics (encoding, line endings, indentation) for every file type, including ones Prettier doesn't format. Prettier 3 also reads .editorconfig (checked here: changing indent_size there changed Prettier's output indentation):

.prettierrc.json
{
  "printWidth": 100,
  "singleQuote": false,
  "trailingComma": "all"
}
.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
npx prettier --check src, then --write (Prettier 3.9.10)
$ npx prettier --check src
Checking formatting...
[warn] src/retry.ts
[warn] Code style issues found in the above file. Run Prettier with --write to fix.
(exit code 1)

$ npx prettier --write src/retry.ts
src/retry.ts 16ms

$ cat src/retry.ts
export const retryPolicy = { maxAttempts: 3, backoffMs: [200, 400, 800] };

$ npx prettier --check src
Checking formatting...
All matched files use Prettier code style!
(exit code 0)
What’s happening
  1. The Prettier config is deliberately tiny: only the options the team decided to change. Every option you don't set is Prettier's default, which is the point of using an opinionated formatter: fewer decisions to argue about.
  2. .editorconfig sets lf line endings and UTF-8 for every file, which matters on a team that mixes macOS, Linux and Windows machines: CRLF line endings otherwise show up as whole-file diffs. Markdown keeps trailing whitespace because two trailing spaces are a line break there.
  3. src/retry.ts was written as export const retryPolicy = {maxAttempts:3, backoffMs : [200,400,800]}. prettier --check lists it and exits 1; that's the CI form. --write rewrites it (the 16ms is that run's timing), and the second --check passes with exit code 0.
  4. In CI you run --check (never --write, which would hide the problem); in the pre-commit hook you run --write, so developers never have to think about it.
AdvancedA pre-commit hook with lint-staged

lint-staged runs commands only on the files being committed, which keeps the hook fast enough that nobody disables it. The usual way to share the hook is husky (v9.1.7: npx husky init adds "prepare": "husky" to package.json, so the hooks are installed on npm install, and creates .husky/pre-commit, which you edit to run npx lint-staged). For this test the hook was written to .git/hooks/pre-commit by hand in a throwaway repository instead (husky itself wasn't run), with this package.json:

package.json (excerpt)
{
  "scripts": {
    "prepare": "husky"
  },
  "lint-staged": {
    "*.{ts,tsx,js,mjs}": ["eslint --fix --max-warnings=0", "prettier --write"],
    "*.{json,md,yml,css,html}": "prettier --write"
  }
}
git commit, twice (lint-staged 17.6.0; path shortened)
$ git add src/retry.ts && git commit -m "feat: retry policy"
⋯ Backing up original state…
✔ Done backing up original state (3743608)!
⋯ Running tasks for staged files…
    *.{ts,tsx,js,mjs} — 1 file
      ⋯ eslint --fix --max-warnings=0
      ⋯ prettier --write

✔ eslint --fix --max-warnings=0
✔ prettier --write

✔ Done running tasks for staged files!
⋯ Staging changes from tasks…
✔ Done staging changes from tasks!
⋯ Cleaning up temporary files…
✔ Done cleaning up temporary files!
[main cabef20] feat: retry policy
 1 file changed, 1 insertion(+)
 create mode 100644 src/retry.ts

$ git add src/status.ts && git commit -m "feat: online check"
⋯ Backing up original state…
✔ Done backing up original state (7dd8378)!
⋯ Running tasks for staged files…
    *.{ts,tsx,js,mjs} — 1 file
      ⋯ eslint --fix --max-warnings=0
      ⋯ prettier --write

✖ eslint --fix --max-warnings=0
↓ prettier --write

✖ Failed to run tasks for staged files!
↓ Skipped staging changes from tasks…
⋯ Reverting to original state because of errors…
✔ Done reverting to original state!
⋯ Cleaning up temporary files…
✔ Done cleaning up temporary files!

✖ eslint --fix --max-warnings=0:

/…/device-ui/src/status.ts
  2:17  error  Expected '===' and instead saw '=='  eqeqeq

✖ 1 problem (1 error, 0 warnings)
(exit code 1, no commit created)
What’s happening
  1. The lint-staged block maps file globs to commands. For code: eslint --fix (fix what's fixable, fail on anything left, warnings included) and then prettier --write. For config and docs: just Prettier.
  2. First commit: the badly formatted retry.ts is staged. lint-staged backs up the current state, runs ESLint (nothing to report) and Prettier (reformats the file), re-stages the fixed file, and lets the commit through. The commit contains the formatted version; the developer did nothing.
  3. Second commit: status.ts uses ==. ESLint doesn't auto-fix this ==, so it fails; lint-staged skips Prettier, restores the original state, prints ESLint's report, and the hook exits 1, so Git creates no commit.
  4. The developer fixes the line and commits again. A hook only sees staged files, so it takes seconds even in a large repository, which is what keeps people from reaching for --no-verify.
  5. Because anyone can use --no-verify (or not install the hook at all), the same checks run again in CI. The hook is the convenience; CI is the guarantee.
AdvancedThe same checks as a required CI workflow
.github/workflows/standards.yml
name: standards

on:
  pull_request:
    types: [opened, edited, synchronize, reopened]

permissions:
  contents: read

concurrency:
  group: standards-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  standards:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v7
        with:
          fetch-depth: 0 # the policy step diffs against the base branch

      - uses: actions/setup-node@v7
        with:
          node-version: 24
          cache: npm

      - run: npm ci

      - name: Format (Prettier)
        run: npx prettier --check .

      - name: Lint (ESLint, warnings fail too)
        run: npx eslint . --max-warnings=0

      - name: Types
        run: npx tsc --noEmit

      - name: PR title follows Conventional Commits
        env:
          TITLE: ${{ github.event.pull_request.title }}
        run: |
          echo "$TITLE" | grep -Eq '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-z0-9-]+\))?!?: .+' \
            || { echo "::error::PR title must look like 'type(scope): summary'"; exit 1; }

      - name: ADRs and API style guide
        run: |
          node scripts/adr-check.mjs docs/adr
          node scripts/api-lint.mjs openapi.yaml

      - name: Pull request policy
        env:
          BASE_REF: ${{ github.base_ref }}
        run: node scripts/pr-policy.mjs "origin/$BASE_REF"
What’s happening
  1. The trigger includes edited, so changing the PR title re-runs the title check. permissions: contents: read gives the job's token the least it needs; concurrency cancels a stale run when new commits are pushed, saving CI minutes.
  2. fetch-depth: 0 fetches full history; the default shallow clone has only one commit, and the policy step needs to diff the PR against its base branch.
  3. The format, lint and type steps are the same commands as the hook, but in check mode (prettier --check, --max-warnings=0), so CI never changes code; it only says yes or no.
  4. The PR title goes through an environment variable (TITLE), never directly into the script as ${{ github.event.pull_request.title }}. A PR title is attacker-controlled text; expanding it inside run: would let a title like "; curl … | sh run as a command. GitHub's security guidance (GitHub Blog, "Four tips to keep your GitHub Actions workflows secure", 2023) recommends exactly this pattern. It's checked because, with squash merging, the title becomes the commit message.
  5. The last two steps run the team's own scripts (the ADR check, the API house rules and the PR policy from this chapter, saved under scripts/). The workflow passes actionlint (1.7.12); it hasn't been run on GitHub here. actions/checkout@v7 and actions/setup-node@v7 are the current majors (latest releases v7.0.1, July 2026, and v7.1.0, October 2026, checked with gh api on 27 October 2026). It becomes binding only when a branch rule or ruleset lists the standards check as required.
AdvancedCode owners and repository rules

CODEOWNERS decides who must review what. GitHub looks for the file in .github/, the repository root, then docs/, and uses the first one it finds; the file on the PR's base branch is the one that counts; patterns follow most .gitignore rules, and the last matching pattern wins; approval from any one listed owner satisfies "Require review from Code Owners" (GitHub Docs, "About code owners", checked October 2026):

.github/CODEOWNERS
# Default owners for everything
*                @acme/web-leads

# Docs: the docs group (last match wins, so this beats *)
*.md             @acme/docs

# The API and its contract
/src/api/        @acme/api-team
/openapi.yaml    @acme/api-team

# Schema migrations need the API team and a DBA
/db/migrations/  @acme/api-team @acme/dba

Repository rulesets (or the older branch protection rules) make the checks binding. The rules most teams turn on for main, from GitHub's "Available rules for rulesets": require a pull request with approvals, code owner review, dismissal of stale approvals and resolved conversations; require status checks to pass (strict mode also requires the branch to be up to date with main); require linear history if you squash or rebase; block force pushes (on by default); restrict deletions (on by default); and optionally require signed commits, successful deployments or code scanning results. A ruleset can also restrict which merge methods are allowed.

The model below plays the part of a PR-policy CI step: it reads the PR's diff from Git, works out the required owners with last-match-wins, and applies the team's rules:

pr-policy.mjs
// Standards as a CI step: read what a pull request changed (git diff against main), work out the
// required code owners (CODEOWNERS: the last matching pattern wins) and apply the team's rules.
// Real git in a throwaway repository with fixed names and dates (Git 2.54).
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

const dir = mkdtempSync(join(tmpdir(), "git-demo-"));
let minute = 0;
const git = (...args) => {
  const date = `${1767603600 + 60 * minute++} +0000`;
  return execFileSync("git", args, {
    cwd: dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],
    env: { ...process.env, LC_ALL: "C", GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1",
      GIT_AUTHOR_NAME: "Asha", GIT_AUTHOR_EMAIL: "asha@example.com", GIT_AUTHOR_DATE: date,
      GIT_COMMITTER_NAME: "Asha", GIT_COMMITTER_EMAIL: "asha@example.com", GIT_COMMITTER_DATE: date },
  }).trimEnd();
};
const write = (file, text) => { mkdirSync(join(dir, dirname(file)), { recursive: true }); writeFileSync(join(dir, file), text); };

const CODEOWNERS = `*                @acme/web-leads
*.md             @acme/docs
/src/api/        @acme/api-team
/db/migrations/  @acme/api-team @acme/dba`;
const owners = (file) => {
  let match = [];
  for (const line of CODEOWNERS.split("\n")) {
    const [pattern, ...who] = line.trim().split(/\s+/);
    const hit = pattern === "*" || (pattern.startsWith("*.") && file.endsWith(pattern.slice(1)))
      || (pattern.startsWith("/") && file.startsWith(pattern.slice(1)));
    if (hit) match = who; // later lines override earlier ones
  }
  return match.join(" ");
};

try {
  git("-c", "init.defaultBranch=main", "init", "-q");
  for (const f of ["src/api/devices.js", "openapi.yaml", "package.json", "package-lock.json", "README.md"]) write(f, "v1\n");
  git("add", "."); git("commit", "-q", "-m", "chore: initial commit");
  git("switch", "-q", "-c", "feature/serials");
  write("src/api/devices.js", "v1\n" + "serial handling\n".repeat(420));
  write("db/migrations/0042_add_serial.sql", "ALTER TABLE devices ADD serial TEXT;\n");
  write("package.json", "v2\n"); // new dependency, but no lockfile change
  write("README.md", "v1\nserials\n");
  git("add", "."); git("commit", "-q", "-m", "feat(api): device serial numbers");

  const changed = git("diff", "--name-only", "main...HEAD").split("\n");
  const lines = git("diff", "--numstat", "main...HEAD").split("\n").reduce((n, l) => n + Number(l.split("\t")[0]) + Number(l.split("\t")[1]), 0);
  console.log("Changed files and required reviewers:");
  for (const f of changed) console.log(`  ${f.padEnd(34)} ${owners(f)}`);
  const touched = (p) => changed.some((f) => f.startsWith(p));
  const findings = [];
  if (touched("src/api/") && !touched("openapi.yaml")) findings.push("FAIL src/api/ changed but openapi.yaml did not (API-first rule)");
  if (touched("package.json") && !touched("package-lock.json")) findings.push("FAIL package.json changed without package-lock.json");
  if (touched("db/migrations/") && !touched("docs/adr/")) findings.push("WARN schema migration without an ADR: link the decision in the PR");
  if (lines > 400) findings.push(`WARN ${lines} lines changed; aim for under 400 so it can be reviewed in one sitting`);
  console.log(`\nPolicy check:\n  ${findings.join("\n  ")}`);
} finally {
  rmSync(dir, { recursive: true, force: true });
}
Result
Changed files and required reviewers:
  README.md                          @acme/docs
  db/migrations/0042_add_serial.sql  @acme/api-team @acme/dba
  package.json                       @acme/web-leads
  src/api/devices.js                 @acme/api-team

Policy check:
  FAIL src/api/ changed but openapi.yaml did not (API-first rule)
  FAIL package.json changed without package-lock.json
  WARN schema migration without an ADR: link the decision in the PR
  WARN 424 lines changed; aim for under 400 so it can be reviewed in one sitting
What’s happening
  1. The repository starts with five files on main. The PR branch changes the API, adds a migration, bumps package.json without the lockfile, and edits the README; the API change is 420 lines.
  2. git diff --name-only main...HEAD (three dots: compare with the point where the branch left main, so changes made on main meanwhile aren't counted) lists the four changed files; git diff --numstat gives added and deleted lines per file, summed to 424.
  3. Owners are resolved line by line with the last match winning: README.md matches * and then *.md, so it goes to @acme/docs only; the migration matches * then /db/migrations/, so it needs the API team and a DBA; package.json only matches *.
  4. Then the house rules: an API change without an openapi.yaml change fails (the API-first rule), a manifest change without the lockfile fails (the build wouldn't be reproducible), a migration without an ADR is a warning, and more than 400 changed lines is a warning to split the PR. The 400-line threshold is a common rule of thumb, not a law.
  5. These are exactly the comments a tired reviewer forgets to make, and the ones that feel personal when a person makes them. Coming from a bot, consistently, on every PR, they're just the standard.
AdvancedNew rules on an old codebase: suppressions and ratchets

Turning on no-floating-promises in a five-year-old codebase can produce hundreds of errors. Fixing them all first blocks the rule forever; turning it on as a warning means it's ignored. The answer is a ratchet: record today's violations, fail only on new ones, and let the count only go down. ESLint 10 has this built in as bulk suppressions (--suppress-all writes eslint-suppressions.json). Run against the four-problem file above:

npx eslint . --suppress-all, then a new == is added (ESLint 10.13.0; paths shortened)
$ npx eslint . --suppress-all
/…/device-ui/src/devices.ts
  9:3  warning  Unexpected console statement. Only these console methods are allowed: warn, error  no-console

✖ 1 problem (0 errors, 1 warning)

$ cat eslint-suppressions.json
{
  "src/devices.ts": {
    "@typescript-eslint/no-floating-promises": {
      "count": 1
    },
    "@typescript-eslint/no-unused-vars": {
      "count": 1
    },
    "eqeqeq": {
      "count": 1
    }
  }
}

$ echo 'export const isAdmin = (role: string) => role == "admin";' >> src/devices.ts
$ npx eslint .
/…/device-ui/src/devices.ts
   9:3   warning  Unexpected console statement. Only these console methods are allowed: warn, error  no-console
  10:41  error    Expected '===' and instead saw '=='                                                eqeqeq
  16:47  error    Expected '===' and instead saw '=='                                                eqeqeq

✖ 3 problems (2 errors, 1 warning)
(exit code 1)
What’s happening
  1. --suppress-all records every current error as a count per file and per rule. The three errors disappear from the output; the no-console warning stays, because suppressions only cover errors. The command exits 0, so CI can go green on day one with the new rules switched on.
  2. The suppressions file is committed. It's a visible, reviewable list of debt: eqeqeq once in src/devices.ts, and so on.
  3. Someone adds a second == to the same file. The file now has two eqeqeq errors against a suppressed count of one, so ESLint reports both (lines 10 and 16) and fails. It can't tell which one is new, so it shows every violation of that rule in that file; the developer fixes theirs (and maybe the old one).
  4. When violations are fixed, npx eslint . --prune-suppressions removes the counts that are no longer needed (checked: after fixing the ==, the eqeqeq entry disappeared from the file), so the ratchet only tightens. It's also enforced: until you prune, ESLint exits with code 2 and says "There are suppressions left that do not occur anymore" (checked with 10.13.0; --pass-on-unpruned-suppressions turns that off), so a fixed violation can't quietly stay in the allowance. The next model shows the same idea across a whole codebase, for any tool that can count problems.
lint-ratchet.mjs
// Turning on a strict rule in an old codebase without fixing 1,200 warnings first: a "ratchet".
// CI compares this PR's lint counts per rule with a committed baseline; counts may only go down.
const baseline = { "@typescript-eslint/no-explicit-any": 1214, "@typescript-eslint/no-floating-promises": 87, eqeqeq: 40 };

function ratchet(baseline, current) {
  const report = [];
  const next = { ...baseline };
  for (const rule of Object.keys({ ...baseline, ...current })) {
    const before = baseline[rule] ?? 0;
    const now = current[rule] ?? 0;
    if (now > before) report.push(`FAIL ${rule}: ${before} -> ${now} (+${now - before}); new code must not add violations`);
    else if (now < before) { report.push(`ok   ${rule}: ${before} -> ${now}; baseline tightened`); next[rule] = now; }
  }
  return { failed: report.some((l) => l.startsWith("FAIL")), report, next };
}

const prs = [
  ["PR #210 (adds a typed API client)", { "@typescript-eslint/no-explicit-any": 1214, "@typescript-eslint/no-floating-promises": 87, eqeqeq: 40 }],
  ["PR #211 (fixes promises in sync.ts)", { "@typescript-eslint/no-explicit-any": 1214, "@typescript-eslint/no-floating-promises": 79, eqeqeq: 40 }],
  ["PR #212 (copy-pastes an old module)", { "@typescript-eslint/no-explicit-any": 1221, "@typescript-eslint/no-floating-promises": 79, eqeqeq: 41 }],
];

let current = baseline;
for (const [name, counts] of prs) {
  const { failed, report, next } = ratchet(current, counts);
  console.log(`${name}: ${failed ? "blocked" : "passes"}`);
  for (const line of report) console.log(`  ${line}`);
  if (!failed) current = next; // the merged PR commits the tightened baseline
}
console.log(`\nbaseline now: ${JSON.stringify(current)}`);
Result
PR #210 (adds a typed API client): passes
PR #211 (fixes promises in sync.ts): passes
  ok   @typescript-eslint/no-floating-promises: 87 -> 79; baseline tightened
PR #212 (copy-pastes an old module): blocked
  FAIL @typescript-eslint/no-explicit-any: 1214 -> 1221 (+7); new code must not add violations
  FAIL eqeqeq: 40 -> 41 (+1); new code must not add violations

baseline now: {"@typescript-eslint/no-explicit-any":1214,"@typescript-eslint/no-floating-promises":79,"eqeqeq":40}
What’s happening
  1. The baseline is committed: 1,214 no-explicit-any, 87 floating promises and 40 == across the codebase. ratchet() compares each rule's count in this PR with the baseline.
  2. PR #210 adds a typed API client without new violations: passes, nothing changes.
  3. PR #211 fixes eight floating promises: passes, and the baseline tightens to 79 when it merges, so those eight can never come back silently.
  4. PR #212 copy-pastes an old module and adds seven anys and one ==: blocked, with the exact rule and delta, while the improvements from #211 stay locked in. The final baseline shows only #211's tightening.
  5. This works for anything countable: TypeScript strict errors while migrating, accessibility issues, bundle size, test coverage (where the ratchet goes up instead of down).
Hook, CI or review?

Where should each check live: (a) Prettier formatting, (b) the full end-to-end browser test suite (12 minutes), (c) "this endpoint should be paginated", (d) no secrets committed?

Show answer

(a) hook and CI; (b) CI only; (c) API lint in CI plus review; (d) hook and CI, plus provider-side secret scanning.

What’s happening
  1. (a) Formatting is fast and auto-fixable: prettier --write in the pre-commit hook so it's never a problem, and prettier --check in CI because hooks can be skipped.
  2. (b) Twelve minutes is far too slow for a hook (people will bypass it); it belongs in CI, ideally in parallel shards, as a required check on PRs or at least before deploy.
  3. (c) Partly mechanical, partly judgement: an API lint rule ("collection GETs take limit and cursor") catches the common case in CI, and a reviewer decides the exceptions (a list that can never exceed ten items).
  4. (d) Secrets are the case for every layer: a fast secret scan in the hook catches the mistake before it leaves the laptop, a CI scan catches what skipped the hook, and the Git host's own secret scanning catches what reached the server. A secret that was pushed must be rotated, not just deleted, because it stays in Git history.

Written fresh for a lead full stack role. Git examples were run; GitHub Actions workflows were checked with actionlint. Behavioural stories are yours to write in My notes.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.